├── tmp └── .gitkeep ├── spec ├── fixtures │ ├── empty_array.json │ ├── bearer_token.json │ ├── long.png │ ├── me.jpg │ ├── we_concept_bg2.png │ ├── empty_cursor.json │ ├── friends_ids.json │ ├── checkip.html │ ├── muted_ids.json │ ├── followers_ids.json │ ├── rate_limit_status.json │ ├── upload.json │ ├── ids_list.json │ ├── ids_list2.json │ ├── request_token │ ├── access_token │ ├── not_found.json │ ├── direct_message_event.json │ ├── .trc │ ├── following.json │ ├── not_following.json │ ├── settings.json │ ├── .trc_set │ ├── trends.json │ ├── locations.json │ ├── geoplugin.xml │ ├── list.json │ ├── gem.json │ ├── sferik.json │ ├── status_no_place.json │ ├── status_with_mention.json │ ├── muted_users.json │ ├── status_no_full_name.json │ ├── direct_message.json │ ├── status_no_country.json │ ├── status_no_attributes.json │ ├── status_no_locality.json │ ├── status_no_street_address.json │ ├── status.json │ ├── retweet.json │ ├── users.json │ ├── users_list.json │ ├── 501_ids.json │ ├── lists.json │ ├── recommendations.json │ ├── geo_no_state.json │ ├── geo_no_city.json │ └── direct_message_events.json ├── helper.rb ├── editor_spec.rb ├── utils_spec.rb ├── rcfile_spec.rb ├── set_spec.rb └── stream_spec.rb ├── .rspec ├── .github ├── FUNDING.yml └── workflows │ └── ruby.yml ├── icon ├── t.png └── t.psd ├── screenshots ├── list.png ├── history.png └── timeline.png ├── .gitignore ├── lib ├── t │ ├── core_ext │ │ ├── string.rb │ │ └── kernel.rb │ ├── version.rb │ ├── requestable.rb │ ├── editor.rb │ ├── identicon.rb │ ├── collectable.rb │ ├── set.rb │ ├── rcfile.rb │ ├── utils.rb │ ├── delete.rb │ ├── list.rb │ ├── printable.rb │ ├── stream.rb │ └── search.rb └── t.rb ├── Gemfile ├── Rakefile ├── LICENSE.md ├── bin └── t ├── t.gemspec ├── CONTRIBUTING.md ├── .rubocop.yml ├── tasks ├── zsh.rake └── bash.rake └── README.md /tmp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/fixtures/empty_array.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [sferik] 2 | -------------------------------------------------------------------------------- /icon/t.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/icon/t.png -------------------------------------------------------------------------------- /icon/t.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/icon/t.psd -------------------------------------------------------------------------------- /screenshots/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/screenshots/list.png -------------------------------------------------------------------------------- /spec/fixtures/bearer_token.json: -------------------------------------------------------------------------------- 1 | {"token_type":"bearer","access_token":"AAAA%2FAAA%3DAAAAAAAA"} -------------------------------------------------------------------------------- /spec/fixtures/long.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/spec/fixtures/long.png -------------------------------------------------------------------------------- /spec/fixtures/me.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/spec/fixtures/me.jpg -------------------------------------------------------------------------------- /screenshots/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/screenshots/history.png -------------------------------------------------------------------------------- /screenshots/timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/screenshots/timeline.png -------------------------------------------------------------------------------- /spec/fixtures/we_concept_bg2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sferik/t-ruby/HEAD/spec/fixtures/we_concept_bg2.png -------------------------------------------------------------------------------- /spec/fixtures/empty_cursor.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor_str":"0","next_cursor":0,"ids":[],"previous_cursor":0,"next_cursor_str":"0"} -------------------------------------------------------------------------------- /spec/fixtures/friends_ids.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor_str":"0","next_cursor":0,"ids":[7505382],"previous_cursor":0,"next_cursor_str":"0"} -------------------------------------------------------------------------------- /spec/fixtures/checkip.html: -------------------------------------------------------------------------------- 1 | Current IP CheckCurrent IP Address: 50.131.22.169 2 | -------------------------------------------------------------------------------- /spec/fixtures/muted_ids.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor_str":"0","next_cursor":0,"ids":[14098423],"previous_cursor":0,"next_cursor_str":"0"} 2 | -------------------------------------------------------------------------------- /spec/fixtures/followers_ids.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor_str":"0","next_cursor":0,"ids":[213747670,428004849],"previous_cursor":0,"next_cursor_str":"0"} -------------------------------------------------------------------------------- /spec/fixtures/rate_limit_status.json: -------------------------------------------------------------------------------- 1 | {"remaining_hits":19993,"hourly_limit":20000,"reset_time_in_seconds":1288060988,"reset_time":"Tue Oct 26 12:43:08 +0000 2010"} -------------------------------------------------------------------------------- /spec/fixtures/upload.json: -------------------------------------------------------------------------------- 1 | {"image":{"w":428,"h":428,"image_type":"image\/png"},"media_id":470030289822314497,"media_id_string":"470030289822314497","size":68900} -------------------------------------------------------------------------------- /spec/fixtures/ids_list.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor":0,"next_cursor_str":"1305102810874389703","ids":[20009713],"previous_cursor_str":"0","next_cursor":1305102810874389703} -------------------------------------------------------------------------------- /spec/fixtures/ids_list2.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor":-1305101990888327757,"next_cursor_str":"0","ids":[14100886],"previous_cursor_str":"-1305101990888327757","next_cursor":0} -------------------------------------------------------------------------------- /spec/fixtures/request_token: -------------------------------------------------------------------------------- 1 | oauth_token=Z6eEdO8MOmk394WozF5oKyuAv855l4Mlqo7hhlSLik&oauth_token_secret=Kd75W4OQfb2oJTV0vzGzeXftVAwgMnEK9MumzYcM&oauth_callback_confirmed=true -------------------------------------------------------------------------------- /spec/fixtures/access_token: -------------------------------------------------------------------------------- 1 | oauth_token=6253282-eWudHldSbIaelX7swmsiHImEL4KinwaGloHANdrY&oauth_token_secret=2EEfA6BG3ly3sR3RjE0IBSnlQu4ZrUzPiYKmrkVU&user_id=6253282&screen_name=twitterapi -------------------------------------------------------------------------------- /spec/fixtures/not_found.json: -------------------------------------------------------------------------------- 1 | {"error":"The specified user is not a member of this list","request":"/1/lists/members/show.json?owner_screen_name=sferik&slug=presidents&screen_name=sferik"} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *~ 4 | *.swp 5 | *.swo 6 | .bundle 7 | .rvmrc 8 | .yardoc 9 | Gemfile.lock 10 | coverage/* 11 | doc/* 12 | log/* 13 | pkg/* 14 | tmp/* 15 | .DS_Store 16 | .idea 17 | -------------------------------------------------------------------------------- /lib/t/core_ext/string.rb: -------------------------------------------------------------------------------- 1 | class String 2 | def prepend_at 3 | "@#{self}" 4 | end 5 | 6 | def strip_ats 7 | tr("@", "") 8 | end 9 | 10 | alias old_to_i to_i 11 | 12 | def to_i(base = 10) 13 | tr(",", "").old_to_i(base) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/t/version.rb: -------------------------------------------------------------------------------- 1 | module T 2 | class Version 3 | MAJOR = 4 4 | MINOR = 2 5 | PATCH = 0 6 | PRE = nil 7 | 8 | class << self 9 | # @return [String] 10 | def to_s 11 | [MAJOR, MINOR, PATCH, PRE].compact.join(".") 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/fixtures/direct_message_event.json: -------------------------------------------------------------------------------- 1 | {"event":{"type":"message_create","id":"1006278767680131076","created_timestamp":"1528750528627","message_create":{"target":{"recipient_id":"58983"},"sender_id":"124294236","message_data":{"text":"testing","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}}} 2 | -------------------------------------------------------------------------------- /spec/fixtures/.trc: -------------------------------------------------------------------------------- 1 | --- 2 | configuration: 3 | default_profile: 4 | - testcli 5 | - abc123 6 | profiles: 7 | testcli: 8 | abc123: 9 | consumer_key: abc123 10 | secret: epzrjvxtumoc 11 | token: 428004849-cebdct6bwobn 12 | username: testcli 13 | consumer_secret: asdfasd223sd2 14 | -------------------------------------------------------------------------------- /lib/t/core_ext/kernel.rb: -------------------------------------------------------------------------------- 1 | module Kernel 2 | def Bignum(arg, base = 0) # rubocop:disable Naming/MethodName 3 | Integer(arg, base) 4 | end 5 | 6 | def Fixnum(arg, base = 0) # rubocop:disable Naming/MethodName 7 | Integer(arg, base) 8 | end 9 | 10 | def NilClass(_) # rubocop:disable Naming/MethodName 11 | nil 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/fixtures/following.json: -------------------------------------------------------------------------------- 1 | {"relationship":{"target":{"followed_by":true,"id_str":"14100886","following":false,"screen_name":"pengwynn","id":14100886},"source":{"marked_spam":false,"notifications_enabled":false,"followed_by":false,"want_retweets":true,"id_str":"7505382","blocking":false,"all_replies":false,"following":true,"screen_name":"sferik","id":7505382}}} -------------------------------------------------------------------------------- /spec/fixtures/not_following.json: -------------------------------------------------------------------------------- 1 | {"relationship":{"target":{"followed_by":false,"id_str":"14100886","following":true,"screen_name":"sferik","id":7505382},"source":{"marked_spam":false,"notifications_enabled":false,"followed_by":true,"want_retweets":true,"id_str":"7505382","blocking":false,"all_replies":false,"following":false,"screen_name":"pengwynn","id":14100886}}} -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rake" 4 | 5 | group :development do 6 | gem "pry" 7 | end 8 | 9 | group :test do 10 | gem "csv" 11 | gem "rb-readline", ">= 0.5.5" 12 | gem "rspec", ">= 3" 13 | gem "rubocop", ">= 0.79" 14 | gem "rubocop-performance" 15 | gem "rubocop-rake" 16 | gem "rubocop-rspec" 17 | gem "simplecov", ">= 0.16" 18 | gem "timecop" 19 | gem "tins" 20 | gem "webmock", ">= 3.8.2" 21 | end 22 | 23 | gemspec 24 | -------------------------------------------------------------------------------- /spec/fixtures/settings.json: -------------------------------------------------------------------------------- 1 | {"language":"en","discoverable_by_email":true,"trend_location":[{"url":"http://where.yahooapis.com/v1/place/23424803","parentid":1,"name":"Ireland","countryCode":"IE","placeType":{"name":"Country","code":12},"woeid":23424803,"country":"Ireland"}],"sleep_time":{"enabled":false,"start_time":0,"end_time":0},"geo_enabled":true,"time_zone":{"name":"Eastern Time (US & Canada)","utc_offset":-18000,"tzinfo_name":"America/New_York"},"always_use_https":true} -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler::GemHelper.install_tasks 3 | 4 | require "rspec/core/rake_task" 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | begin 8 | require "rubocop/rake_task" 9 | RuboCop::RakeTask.new 10 | rescue LoadError 11 | desc "Run RuboCop" 12 | task :rubocop do 13 | warn "Rubocop is disabled" 14 | end 15 | end 16 | 17 | Dir.glob("tasks/*.rake").each { |r| import r } 18 | 19 | task release: ["completion:zsh", "completion:bash"] 20 | task test: :spec 21 | task default: %i[spec rubocop] 22 | -------------------------------------------------------------------------------- /spec/fixtures/.trc_set: -------------------------------------------------------------------------------- 1 | --- 2 | configuration: 3 | default_profile: 4 | - testcli 5 | - abc123 6 | profiles: 7 | testcli: 8 | abc123: 9 | consumer_key: abc123 10 | secret: epzrjvxtumoc 11 | token: 428004849-cebdct6bwobn 12 | username: testcli 13 | consumer_secret: asdfasd223sd2 14 | testcli1: 15 | abc123: 16 | consumer_key: abc123 17 | secret: epzrjvxtumoc 18 | token: 428004849-cebdct6bwobn 19 | username: testcli 20 | consumer_secret: asdfasd223sd2 21 | -------------------------------------------------------------------------------- /spec/fixtures/trends.json: -------------------------------------------------------------------------------- 1 | [{"as_of":"2010-10-25T14:49:50Z","created_at":"2010-10-25T14:41:13Z","trends":[{"promoted_content":null,"query":"%23sevenwordsaftersex","url":"http://search.twitter.com/search?q=%23sevenwordsaftersex","name":"#sevenwordsaftersex","events":null},{"promoted_content":null,"query":"Walkman","url":"http://search.twitter.com/search?q=Walkman","name":"Walkman","events":null},{"promoted_content":null,"query":"Allen+Iverson","url":"http://search.twitter.com/search?q=Allen+Iverson","name":"Allen Iverson","events":null}],"locations":[{"woeid":1,"name":"Worldwide"}]}] -------------------------------------------------------------------------------- /lib/t/requestable.rb: -------------------------------------------------------------------------------- 1 | require "twitter" 2 | 3 | module T 4 | module Requestable 5 | private 6 | 7 | def client 8 | return @client if @client 9 | 10 | @rcfile.path = options["profile"] if options["profile"] 11 | @client = Twitter::REST::Client.new do |config| 12 | config.consumer_key = @rcfile.active_consumer_key 13 | config.consumer_secret = @rcfile.active_consumer_secret 14 | config.access_token = @rcfile.active_token 15 | config.access_token_secret = @rcfile.active_secret 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | ruby-version: ['3.2', '3.3', '3.4'] 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby-version }} 25 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 26 | - name: Run tests 27 | run: bundle exec rspec spec 28 | -------------------------------------------------------------------------------- /lib/t.rb: -------------------------------------------------------------------------------- 1 | require "t/cli" 2 | require "time" 3 | 4 | module T 5 | class << self 6 | # Convert time to local time by applying the `utc_offset` setting. 7 | def local_time(time) 8 | time = time.dup 9 | utc_offset ? (time.utc + utc_offset) : time.localtime 10 | end 11 | 12 | # UTC offset in seconds to apply time instances before displaying. 13 | # If not set, time instances are displayed in default local time. 14 | attr_reader :utc_offset 15 | 16 | def utc_offset=(offset) 17 | @utc_offset = case offset 18 | when String 19 | Time.zone_offset(offset) 20 | when NilClass 21 | nil 22 | else 23 | offset.to_i 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/t/editor.rb: -------------------------------------------------------------------------------- 1 | require "tempfile" 2 | require "shellwords" 3 | 4 | module T 5 | class Editor 6 | class << self 7 | def gets 8 | file = tempfile 9 | edit(file.path) 10 | File.read(file).strip 11 | ensure 12 | file.close 13 | file.unlink 14 | end 15 | 16 | def tempfile 17 | Tempfile.new("TWEET_EDITMSG") 18 | end 19 | 20 | def edit(path) 21 | system(Shellwords.join([editor, path])) 22 | end 23 | 24 | def editor 25 | ENV["VISUAL"] || ENV["EDITOR"] || system_editor 26 | end 27 | 28 | def system_editor 29 | /mswin|mingw/.match?(RbConfig::CONFIG["host_os"]) ? "notepad" : "vi" 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/fixtures/locations.json: -------------------------------------------------------------------------------- 1 | [{"name":"Worldwide","placeType":{"code":19,"name":"Supername"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1","parentid":0,"country":"","woeid":1,"countryCode":null},{"name":"San Francisco","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2487956","parentid":23424977,"country":"United States","woeid":2487956,"countryCode":"US"},{"name":"United States","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424977","parentid":1,"country":"United States","woeid":23424977,"countryCode":"US"},{"name":"Soweto","placeType":{"code":22,"name":"Unknown"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1587677","parentid":23424942,"country":"South Africa","woeid":1587677,"countryCode":"ZA"}] -------------------------------------------------------------------------------- /spec/fixtures/geoplugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | San Francisco 4 | CA 5 | 415 6 | 807 7 | US 8 | United States 9 | NA 10 | 37.76969909668 11 | -122.39330291748 12 | CA 13 | California 14 | USD 15 | &#36; 16 | 1 17 | 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2025 Erik Berlin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/t/identicon.rb: -------------------------------------------------------------------------------- 1 | module T 2 | class Identicon 3 | # Six-bit number (0-63) 4 | attr_accessor :bits 5 | 6 | # Eight-bit number (0-255) 7 | attr_accessor :color 8 | 9 | def initialize(number) 10 | # Bottom six bits 11 | @bits = number & 0x3f 12 | 13 | # Next highest eight bits 14 | @fcolor = (number >> 6) & 0xff 15 | 16 | # Next highest eight bits 17 | @bcolor = (number >> 14) & 0xff 18 | end 19 | 20 | def lines 21 | ["#{bg @bits[0]} #{bg @bits[1]} #{bg @bits[0]} #{reset}", 22 | "#{bg @bits[2]} #{bg @bits[3]} #{bg @bits[2]} #{reset}", 23 | "#{bg @bits[4]} #{bg @bits[5]} #{bg @bits[4]} #{reset}"] 24 | end 25 | 26 | private 27 | 28 | def reset 29 | "\033[0m" 30 | end 31 | 32 | def bg(bit) 33 | bit.zero? ? "\033[48;5;#{@bcolor}m" : "\033[48;5;#{@fcolor}m" 34 | end 35 | end 36 | 37 | class << Identicon 38 | def for_user_name(string) 39 | Identicon.new(digest(string)) 40 | end 41 | 42 | private 43 | 44 | def digest(string) 45 | require "digest" 46 | Digest::MD5.digest(string).chars.inject(0) { |acc, elem| (acc << 8) | elem.ord } 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/t/collectable.rb: -------------------------------------------------------------------------------- 1 | require "twitter" 2 | require "retryable" 3 | 4 | module T 5 | module Collectable 6 | MAX_NUM_RESULTS = 200 7 | MAX_PAGE = 51 8 | 9 | def collect_with_max_id(collection = [], max_id = nil, &) 10 | tweets = Retryable.retryable(tries: 3, on: Twitter::Error, sleep: 0) do 11 | yield(max_id) 12 | end 13 | return collection if tweets.nil? 14 | 15 | collection += tweets 16 | tweets.empty? ? collection.flatten : collect_with_max_id(collection, tweets.last.id - 1, &) 17 | end 18 | 19 | def collect_with_count(count) 20 | opts = {} 21 | opts[:count] = MAX_NUM_RESULTS 22 | collect_with_max_id do |max_id| 23 | opts[:max_id] = max_id unless max_id.nil? 24 | opts[:count] = count unless count >= MAX_NUM_RESULTS 25 | if count.positive? 26 | tweets = yield opts 27 | count -= tweets.length 28 | tweets 29 | end 30 | end.flatten.compact 31 | end 32 | 33 | def collect_with_page(collection = ::Set.new, page = 1, previous = nil, &) 34 | tweets = Retryable.retryable(tries: 3, on: Twitter::Error, sleep: 0) do 35 | yield page 36 | end 37 | return collection if tweets.nil? || tweets == previous || page >= MAX_PAGE 38 | 39 | collection += tweets 40 | tweets.empty? ? collection.flatten : collect_with_page(collection, page + 1, tweets, &) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bin/t: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Trap interrupts to quit cleanly. See 4 | # https://twitter.com/mitchellh/status/283014103189053442 5 | Signal.trap("INT") { abort } 6 | 7 | require "oauth" 8 | require "t" 9 | require "twitter" 10 | 11 | # Output message to $stderr, prefixed with the program name 12 | def pute(*args) 13 | first = args.shift.dup 14 | first.insert(0, "#{$PROGRAM_NAME}: ") 15 | args.unshift(first) 16 | abort(args.join("\n")) 17 | end 18 | 19 | begin 20 | T::CLI.start(ARGV) 21 | rescue Interrupt 22 | pute "Quitting..." 23 | rescue OAuth::Unauthorized 24 | pute "Authorization failed" 25 | rescue Twitter::Error::TooManyRequests => e 26 | pute e.message, "The rate limit for this request will reset in #{e.rate_limit.reset_in} #{e.rate_limit.reset_in == 1 ? 'second' : 'seconds'}." 27 | rescue Twitter::Error::BadRequest => e 28 | pute e.message, "Run `t authorize` to authorize." 29 | rescue Twitter::Error::Forbidden, Twitter::Error::Unauthorized => e 30 | if ["Error processing your OAuth request: Read-only application cannot POST", "This application is not allowed to access or delete your direct messages"].include?(e.message) 31 | warn(%q(Make sure to set your Twitter application's Access Level to "Read, Write and Access direct messages".)) 32 | require "thor" 33 | Thor::Shell::Basic.new.ask "Press [Enter] to open the Twitter Developer site." 34 | require "launchy" 35 | Launchy.open("https://dev.twitter.com/apps") { |u, _, _| warn "Manually open #{u}" } 36 | else 37 | pute e.message 38 | end 39 | rescue Twitter::Error => e 40 | pute e.message 41 | end 42 | -------------------------------------------------------------------------------- /spec/fixtures/list.json: -------------------------------------------------------------------------------- 1 | {"name":"presidents","full_name":"@sferik/presidents","member_count":2,"description":"Presidents of the United States of America","mode":"public","uri":"/sferik/presidents","user":{"id":7505382,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","url":"https://github.com/sferik","created_at":"Mon Jul 16 12:59:01 +0000 2007","followers_count":2126,"default_profile":false,"profile_background_color":"000000","lang":"en","utc_offset":-28800,"name":"Erik Michaels-Ober","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","location":"San Francisco","profile_link_color":"0084B4","listed_count":115,"verified":false,"protected":false,"profile_use_background_image":true,"is_translator":false,"following":null,"description":"My heart is in the work.","profile_text_color":"333333","statuses_count":6951,"screen_name":"sferik","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","time_zone":"Pacific Time (US & Canada)","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","friends_count":204,"default_profile_image":false,"contributors_enabled":false,"profile_sidebar_border_color":"C0DEED","id_str":"7505382","geo_enabled":true,"favourites_count":3124,"profile_background_tile":false,"notifications":null,"show_all_inline_media":true,"profile_sidebar_fill_color":"DDEEF6","follow_request_sent":null},"id_str":"8863586","subscriber_count":1,"created_at":"Mon Mar 15 12:10:13 +0000 2010","following":false,"slug":"presidents","id":8863586} -------------------------------------------------------------------------------- /t.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "t/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.add_dependency "geokit", "~> 1.14" 9 | spec.add_dependency "htmlentities", "~> 4.3" 10 | spec.add_dependency "launchy", "~> 3.0" 11 | spec.add_dependency "oauth", "~> 1.1" 12 | spec.add_dependency "retryable", "~> 3.0" 13 | spec.add_dependency "thor", "~> 1.3" 14 | spec.add_dependency "twitter", "~> 8.2" 15 | spec.author = "Erik Berlin" 16 | spec.description = "A command-line power tool for Twitter." 17 | spec.email = "sferik@gmail.com" 18 | spec.executables = Dir["bin/*"].map { |f| File.basename(f) } 19 | spec.files = %w[CONTRIBUTING.md LICENSE.md README.md t.gemspec] + Dir["bin/*"] + Dir["lib/**/*.rb"] 20 | spec.homepage = "http://sferik.github.com/t/" 21 | spec.licenses = %w[MIT] 22 | 23 | spec.metadata = { 24 | "allowed_push_host" => "https://rubygems.org", 25 | "bug_tracker_uri" => "https://github.com/sferik/t-ruby/issues", 26 | "changelog_uri" => "https://github.com/sferik/t-ruby/blob/master/CHANGELOG.md", 27 | "documentation_uri" => "https://rubydoc.info/gems/t/", 28 | "funding_uri" => "https://github.com/sponsors/sferik/", 29 | "homepage_uri" => spec.homepage, 30 | "rubygems_mfa_required" => "true", 31 | "source_code_uri" => "https://github.com/sferik/t-ruby", 32 | } 33 | 34 | spec.name = "t" 35 | spec.require_paths = %w[lib] 36 | spec.required_ruby_version = ">= 3.2" 37 | spec.summary = "CLI for Twitter" 38 | spec.version = T::Version 39 | end 40 | -------------------------------------------------------------------------------- /spec/fixtures/gem.json: -------------------------------------------------------------------------------- 1 | {"time_zone":"Pacific Time (US & Canada)","profile_text_color":"333333","protected":false,"id_str":"213747670","default_profile":false,"contributors_enabled":false,"following":true,"profile_background_image_url":"http://a0.twimg.com/images/themes/theme15/bg.png","followers_count":545,"profile_image_url":"http://a1.twimg.com/profile_images/1163402051/ruby_normal.png","name":"The Ruby Gem","profile_link_color":"0084B4","profile_image_url_https":"https://si0.twimg.com/profile_images/1163402051/ruby_normal.png","listed_count":43,"utc_offset":-28800,"created_at":"Tue Nov 09 18:03:22 +0000 2010","description":"Ruby wrapper for the Twitter API","notifications":false,"profile_background_color":"022330","statuses_count":32,"profile_background_tile":false,"status":{"contributors":null,"coordinates":null,"id_str":"142615165530152960","retweet_count":2,"in_reply_to_user_id":null,"favorited":true,"created_at":"Fri Dec 02 14:44:39 +0000 2011","possibly_sensitive":false,"in_reply_to_screen_name":null,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"retweeted":true,"truncated":false,"in_reply_to_user_id_str":null,"place":null,"source":"The Ruby Gem","id":142615165530152960,"geo":null,"text":"Version 2.0.1 has been released. Adds Rails 2.3 compatibility. If you were waiting to upgrade to 2.0, now's the time! http://t.co/cCwpxpFv"},"profile_sidebar_fill_color":"C0DFEC","default_profile_image":false,"follow_request_sent":false,"lang":"en","geo_enabled":false,"friends_count":6,"profile_sidebar_border_color":"a8c7f7","location":"","is_translator":false,"show_all_inline_media":false,"screen_name":"gem","id":213747670,"verified":false,"profile_use_background_image":true,"profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme15/bg.png","favourites_count":4,"url":"http://rubygems.org/gems/twitter"} -------------------------------------------------------------------------------- /spec/fixtures/sferik.json: -------------------------------------------------------------------------------- 1 | {"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"Vagabond.","url":"https://github.com/sferik","protected":false,"followers_count":2262,"friends_count":212,"listed_count":118,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3755,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":7890,"lang":"en","status":{"created_at":"Sun Jul 08 18:29:20 +0000 2012","id":222034648631484416,"id_str":"222034648631484416","text":"@goldman You're near my home town! Say hi to Woodstock for me.","source":"Twitter for Mac","truncated":false,"in_reply_to_status_id":222012135281143809,"in_reply_to_status_id_str":"222012135281143809","in_reply_to_user_id":291,"in_reply_to_user_id_str":"291","in_reply_to_screen_name":"goldman","geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"favorited":false,"retweeted":false},"contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false} -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | ENV["THOR_COLUMNS"] = "80" 2 | 3 | require "simplecov" 4 | 5 | SimpleCov.start do 6 | add_filter "/spec" 7 | minimum_coverage(99.18) 8 | end 9 | 10 | require "t" 11 | require "json" 12 | require "readline" 13 | require "rspec" 14 | require "timecop" 15 | require "webmock/rspec" 16 | 17 | RSpec.configure do |config| 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | 22 | config.before do 23 | stub_post("/oauth2/token").with(body: "grant_type=client_credentials").to_return(body: fixture("bearer_token.json"), headers: {content_type: "application/json; charset=utf-8"}) 24 | end 25 | end 26 | 27 | def a_delete(path, endpoint = "https://api.twitter.com") 28 | a_request(:delete, endpoint + path) 29 | end 30 | 31 | def a_get(path, endpoint = "https://api.twitter.com") 32 | a_request(:get, endpoint + path) 33 | end 34 | 35 | def a_post(path, endpoint = "https://api.twitter.com") 36 | a_request(:post, endpoint + path) 37 | end 38 | 39 | def a_put(path, endpoint = "https://api.twitter.com") 40 | a_request(:put, endpoint + path) 41 | end 42 | 43 | def stub_delete(path, endpoint = "https://api.twitter.com") 44 | stub_request(:delete, endpoint + path) 45 | end 46 | 47 | def stub_get(path, endpoint = "https://api.twitter.com") 48 | stub_request(:get, endpoint + path) 49 | end 50 | 51 | def stub_post(path, endpoint = "https://api.twitter.com") 52 | stub_request(:post, endpoint + path) 53 | end 54 | 55 | def stub_put(path, endpoint = "https://api.twitter.com") 56 | stub_request(:put, endpoint + path) 57 | end 58 | 59 | def project_path 60 | File.expand_path("..", __dir__) 61 | end 62 | 63 | def fixture_path 64 | File.expand_path("fixtures", __dir__) 65 | end 66 | 67 | def fixture(file) 68 | File.new("#{fixture_path}/#{file}") 69 | end 70 | 71 | def tweet_from_fixture(file) 72 | Twitter::Tweet.new(JSON.parse(fixture(file).read, symbolize_names: true)) 73 | end 74 | -------------------------------------------------------------------------------- /spec/fixtures/status_no_place.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092551,"id_str":"55709764298092551","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/status_with_mention.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Thu Nov 01 01:23:39 +0000 2012","id":263813522369159169,"id_str":"263813522369159169","text":"@sferik Excellent","source":"Tweetbot for iOS","truncated":false,"in_reply_to_status_id":263810294395072513,"in_reply_to_status_id_str":"263810294395072513","in_reply_to_user_id":7505382,"in_reply_to_user_id_str":"7505382","in_reply_to_screen_name":"sferik","user":{"id":19110970,"id_str":"19110970","name":"Josh French","screen_name":"joshfrench","location":"San Francisco","description":"B, B+ tops","url":"http://joshfrench.tumblr.com","entities":{"url":{"urls":[{"url":"http://joshfrench.tumblr.com","expanded_url":null,"indices":[0,28]}]},"description":{"urls":[]}},"protected":false,"followers_count":464,"friends_count":375,"listed_count":27,"created_at":"Sat Jan 17 14:14:16 +0000 2009","favourites_count":540,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":4678,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/2163716426/medium_normal.jpg","profile_image_url_https":"https://si0.twimg.com/profile_images/2163716426/medium_normal.jpg","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"default_profile":true,"default_profile_image":false,"following":true,"follow_request_sent":false,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"entities":{"hashtags":[],"urls":[],"user_mentions":[{"screen_name":"sferik","name":"Erik Michaels-Ober","id":7505382,"id_str":"7505382","indices":[0,7]}]},"favorited":false,"retweeted":false} -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | In the spirit of [free software][free-sw], **everyone** is encouraged to help 3 | improve this project. 4 | 5 | [free-sw]: http://www.fsf.org/licensing/essays/free-sw.html 6 | 7 | Here are some ways *you* can contribute: 8 | 9 | * by using alpha, beta, and prerelease versions 10 | * by reporting bugs 11 | * by suggesting new features 12 | * by writing or editing documentation 13 | * by writing specifications 14 | * by writing code (**no patch is too small**: fix typos, add comments, clean up 15 | inconsistent whitespace) 16 | * by refactoring code 17 | * by fixing [issues][] 18 | * by reviewing patches 19 | * [financially][gittip] 20 | 21 | [issues]: https://github.com/sferik/t/issues 22 | [gittip]: https://www.gittip.com/sferik/ 23 | 24 | ## Submitting an Issue 25 | We use the [GitHub issue tracker][issues] to track bugs and features. Before 26 | submitting a bug report or feature request, check to make sure it hasn't 27 | already been submitted. When submitting a bug report, please include a [Gist][] 28 | that includes a stack trace and any details that may be necessary to reproduce 29 | the bug, including your gem version, Ruby version, and operating system. 30 | Ideally, a bug report should include a pull request with failing specs. 31 | 32 | [gist]: https://gist.github.com/ 33 | 34 | ## Submitting a Pull Request 35 | 1. [Fork the repository.][fork] 36 | 2. [Create a topic branch.][branch] 37 | 3. Add specs for your unimplemented feature or bug fix. 38 | 4. Run `bundle exec rake spec`. If your specs pass, return to step 3. 39 | 5. Implement your feature or bug fix. 40 | 6. Run `bundle exec rake default`. If your specs fail, return to step 5. 41 | 7. Run `open coverage/index.html`. If your changes are not completely covered 42 | by your tests, return to step 3. 43 | 8. Add, commit, and push your changes. 44 | 9. [Submit a pull request.][pr] 45 | 46 | [fork]: http://help.github.com/fork-a-repo/ 47 | [branch]: http://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ 48 | [pr]: http://help.github.com/send-pull-requests/ 49 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - rubocop-performance 3 | - rubocop-rake 4 | - rubocop-rspec 5 | 6 | AllCops: 7 | TargetRubyVersion: 3.2 8 | NewCops: enable 9 | 10 | Layout/AccessModifierIndentation: 11 | EnforcedStyle: outdent 12 | 13 | Layout/CaseIndentation: 14 | EnforcedStyle: end 15 | 16 | Layout/DotPosition: 17 | EnforcedStyle: trailing 18 | 19 | Layout/EndAlignment: 20 | EnforcedStyleAlignWith: variable 21 | 22 | Layout/LineLength: 23 | AllowURI: true 24 | Enabled: false 25 | 26 | Layout/SpaceInsideHashLiteralBraces: 27 | EnforcedStyle: no_space 28 | 29 | Layout/TrailingWhitespace: 30 | Enabled: false 31 | 32 | Metrics/AbcSize: 33 | Enabled: false 34 | 35 | Metrics/BlockNesting: 36 | Max: 2 37 | 38 | Metrics/BlockLength: 39 | Enabled: false 40 | 41 | Metrics/ClassLength: 42 | Enabled: false 43 | 44 | Metrics/CyclomaticComplexity: 45 | Max: 11 # TODO: Lower to 6 46 | 47 | Metrics/MethodLength: 48 | Enabled: false 49 | 50 | Metrics/ParameterLists: 51 | Max: 4 52 | CountKeywordArgs: true 53 | 54 | Metrics/PerceivedComplexity: 55 | Max: 28 # TODO: Lower to 7 56 | 57 | Naming/HeredocDelimiterNaming: 58 | Enabled: false 59 | 60 | Style/CollectionMethods: 61 | PreferredMethods: 62 | map: 'collect' 63 | reduce: 'inject' 64 | find: 'detect' 65 | find_all: 'select' 66 | 67 | Style/Documentation: 68 | Enabled: false 69 | 70 | Style/DoubleNegation: 71 | Enabled: false 72 | 73 | Style/EachWithObject: 74 | Enabled: false 75 | 76 | Style/Encoding: 77 | Enabled: false 78 | 79 | Style/FrozenStringLiteralComment: 80 | Enabled: false 81 | 82 | Style/Lambda: 83 | Enabled: false 84 | 85 | Style/RaiseArgs: 86 | EnforcedStyle: compact 87 | 88 | Style/RegexpLiteral: 89 | Exclude: 90 | - 'Guardfile' 91 | 92 | Style/StringLiterals: 93 | EnforcedStyle: double_quotes 94 | 95 | Style/TrailingCommaInArrayLiteral: 96 | EnforcedStyleForMultiline: 'comma' 97 | 98 | Style/TrailingCommaInHashLiteral: 99 | EnforcedStyleForMultiline: 'comma' 100 | -------------------------------------------------------------------------------- /spec/fixtures/muted_users.json: -------------------------------------------------------------------------------- 1 | [{"id":14098423,"id_str":"14098423","name":"John Britton","screen_name":"johndbritton","location":"New York, NY","description":"Hacker At-Large. Curious. College escapee. World traveling vagabond. Education Liaison @github. Volunteer @p2pu. Mozillian. @twilio alum.","url":"http://t.co/0gTrXsBkwy","entities":{"url":{"urls":[{"url":"http://t.co/0gTrXsBkwy","expanded_url":"http://johndbritton.com","display_url":"johndbritton.com","indices":[0,22]}]},"description":{"urls":[]}},"protected":false,"followers_count":3691,"friends_count":1550,"listed_count":255,"created_at":"Sat Mar 08 01:54:24 +0000 2008","favourites_count":93,"utc_offset":-14400,"time_zone":"Eastern Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6389,"lang":"en","status":{"created_at":"Tue Jul 22 08:48:21 +0000 2014","id":491505014268256260,"id_str":"491505014268256256","text":"I'm hacking when I should be sleeping.","source":"Twitter for Mac","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[],"symbols":[],"urls":[],"user_mentions":[]},"favorited":false,"retweeted":false,"lang":"en"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"333333","profile_background_image_url":"http://pbs.twimg.com/profile_background_images/4192334/www.johndbritton.com.png","profile_background_image_url_https":"https://pbs.twimg.com/profile_background_images/4192334/www.johndbritton.com.png","profile_background_tile":false,"profile_image_url":"http://pbs.twimg.com/profile_images/490680869263122432/X8X4c1MI_normal.jpeg","profile_image_url_https":"https://pbs.twimg.com/profile_images/490680869263122432/X8X4c1MI_normal.jpeg","profile_link_color":"0062A0","profile_sidebar_border_color":"333333","profile_sidebar_fill_color":"DDD7CD","profile_text_color":"666666","profile_use_background_image":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false}] 2 | -------------------------------------------------------------------------------- /spec/fixtures/status_no_full_name.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092548,"id_str":"55709764298092548","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d4","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/direct_message.json: -------------------------------------------------------------------------------- 1 | {"sender_screen_name":"sferik","recipient_screen_name":"pengwynn","recipient_id":14100886,"recipient":{"profile_background_tile":false,"profile_sidebar_fill_color":"dddddd","description":"Christian husband and father. Dev Experience @ HP Cloud Services. Co-host of the @changelogshow. Mashup of design & development.","friends_count":1877,"follow_request_sent":false,"notifications":false,"profile_sidebar_border_color":"cccccc","verified":false,"time_zone":"Central Time (US & Canada)","favourites_count":32,"url":"http://wynnnetherland.com","profile_background_color":"efefef","listed_count":184,"lang":"en","created_at":"Sat Mar 08 16:34:22 +0000 2008","location":"Dallas, TX","show_all_inline_media":false,"statuses_count":3928,"profile_use_background_image":true,"profile_text_color":"666666","protected":false,"profile_image_url":"http://a2.twimg.com/profile_images/485575482/komikazee_normal.png","id_str":"14100886","geo_enabled":true,"name":"Wynn Netherland","contributors_enabled":false,"following":true,"profile_background_image_url":"http://a1.twimg.com/profile_background_images/61741268/twitter-small.png","profile_link_color":"35abe9","screen_name":"pengwynn","id":14100886,"utc_offset":-21600,"followers_count":2767},"created_at":"Tue Oct 26 21:24:23 +0000 2010","id_str":"1825786345","sender":{"profile_background_tile":false,"profile_sidebar_fill_color":"DDEEF6","description":"Adventures in hunger and foolishness.","friends_count":88,"follow_request_sent":false,"notifications":false,"profile_sidebar_border_color":"C0DEED","verified":false,"time_zone":"Pacific Time (US & Canada)","favourites_count":729,"url":null,"profile_background_color":"000000","listed_count":28,"lang":"en","created_at":"Mon Jul 16 12:59:01 +0000 2007","location":"San Francisco","show_all_inline_media":true,"statuses_count":2970,"profile_use_background_image":true,"profile_text_color":"333333","protected":false,"profile_image_url":"http://a0.twimg.com/profile_images/323331048/me_normal.jpg","id_str":"7505382","geo_enabled":true,"name":"Erik Michaels-Ober","contributors_enabled":false,"following":false,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_link_color":"0084B4","screen_name":"sferik","id":7505382,"utc_offset":-28800,"followers_count":898},"sender_id":7505382,"id":1825786345,"text":"Creating a fixture for the Twitter gem"} -------------------------------------------------------------------------------- /spec/fixtures/status_no_country.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092547,"id_str":"55709764298092547","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d3","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","full_name":"Blowfish Sushi To Die For, San Francisco","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/status_no_attributes.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092546,"id_str":"55709764298092546","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d2","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","full_name":"Blowfish Sushi To Die For, San Francisco","country_code":"US","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/status_no_locality.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092549,"id_str":"55709764298092549","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d5","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","full_name":"Blowfish Sushi To Die For, San Francisco","country_code":"US","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]},"attributes":{"region":"California"}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/status_no_street_address.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092550,"id_str":"55709764298092550","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d6","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","full_name":"Blowfish Sushi To Die For, San Francisco","country_code":"US","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]},"attributes":{"region":"California","locality":"San Francisco"}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/fixtures/status.json: -------------------------------------------------------------------------------- 1 | {"created_at":"Wed Apr 06 19:13:37 +0000 2011","id":55709764298092545,"id_str":"55709764298092545","text":"The problem with your code is that it's doing exactly what you told it to do.","source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":7505382,"id_str":"7505382","name":"Erik Michaels-Ober","screen_name":"sferik","location":"San Francisco","description":"My heart is in the work.","url":"https://github.com/sferik","protected":false,"followers_count":2094,"friends_count":203,"listed_count":114,"created_at":"Mon Jul 16 12:59:01 +0000 2007","favourites_count":3073,"utc_offset":-28800,"time_zone":"Pacific Time (US & Canada)","geo_enabled":true,"verified":false,"statuses_count":6890,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_background_tile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"show_all_inline_media":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":{"type":"Point","coordinates":[37.75963095,-122.410067]},"coordinates":{"type":"Point","coordinates":[-122.410067,37.75963095]},"place":{"id":"f29bbd03562e37d1","url":"http://api.twitter.com/1/geo/id/f29bbd03562e37d1.json","place_type":"poi","name":"Blowfish Sushi To Die For","full_name":"Blowfish Sushi To Die For, San Francisco","country_code":"US","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597],[-122.409954,37.759597]]]},"attributes":{"region":"California","locality":"San Francisco","street_address":"2170 Bryant St"}},"contributors":null,"retweet_count":320,"favorite_count":50,"favorited":false,"retweeted":false,"possibly_sensitive":false} -------------------------------------------------------------------------------- /spec/editor_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "helper" 4 | 5 | describe T::Editor do 6 | context "when editing a file" do 7 | before do 8 | allow(described_class).to receive(:edit) do |path| 9 | File.binwrite(path, "A tweet!!!!") 10 | end 11 | end 12 | 13 | it "fetches your tweet content without comments" do 14 | expect(described_class.gets).to eq("A tweet!!!!") 15 | end 16 | end 17 | 18 | context "when fetching the editor to write in" do 19 | context "no $VISUAL or $EDITOR set" do 20 | before do 21 | ENV["EDITOR"] = ENV["VISUAL"] = nil 22 | end 23 | 24 | context "host_os is Mac OSX" do 25 | it "returns the system editor" do 26 | RbConfig::CONFIG["host_os"] = "darwin12.2.0" 27 | expect(described_class.editor).to eq("vi") 28 | end 29 | end 30 | 31 | context "host_os is Linux" do 32 | it "returns the system editor" do 33 | RbConfig::CONFIG["host_os"] = "3.2.0-4-amd64" 34 | expect(described_class.editor).to eq("vi") 35 | end 36 | end 37 | 38 | context "host_os is Windows" do 39 | it "returns the system editor" do 40 | RbConfig::CONFIG["host_os"] = "mswin" 41 | expect(described_class.editor).to eq("notepad") 42 | end 43 | end 44 | end 45 | 46 | context "$VISUAL is set" do 47 | before do 48 | ENV["EDITOR"] = nil 49 | ENV["VISUAL"] = "/my/vim/install" 50 | end 51 | 52 | it "returns the system editor" do 53 | expect(described_class.editor).to eq("/my/vim/install") 54 | end 55 | end 56 | 57 | context "$EDITOR is set" do 58 | before do 59 | ENV["EDITOR"] = "/usr/bin/subl" 60 | ENV["VISUAL"] = nil 61 | end 62 | 63 | it "returns the system editor" do 64 | expect(described_class.editor).to eq("/usr/bin/subl") 65 | end 66 | end 67 | 68 | context "$VISUAL and $EDITOR are set" do 69 | before do 70 | ENV["EDITOR"] = "/my/vastly/superior/editor" 71 | ENV["VISUAL"] = "/usr/bin/emacs" 72 | end 73 | 74 | it "returns the system editor" do 75 | expect(described_class.editor).to eq("/usr/bin/emacs") 76 | end 77 | end 78 | end 79 | 80 | context "when fetching system editor" do 81 | context "on a mac" do 82 | before do 83 | RbConfig::CONFIG["host_os"] = "darwin12.2.0" 84 | end 85 | 86 | it "returns 'vi' on a unix machine" do 87 | expect(described_class.system_editor).to eq("vi") 88 | end 89 | end 90 | 91 | context "on a Windows POC" do 92 | before do 93 | RbConfig::CONFIG["host_os"] = "mswin" 94 | end 95 | 96 | it "returns 'notepad' on a windows box" do 97 | expect(described_class.system_editor).to eq("notepad") 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/t/set.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require "t/rcfile" 3 | require "t/requestable" 4 | 5 | module T 6 | class Set < Thor 7 | include T::Requestable 8 | 9 | check_unknown_options! 10 | 11 | def initialize(*) 12 | @rcfile = T::RCFile.instance 13 | super 14 | end 15 | 16 | desc "active SCREEN_NAME [CONSUMER_KEY]", "Set your active account." 17 | def active(screen_name, consumer_key = nil) 18 | require "t/core_ext/string" 19 | screen_name = screen_name.strip_ats 20 | @rcfile.path = options["profile"] if options["profile"] 21 | consumer_key = @rcfile[screen_name].keys.last if consumer_key.nil? 22 | @rcfile.active_profile = {"username" => @rcfile[screen_name][consumer_key]["username"], "consumer_key" => consumer_key} 23 | say "Active account has been updated to #{@rcfile.active_profile[0]}." 24 | end 25 | map %w[account default] => :active 26 | 27 | desc "bio DESCRIPTION", "Edits your Bio information on your Twitter profile." 28 | def bio(description) 29 | client.update_profile(description:) 30 | say "@#{@rcfile.active_profile[0]}'s bio has been updated." 31 | end 32 | 33 | desc "language LANGUAGE_NAME", "Selects the language you'd like to receive notifications in." 34 | def language(language_name) 35 | client.settings(lang: language_name) 36 | say "@#{@rcfile.active_profile[0]}'s language has been updated." 37 | end 38 | 39 | desc "location PLACE_NAME", "Updates the location field in your profile." 40 | def location(place_name) 41 | client.update_profile(location: place_name) 42 | say "@#{@rcfile.active_profile[0]}'s location has been updated." 43 | end 44 | 45 | desc "name NAME", "Sets the name field on your Twitter profile." 46 | def name(name) 47 | client.update_profile(name:) 48 | say "@#{@rcfile.active_profile[0]}'s name has been updated." 49 | end 50 | 51 | desc "profile_background_image FILE", "Sets the background image on your Twitter profile." 52 | method_option "tile", aliases: "-t", type: :boolean, desc: "Whether or not to tile the background image." 53 | def profile_background_image(file) 54 | client.update_profile_background_image(File.new(File.expand_path(file)), tile: options["tile"], skip_status: true) 55 | say "@#{@rcfile.active_profile[0]}'s background image has been updated." 56 | end 57 | map %w[background background_image] => :profile_background_image 58 | 59 | desc "profile_image FILE", "Sets the image on your Twitter profile." 60 | def profile_image(file) 61 | client.update_profile_image(File.new(File.expand_path(file))) 62 | say "@#{@rcfile.active_profile[0]}'s image has been updated." 63 | end 64 | map %w[avatar image] => :profile_image 65 | 66 | desc "website URI", "Sets the website field on your profile." 67 | def website(uri) 68 | client.update_profile(url: uri) 69 | say "@#{@rcfile.active_profile[0]}'s website has been updated." 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/fixtures/retweet.json: -------------------------------------------------------------------------------- 1 | {"retweeted_status":{"place":null,"retweet_count":null,"geo":null,"retweeted":false,"in_reply_to_status_id":null,"source":"Twitter for iPhone","truncated":false,"in_reply_to_status_id_str":null,"created_at":"Sun Oct 24 03:46:25 +0000 2010","in_reply_to_user_id":null,"favorited":false,"in_reply_to_user_id_str":null,"user":{"geo_enabled":false,"time_zone":"Eastern Time (US & Canada)","description":"Raconteur.","profile_sidebar_fill_color":"dddddd","followers_count":91398,"verified":false,"notifications":false,"follow_request_sent":false,"profile_use_background_image":false,"profile_sidebar_border_color":"5d5d5d","url":"http://daringfireball.net","profile_background_image_url":"http://a3.twimg.com/profile_background_images/3150433/Rumsfeld__Kissinger__Nixon_at_1974_NATO_meeting.jpg","lang":"en","created_at":"Fri Dec 01 02:52:40 +0000 2006","profile_background_color":"5D5D5D","location":"Philadelphia","listed_count":5095,"profile_background_tile":false,"friends_count":452,"protected":false,"profile_image_url":"http://a3.twimg.com/profile_images/546338003/gruber-sxsw-final_normal.png","statuses_count":10488,"profile_text_color":"000000","name":"John Gruber","show_all_inline_media":false,"following":true,"favourites_count":8601,"screen_name":"gruber","id":33423,"id_str":"33423","contributors_enabled":false,"utc_offset":-18000,"profile_link_color":"2626C3"},"contributors":null,"coordinates":null,"in_reply_to_screen_name":null,"id":28561922516,"id_str":"28561922516","text":"As for the Series, I'm for the Giants. Fuck Texas, fuck Nolan Ryan, fuck George Bush."},"place":null,"retweet_count":null,"geo":null,"retweeted":false,"in_reply_to_status_id":null,"new_id":608250155669389312,"new_id_str":"608250155669389312","source":"The Run Around","truncated":false,"in_reply_to_status_id_str":null,"created_at":"Mon Oct 25 07:39:11 +0000 2010","in_reply_to_user_id":null,"favorited":false,"in_reply_to_user_id_str":null,"user":{"geo_enabled":true,"time_zone":"Pacific Time (US & Canada)","description":"Adventures in hunger and foolishness.","profile_sidebar_fill_color":"DDEEF6","followers_count":898,"verified":false,"notifications":false,"follow_request_sent":false,"profile_use_background_image":true,"profile_sidebar_border_color":"C0DEED","url":null,"profile_background_image_url":"http://a3.twimg.com/profile_background_images/162641967/we_concept_bg2.png","lang":"en","created_at":"Mon Jul 16 12:59:01 +0000 2007","profile_background_color":"000000","location":"San Francisco","listed_count":28,"profile_background_tile":false,"friends_count":88,"protected":false,"profile_image_url":"http://a0.twimg.com/profile_images/323331048/me_normal.jpg","statuses_count":2968,"profile_text_color":"333333","name":"Erik Michaels-Ober","show_all_inline_media":true,"following":false,"favourites_count":727,"screen_name":"sferik","id":7505382,"id_str":"7505382","contributors_enabled":false,"utc_offset":-28800,"profile_link_color":"0084B4"},"contributors":null,"coordinates":null,"in_reply_to_screen_name":null,"id":28669546014,"id_str":"28669546014","text":"RT @gruber: As for the Series, I'm for the Giants. Fuck Texas, fuck Nolan Ryan, fuck George Bush."} -------------------------------------------------------------------------------- /lib/t/rcfile.rb: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | 3 | module T 4 | class RCFile 5 | include Singleton 6 | attr_reader :path 7 | 8 | FILE_NAME = ".trc".freeze 9 | 10 | def initialize 11 | @path = File.join(File.expand_path("~"), FILE_NAME) 12 | @data = load_file 13 | end 14 | 15 | def [](username) 16 | profiles[find(username)] 17 | end 18 | 19 | def find(username) 20 | possibilities = Array(find_case_insensitive_match(username) || find_case_insensitive_possibilities(username)) 21 | raise(ArgumentError.new("Username #{username} is #{possibilities.empty? ? 'not found.' : "ambiguous, matching #{possibilities.join(', ')}"}")) unless possibilities.size == 1 22 | 23 | possibilities.first 24 | end 25 | 26 | def find_case_insensitive_match(username) 27 | profiles.keys.detect { |u| username.casecmp(u).zero? } 28 | end 29 | 30 | def find_case_insensitive_possibilities(username) 31 | profiles.keys.select { |u| username.casecmp(u[0, username.length]).zero? } 32 | end 33 | 34 | def []=(username, profile) 35 | profiles[username] ||= {} 36 | profiles[username].merge!(profile) 37 | write 38 | end 39 | 40 | def configuration 41 | @data["configuration"] 42 | end 43 | 44 | def active_consumer_key 45 | profiles[active_profile[0]][active_profile[1]]["consumer_key"] if active_profile? 46 | end 47 | 48 | def active_consumer_secret 49 | profiles[active_profile[0]][active_profile[1]]["consumer_secret"] if active_profile? 50 | end 51 | 52 | def active_profile 53 | configuration["default_profile"] 54 | end 55 | 56 | def active_profile=(profile) 57 | configuration["default_profile"] = [profile["username"], profile["consumer_key"]] 58 | write 59 | end 60 | 61 | def active_secret 62 | profiles[active_profile[0]][active_profile[1]]["secret"] if active_profile? 63 | end 64 | 65 | def active_token 66 | profiles[active_profile[0]][active_profile[1]]["token"] if active_profile? 67 | end 68 | 69 | def delete 70 | FileUtils.rm_f(@path) 71 | end 72 | 73 | def empty? 74 | @data == default_structure 75 | end 76 | 77 | def load_file 78 | require "yaml" 79 | YAML.load_file(@path) 80 | rescue Errno::ENOENT 81 | default_structure 82 | end 83 | 84 | def path=(path) 85 | @path = path 86 | @data = load_file 87 | end 88 | 89 | def profiles 90 | @data["profiles"] 91 | end 92 | 93 | def reset 94 | send(:initialize) 95 | end 96 | 97 | def delete_profile(profile) 98 | profiles.delete(profile) 99 | write 100 | end 101 | 102 | def delete_key(profile, key) 103 | profiles[profile].delete(key) 104 | write 105 | end 106 | 107 | private 108 | 109 | def active_profile? 110 | active_profile && profiles[active_profile[0]] && profiles[active_profile[0]][active_profile[1]] 111 | end 112 | 113 | def default_structure 114 | {"configuration" => {}, "profiles" => {}} 115 | end 116 | 117 | def write 118 | require "yaml" 119 | File.open(@path, File::RDWR | File::TRUNC | File::CREAT, 0o0600) do |rcfile| 120 | rcfile.write @data.to_yaml 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /tasks/zsh.rake: -------------------------------------------------------------------------------- 1 | namespace :completion do 2 | desc "Generate zsh completion file" 3 | task :zsh do 4 | Bundler.require(:default) 5 | 6 | output_path = "etc/t-completion.zsh" 7 | file_path = File.expand_path(output_path) 8 | puts "Compiling zsh completion to #{output_path}" 9 | File.write(file_path, zsh_completion) 10 | 11 | git_status = `git status -s` 12 | if git_status[/M #{output_path}/] 13 | cmd = "git add #{output_path} && git commit -m 'Updating Zsh completion'" 14 | result = system cmd 15 | raise("Could not commit changes") unless result 16 | end 17 | end 18 | end 19 | 20 | def zsh_completion 21 | %(#compdef t 22 | 23 | # Completion for Zsh. Source from somewhere in your $fpath. 24 | 25 | _t (){ 26 | local -a t_general_options 27 | 28 | #{general_options_completions} 29 | 30 | if (( CURRENT > 2 )); then 31 | (( CURRENT-- )) 32 | shift words 33 | _call_function 1 _t_${words[1]} 34 | else 35 | _values "t command" \\ 36 | #{task_completions} 37 | 38 | fi 39 | } 40 | 41 | #{command_functions} 42 | 43 | #{subcommand_arguments_functions} 44 | 45 | #{subcommand_functions} 46 | ) 47 | end 48 | 49 | def general_options_completions 50 | %(t_general_options=("(-H --host)"{-H,--host=}"[Twitter API server]:URL:_urls" 51 | "(-C --color)"{-C,--color}"[Control how color is used in output]" 52 | "(-U --no-ssl)"{-U,--no-ssl}"[Disable SSL]" 53 | "(-P --profile)"{-P,--profile=}"[Path to RC file]:file:_files" 54 | $nul_arg 55 | ) 56 | ) 57 | end 58 | 59 | def option_completion(thor_option) 60 | aliases = thor_option.aliases 61 | name = thor_option.name 62 | desc = thor_option.description.to_s.gsub "'", "\\\\'" 63 | %("(#{aliases.join(' ')} --#{name})"{#{aliases.join(',')},--#{name}}"[#{desc}]" \\) 64 | end 65 | 66 | def command_function_arguments(command) 67 | body = command.options.collect { |_, option| option_completion(option) } 68 | body << "$t_general_options && ret=0" 69 | 70 | body.join("\n ") 71 | end 72 | 73 | def task_completions 74 | T::CLI.tasks.collect(&:last).collect do |task| 75 | desc = task.description.to_s.gsub "'", "\\\\'" 76 | %( "#{task.name}[#{desc}]" \\) 77 | end.join("\n") 78 | end 79 | 80 | def commands 81 | T::CLI.tasks.except(*T::CLI.subcommands).collect(&:last) 82 | end 83 | 84 | def command_function(command) 85 | %(_t_#{command.name}() { 86 | _arguments \\ 87 | #{command_function_arguments(command)} 88 | } 89 | ) 90 | end 91 | 92 | def command_functions 93 | commands. 94 | collect { |t| command_function(t) }. 95 | join("\n") 96 | end 97 | 98 | def subcommands 99 | T::CLI.tasks. 100 | slice(*T::CLI.subcommands). 101 | collect(&:last) 102 | end 103 | 104 | def subcommand_function(command) 105 | %(_t_#{command.name}() { 106 | _arguments \\ 107 | ":argument:__t_#{command.name}_arguments" \\ 108 | $t_general_options && ret=0 109 | } 110 | ) 111 | end 112 | 113 | def subcommand_functions 114 | subcommands. 115 | collect { |t| subcommand_function(t) }. 116 | join("\n") 117 | end 118 | 119 | def arguments_function(subcommand) 120 | klass = T.const_get subcommand.name.capitalize 121 | %(__t_#{subcommand.name}_arguments() { 122 | _args=(#{klass.tasks.collect { |t| t.last.name }.join("\n ")} 123 | ) 124 | compadd "$@" -k _args 125 | } 126 | ) 127 | end 128 | 129 | def subcommand_arguments_functions 130 | subcommands.collect { |s| arguments_function(s) }.join("\n") 131 | end 132 | -------------------------------------------------------------------------------- /tasks/bash.rake: -------------------------------------------------------------------------------- 1 | namespace :completion do 2 | desc "Generate bash completion file" 3 | task :bash do 4 | Bundler.require(:default) 5 | 6 | output_path = "etc/t-completion.sh" 7 | file_path = File.expand_path(output_path) 8 | puts "Compiling bash completion to #{output_path}" 9 | File.write(file_path, BashCompletion.generate) 10 | 11 | git_status = `git status -s` 12 | if git_status[/M #{output_path}/] 13 | cmd = "git add #{output_path} && git commit -m 'Updating Bash completion'" 14 | result = system cmd 15 | raise("Could not commit changes") unless result 16 | end 17 | end 18 | end 19 | 20 | # using a module to avoid namespace conflicts 21 | module BashCompletion 22 | class << self 23 | def generate 24 | %[# Completion for Bash. Copy it in /etc/bash_completion.d/ or source it 25 | # somewhere in your ~/.bashrc 26 | 27 | _t() { 28 | 29 | local cur prev completions 30 | 31 | COMPREPLY=() 32 | cur=${COMP_WORDS[COMP_CWORD]} 33 | topcmd=${COMP_WORDS[1]} 34 | prev=${COMP_WORDS[COMP_CWORD-1]} 35 | 36 | COMMANDS='#{commands.collect(&:name).join(' ')}' 37 | 38 | case "$topcmd" in 39 | #{comp_cases} 40 | *) completions="$COMMANDS" ;; 41 | esac 42 | 43 | COMPREPLY=( $( compgen -W "$completions" -- $cur )) 44 | return 0 45 | 46 | } 47 | 48 | complete -F _t $filenames t 49 | ] 50 | end 51 | 52 | def comp_cases 53 | commands.collect do |cmd| 54 | options_str = options(cmd).join(" ") 55 | subcmds = subcommands(cmd) 56 | 57 | opts_args = cmd.options.filter_map do |_, opt| 58 | cases = opt.enum 59 | if cases 60 | %[#{option_str(opt).tr(' ', '|')}) 61 | completions='#{cases.join(' ')}' ;;] 62 | end 63 | end 64 | 65 | if subcmds.empty? 66 | %[#{cmd.name}) 67 | case "$prev" in 68 | #{opts_args.join("\n") unless opts_args.empty?} 69 | #{global_options_args} 70 | *) completions='#{options_str}' ;; 71 | esac;;\n] 72 | else 73 | subcommands_cases = subcmds.collect do |sn| 74 | "#{sn}) completions='#{options_str}' ;;" 75 | end.join("\n") 76 | 77 | %[#{cmd.name}) 78 | case "$prev" in 79 | #{cmd.name}) completions='#{subcmds.join(' ')}';; 80 | #{subcommands_cases} 81 | #{opts_args.join("\n") unless opts_args.empty?} 82 | #{global_options_args} 83 | *) completions='#{options_str}';; 84 | esac;;\n] 85 | end 86 | end.join("\n") 87 | end 88 | 89 | def options(cmd) 90 | cmd.options.collect { |_, o| option_str(o) }.concat(global_options) 91 | end 92 | 93 | def option_str(opt) 94 | if opt.aliases 95 | "--#{opt.name} #{opt.aliases.join(' ')}" 96 | else 97 | "--#{opt.name}" 98 | end 99 | end 100 | 101 | def commands 102 | T::CLI.tasks.collect(&:last) 103 | end 104 | 105 | def global_options 106 | %w[-H --host -C --color -P --profile] 107 | end 108 | 109 | def global_options_args 110 | "-C|--color) completions='auto never' ;;\n" 111 | end 112 | 113 | def subcommands(cmd) 114 | return [] unless T::CLI.subcommands.include?(cmd.name) 115 | 116 | klass = T.const_get cmd.name.capitalize 117 | 118 | klass.tasks.collect { |_, t| t.name } 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [{"id":14100886,"profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","profile_link_color":"0084B4","following":true,"default_profile":true,"utc_offset":-21600,"profile_use_background_image":true,"name":"Wynn Netherland","notifications":false,"contributors_enabled":false,"profile_text_color":"333333","geo_enabled":true,"id_str":"14100886","protected":false,"listed_count":358,"show_all_inline_media":true,"friends_count":3427,"profile_image_url":"http://a0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","profile_sidebar_border_color":"C0DEED","followers_count":5457,"description":"Christian, husband, father, GitHubber, Co-host of @thechangelog, Co-author of Sass, Compass, #CSS book http://wynn.fm/sass-meap","is_translator":false,"url":"http://wynnnetherland.com","screen_name":"pengwynn","profile_background_tile":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","profile_sidebar_fill_color":"DDEEF6","created_at":"Sat Mar 08 16:34:22 +0000 2008","statuses_count":6940,"lang":"en","profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","verified":false,"time_zone":"Central Time (US & Canada)","default_profile_image":false,"favourites_count":192,"status":{"in_reply_to_user_id_str":"321401349","retweet_count":0,"in_reply_to_user_id":321401349,"in_reply_to_status_id":221694109407121409,"retweeted":false,"truncated":false,"created_at":"Sat Jul 07 20:33:19 +0000 2012","coordinates":null,"geo":null,"contributors":null,"place":null,"favorited":false,"source":"Twitter for Mac","id":221703464277917696,"in_reply_to_status_id_str":"221694109407121409","id_str":"221703464277917696","in_reply_to_screen_name":"akosmasoftware","text":"@akosmasoftware Sass book! @hcatlin @nex3 are the brains behind Sass. :-)"},"profile_background_color":"C0DEED","follow_request_sent":false,"location":"Denton, TX"},{"id":7505382,"profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_link_color":"0084B4","following":false,"default_profile":false,"utc_offset":-28800,"profile_use_background_image":true,"name":"Erik Michaels-Ober","notifications":false,"contributors_enabled":false,"profile_text_color":"333333","geo_enabled":true,"id_str":"7505382","protected":false,"listed_count":118,"show_all_inline_media":true,"friends_count":212,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_sidebar_border_color":"C0DEED","followers_count":2262,"description":"Vagabond.","is_translator":false,"url":"https://github.com/sferik","screen_name":"sferik","profile_background_tile":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_sidebar_fill_color":"DDEEF6","created_at":"Mon Jul 16 12:59:01 +0000 2007","statuses_count":7890,"lang":"en","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","verified":false,"time_zone":"Pacific Time (US & Canada)","default_profile_image":false,"favourites_count":3755,"status":{"in_reply_to_user_id_str":"291","retweet_count":0,"in_reply_to_user_id":291,"in_reply_to_status_id":222012135281143809,"retweeted":false,"truncated":false,"created_at":"Sun Jul 08 18:29:20 +0000 2012","coordinates":null,"geo":null,"contributors":null,"place":null,"favorited":false,"source":"Twitter for Mac","id":222034648631484416,"in_reply_to_status_id_str":"222012135281143809","id_str":"222034648631484416","in_reply_to_screen_name":"goldman","text":"@goldman You're near my home town! Say hi to Woodstock for me."},"profile_background_color":"000000","follow_request_sent":false,"location":"San Francisco"}] -------------------------------------------------------------------------------- /spec/fixtures/users_list.json: -------------------------------------------------------------------------------- 1 | {"users":[{"id":7505382,"profile_background_image_url":"http://a0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","profile_link_color":"0084B4","following":false,"default_profile":false,"utc_offset":-28800,"profile_use_background_image":true,"name":"Erik Michaels-Ober","notifications":false,"contributors_enabled":false,"profile_text_color":"333333","geo_enabled":true,"id_str":"7505382","protected":false,"listed_count":118,"show_all_inline_media":true,"friends_count":212,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_sidebar_border_color":"C0DEED","followers_count":2262,"description":"Vagabond.","is_translator":false,"url":"https://github.com/sferik","screen_name":"sferik","profile_background_tile":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","profile_sidebar_fill_color":"DDEEF6","created_at":"Mon Jul 16 12:59:01 +0000 2007","statuses_count":7890,"lang":"en","profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/162641967/we_concept_bg2.png","verified":false,"time_zone":"Pacific Time (US & Canada)","default_profile_image":false,"favourites_count":3755,"status":{"in_reply_to_user_id_str":"291","retweet_count":0,"in_reply_to_user_id":291,"in_reply_to_status_id":222012135281143809,"retweeted":false,"truncated":false,"created_at":"Sun Jul 08 18:29:20 +0000 2012","coordinates":null,"geo":null,"contributors":null,"place":null,"favorited":false,"source":"Twitter for Mac","id":222034648631484416,"in_reply_to_status_id_str":"222012135281143809","id_str":"222034648631484416","in_reply_to_screen_name":"goldman","text":"@goldman You're near my home town! Say hi to Woodstock for me."},"profile_background_color":"000000","follow_request_sent":false,"location":"San Francisco"},{"id":14100886,"profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","profile_link_color":"0084B4","following":true,"default_profile":true,"utc_offset":-21600,"profile_use_background_image":true,"name":"Wynn Netherland","notifications":false,"contributors_enabled":false,"profile_text_color":"333333","geo_enabled":true,"id_str":"14100886","protected":false,"listed_count":358,"show_all_inline_media":true,"friends_count":3427,"profile_image_url":"http://a0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","profile_sidebar_border_color":"C0DEED","followers_count":5457,"description":"Christian, husband, father, GitHubber, Co-host of @thechangelog, Co-author of Sass, Compass, #CSS book http://wynn.fm/sass-meap","is_translator":false,"url":"http://wynnnetherland.com","screen_name":"pengwynn","profile_background_tile":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","profile_sidebar_fill_color":"DDEEF6","created_at":"Sat Mar 08 16:34:22 +0000 2008","statuses_count":6940,"lang":"en","profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","verified":false,"time_zone":"Central Time (US & Canada)","default_profile_image":false,"favourites_count":192,"status":{"in_reply_to_user_id_str":"321401349","retweet_count":0,"in_reply_to_user_id":321401349,"in_reply_to_status_id":221694109407121409,"retweeted":false,"truncated":false,"created_at":"Sat Jul 07 20:33:19 +0000 2012","coordinates":null,"geo":null,"contributors":null,"place":null,"favorited":false,"source":"Twitter for Mac","id":221703464277917696,"in_reply_to_status_id_str":"221694109407121409","id_str":"221703464277917696","in_reply_to_screen_name":"akosmasoftware","text":"@akosmasoftware Sass book! @hcatlin @nex3 are the brains behind Sass. :-)"},"profile_background_color":"C0DEED","follow_request_sent":false,"location":"Denton, TX"}],"next_cursor":0,"previous_cursor":0,"next_cursor_str":"0","previous_cursor_str":"0"} -------------------------------------------------------------------------------- /lib/t/utils.rb: -------------------------------------------------------------------------------- 1 | module T 2 | module Utils 3 | private 4 | 5 | # https://github.com/rails/rails/blob/bd8a970/actionpack/lib/action_view/helpers/date_helper.rb 6 | def distance_of_time_in_words(from_time, to_time = Time.now) # rubocop:disable Metrics/CyclomaticComplexity 7 | seconds = (to_time - from_time).abs 8 | minutes = seconds / 60 9 | case minutes 10 | when 0...1 11 | case seconds 12 | when 0...1 13 | "a split second" 14 | when 1...2 15 | "a second" 16 | when 2...60 17 | format("%d seconds", seconds:) 18 | end 19 | when 1...2 20 | "a minute" 21 | when 2...60 22 | format("%d minutes", minutes:) 23 | when 60...120 24 | "an hour" 25 | # 120 minutes up to 23.5 hours 26 | when 120...1410 27 | format("%d hours", hours: (minutes.to_f / 60.0).round) 28 | # 23.5 hours up to 48 hours 29 | when 1410...2880 30 | "a day" 31 | # 48 hours up to 29.5 days 32 | when 2880...42_480 33 | format("%d days", days: (minutes.to_f / 1440.0).round) 34 | # 29.5 days up to 60 days 35 | when 42_480...86_400 36 | "a month" 37 | # 60 days up to 11.5 months 38 | when 86_400...503_700 39 | format("%d months", months: (minutes.to_f / 43_800.0).round) 40 | # 11.5 months up to 2 years 41 | when 503_700...1_051_200 42 | "a year" 43 | else 44 | format("%d years", years: (minutes.to_f / 525_600.0).round) 45 | end 46 | end 47 | alias time_ago_in_words distance_of_time_in_words 48 | alias time_from_now_in_words distance_of_time_in_words 49 | 50 | def fetch_users(users, options) 51 | format_users!(users, options) 52 | require "retryable" 53 | users = Retryable.retryable(tries: 3, on: Twitter::Error, sleep: 0) do 54 | yield users 55 | end 56 | [users, users.length] 57 | end 58 | 59 | def format_users!(users, options) 60 | require "t/core_ext/string" 61 | options["id"] ? users.collect!(&:to_i) : users.collect!(&:strip_ats) 62 | end 63 | 64 | def extract_owner(user_list, options) 65 | owner, list_name = user_list.split("/") 66 | if list_name.nil? 67 | list_name = owner 68 | owner = @rcfile.active_profile[0] 69 | else 70 | require "t/core_ext/string" 71 | owner = options["id"] ? owner.to_i : owner.strip_ats 72 | end 73 | [owner, list_name] 74 | end 75 | 76 | def strip_tags(html) 77 | html.gsub(/<.+?>/, "") 78 | end 79 | 80 | def number_with_delimiter(number, delimiter = ",") 81 | digits = number.to_s.chars 82 | groups = digits.reverse.each_slice(3).collect(&:join) 83 | groups.join(delimiter).reverse 84 | end 85 | 86 | def pluralize(count, singular, plural = nil) 87 | "#{count || 0} " + (count == 1 || count =~ /^1(\.0+)?$/ ? singular : (plural || "#{singular}s")) 88 | end 89 | 90 | def decode_full_text(message, decode_full_uris = false) 91 | require "htmlentities" 92 | text = HTMLEntities.new.decode(message.full_text) 93 | text = decode_uris(text, message.uris) if decode_full_uris 94 | text 95 | end 96 | 97 | def decode_uris(full_text, uri_entities) 98 | return full_text if uri_entities.nil? 99 | 100 | uri_entities.each do |uri_entity| 101 | full_text = full_text.gsub(uri_entity.uri.to_s, uri_entity.expanded_uri.to_s) 102 | end 103 | 104 | full_text 105 | end 106 | 107 | def open_or_print(uri, options) 108 | Launchy.open(uri, options) do 109 | say "Open: #{uri}" 110 | end 111 | end 112 | 113 | def parallel_map(enumerable) 114 | enumerable.collect { |object| Thread.new { yield object } }.collect(&:value) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/fixtures/501_ids.json: -------------------------------------------------------------------------------- 1 | {"previous_cursor_str":"0","next_cursor":0,"ids":[7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382,7505382],"previous_cursor":0,"next_cursor_str":"0"} -------------------------------------------------------------------------------- /spec/utils_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "helper" 4 | 5 | class Test; end 6 | 7 | describe T::Utils do 8 | before :all do 9 | Timecop.freeze(Time.utc(2011, 11, 24, 16, 20, 0)) 10 | T.utc_offset = -28_800 11 | end 12 | 13 | before do 14 | @test = Test.new 15 | @test.extend(described_class) 16 | end 17 | 18 | after :all do 19 | T.utc_offset = nil 20 | Timecop.return 21 | end 22 | 23 | describe "#distance_of_time_in_words" do 24 | it 'returns "a split second" if difference is less than a second' do 25 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 20, 0))).to eq "a split second" 26 | end 27 | 28 | it 'returns "a second" if difference is a second' do 29 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 20, 1))).to eq "a second" 30 | end 31 | 32 | it 'returns "2 seconds" if difference is 2 seconds' do 33 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 20, 2))).to eq "2 seconds" 34 | end 35 | 36 | it 'returns "59 seconds" if difference is just shy of 1 minute' do 37 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 20, 59.9))).to eq "59 seconds" 38 | end 39 | 40 | it 'returns "a minute" if difference is 1 minute' do 41 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 21, 0))).to eq "a minute" 42 | end 43 | 44 | it 'returns "2 minutes" if difference is 2 minutes' do 45 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 16, 22, 0))).to eq "2 minutes" 46 | end 47 | 48 | it 'returns "59 minutes" if difference is just shy of 1 hour' do 49 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 17, 19, 59.9))).to eq "59 minutes" 50 | end 51 | 52 | it 'returns "an hour" if difference is 1 hour' do 53 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 17, 20, 0))).to eq "an hour" 54 | end 55 | 56 | it 'returns "2 hours" if difference is 2 hours' do 57 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 24, 18, 20, 0))).to eq "2 hours" 58 | end 59 | 60 | it 'returns "23 hours" if difference is just shy of 23.5 hours' do 61 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 25, 15, 49, 59.9))).to eq "23 hours" 62 | end 63 | 64 | it 'returns "a day" if difference is 23.5 hours' do 65 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 25, 15, 50, 0))).to eq "a day" 66 | end 67 | 68 | it 'returns "2 days" if difference is 2 days' do 69 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 11, 26, 16, 20, 0))).to eq "2 days" 70 | end 71 | 72 | it 'returns "29 days" if difference is just shy of 29.5 days' do 73 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 12, 24, 4, 19, 59.9))).to eq "29 days" 74 | end 75 | 76 | it 'returns "a month" if difference is 29.5 days' do 77 | expect(@test.send(:distance_of_time_in_words, Time.utc(2011, 12, 24, 4, 20, 0))).to eq "a month" 78 | end 79 | 80 | it 'returns "2 months" if difference is 2 months' do 81 | expect(@test.send(:distance_of_time_in_words, Time.utc(2012, 1, 24, 16, 20, 0))).to eq "2 months" 82 | end 83 | 84 | it 'returns "11 months" if difference is just shy of 11.5 months' do 85 | expect(@test.send(:distance_of_time_in_words, Time.utc(2012, 11, 8, 11, 19, 59.9))).to eq "11 months" 86 | end 87 | 88 | it 'returns "a year" if difference is 11.5 months' do 89 | expect(@test.send(:distance_of_time_in_words, Time.utc(2012, 11, 8, 11, 20, 0))).to eq "a year" 90 | end 91 | 92 | it 'returns "2 years" if difference is 2 years' do 93 | expect(@test.send(:distance_of_time_in_words, Time.utc(2013, 11, 24, 16, 20, 0))).to eq "2 years" 94 | end 95 | end 96 | 97 | describe "#strip_tags" do 98 | it "returns string sans tags" do 99 | expect(@test.send(:strip_tags, 'Twitter for iPhone')).to eq "Twitter for iPhone" 100 | end 101 | end 102 | 103 | describe "#number_with_delimiter" do 104 | it "returns number with delimiter" do 105 | expect(@test.send(:number_with_delimiter, 1_234_567_890)).to eq "1,234,567,890" 106 | end 107 | 108 | context "with custom delimiter" do 109 | it "returns number with custom delimiter" do 110 | expect(@test.send(:number_with_delimiter, 1_234_567_890, ".")).to eq "1.234.567.890" 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/fixtures/lists.json: -------------------------------------------------------------------------------- 1 | [{"uri":"/pengwynn/rubyists","name":"Rubyists","full_name":"@pengwynn/rubyists","description":"","mode":"public","user":{"id":14100886,"profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","time_zone":"Central Time (US & Canada)","location":"Denton, TX","profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","id_str":"14100886","profile_link_color":"0084B4","geo_enabled":true,"default_profile":false,"profile_image_url":"http://a0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","utc_offset":-21600,"profile_use_background_image":false,"statuses_count":7384,"name":"Wynn Netherland","follow_request_sent":false,"profile_text_color":"333333","lang":"en","screen_name":"pengwynn","listed_count":397,"protected":false,"is_translator":false,"followers_count":6182,"profile_sidebar_border_color":"FFFFFF","description":"Christian, husband, father, GitHubber, Co-host of @thechangelog, Co-author of Sass, Compass, #CSS book http://wynn.fm/sass-meap","profile_image_url_https":"https://si0.twimg.com/profile_images/2221455972/wynn-mic-bw_normal.jpg","profile_background_tile":false,"following":true,"profile_sidebar_fill_color":"DDEEF6","default_profile_image":false,"url":"http://wynnnetherland.com","profile_banner_url":"https://si0.twimg.com/profile_banners/14100886/1347987369","favourites_count":338,"created_at":"Sat Mar 08 16:34:22 +0000 2008","friends_count":3528,"verified":false,"notifications":false,"profile_background_color":"292929","contributors_enabled":false},"following":true,"created_at":"Fri Oct 30 14:39:25 +0000 2009","member_count":499,"id_str":"1129440","subscriber_count":39,"slug":"rubyists","id":1129440},{"uri":"/twitter/team","name":"Team","full_name":"@twitter/team","description":"","mode":"other","user":{"id":783214,"profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/657090062/l1uqey5sy82r9ijhke1i.png","time_zone":"Pacific Time (US & Canada)","location":"San Francisco, CA","profile_background_image_url":"http://a0.twimg.com/profile_background_images/657090062/l1uqey5sy82r9ijhke1i.png","id_str":"783214","profile_link_color":"038543","geo_enabled":true,"default_profile":false,"profile_image_url":"http://a0.twimg.com/profile_images/2284174758/v65oai7fxn47qv9nectx_normal.png","utc_offset":-28800,"profile_use_background_image":true,"statuses_count":1433,"name":"Twitter","follow_request_sent":false,"profile_text_color":"333333","lang":"en","screen_name":"twitter","listed_count":73287,"protected":false,"followers_count":13711595,"profile_sidebar_border_color":"EEEEEE","is_translator":false,"description":"Always wondering what's happening. ","profile_background_tile":true,"following":true,"profile_sidebar_fill_color":"F6F6F6","default_profile_image":false,"url":"http://blog.twitter.com/","profile_banner_url":"https://si0.twimg.com/profile_banners/783214/1347405327","favourites_count":18,"created_at":"Tue Feb 20 14:35:54 +0000 2007","friends_count":1249,"verified":true,"notifications":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/2284174758/v65oai7fxn47qv9nectx_normal.png","profile_background_color":"ACDED6","contributors_enabled":true},"following":true,"created_at":"Wed Sep 23 01:18:01 +0000 2009","member_count":1199,"id_str":"574","subscriber_count":78078,"slug":"team","id":574},{"uri":"/sferik/test","name":"test","full_name":"@sferik/test","description":"","mode":"private","user":{"id":7505382,"profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/665875854/bb0b3653dcf0644e344823e0a2eb3382.png","time_zone":"Pacific Time (US & Canada)","location":"San Francisco","profile_background_image_url":"http://a0.twimg.com/profile_background_images/665875854/bb0b3653dcf0644e344823e0a2eb3382.png","id_str":"7505382","profile_link_color":"0084B4","geo_enabled":true,"default_profile":false,"profile_image_url":"http://a0.twimg.com/profile_images/1759857427/image1326743606_normal.png","utc_offset":-28800,"profile_use_background_image":true,"statuses_count":8576,"name":"Erik Michaels-Ober","follow_request_sent":false,"profile_text_color":"333333","lang":"en","screen_name":"sferik","listed_count":129,"protected":false,"followers_count":2449,"profile_sidebar_border_color":"000000","profile_image_url_https":"https://si0.twimg.com/profile_images/1759857427/image1326743606_normal.png","description":"An ingredient in your recipe.","is_translator":false,"profile_background_tile":false,"following":false,"profile_sidebar_fill_color":"DDEEF6","default_profile_image":false,"url":"https://github.com/sferik","favourites_count":4321,"created_at":"Mon Jul 16 12:59:01 +0000 2007","friends_count":203,"verified":false,"notifications":false,"profile_background_color":"000000","contributors_enabled":false},"following":false,"created_at":"Sun Jul 08 22:19:05 +0000 2012","member_count":2,"id_str":"73546689","subscriber_count":0,"slug":"test","id":73546689}] -------------------------------------------------------------------------------- /lib/t/delete.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require "twitter" 3 | require "t/rcfile" 4 | require "t/requestable" 5 | require "t/utils" 6 | 7 | module T 8 | class Delete < Thor 9 | include T::Requestable 10 | include T::Utils 11 | 12 | check_unknown_options! 13 | 14 | def initialize(*) 15 | @rcfile = T::RCFile.instance 16 | super 17 | end 18 | 19 | desc "block USER [USER...]", "Unblock users." 20 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names." 21 | method_option "force", aliases: "-f", type: :boolean 22 | def block(user, *users) 23 | unblocked_users, number = fetch_users(users.unshift(user), options) do |users_to_unblock| 24 | client.unblock(users_to_unblock) 25 | end 26 | say "@#{@rcfile.active_profile[0]} unblocked #{pluralize(number, 'user')}." 27 | say 28 | say "Run `#{File.basename($PROGRAM_NAME)} block #{unblocked_users.collect { |unblocked_user| "@#{unblocked_user.screen_name}" }.join(' ')}` to block." 29 | end 30 | 31 | desc "dm [DIRECT_MESSAGE_ID] [DIRECT_MESSAGE_ID...]", "Delete the last Direct Message sent." 32 | method_option "force", aliases: "-f", type: :boolean 33 | def dm(direct_message_id, *direct_message_ids) 34 | direct_message_ids.unshift(direct_message_id) 35 | require "t/core_ext/string" 36 | direct_message_ids.collect!(&:to_i) 37 | if options["force"] 38 | client.destroy_direct_message(*direct_message_ids) 39 | say "@#{@rcfile.active_profile[0]} deleted #{direct_message_ids.size} direct message#{direct_message_ids.size == 1 ? '' : 's'}." 40 | else 41 | direct_message_ids.each do |direct_message_id_to_delete| 42 | direct_message = client.direct_message(direct_message_id_to_delete) 43 | next unless direct_message 44 | 45 | recipient = client.user(direct_message.recipient_id) 46 | next unless yes? "Are you sure you want to permanently delete the direct message to @#{recipient.screen_name}: \"#{direct_message.text}\"? [y/N]" 47 | 48 | client.destroy_direct_message(direct_message_id_to_delete) 49 | say "@#{@rcfile.active_profile[0]} deleted the direct message sent to @#{recipient.screen_name}: \"#{direct_message.text}\"" 50 | end 51 | end 52 | end 53 | map %w[d m] => :dm 54 | 55 | desc "favorite TWEET_ID [TWEET_ID...]", "Delete favorites." 56 | method_option "force", aliases: "-f", type: :boolean 57 | def favorite(status_id, *status_ids) 58 | status_ids.unshift(status_id) 59 | require "t/core_ext/string" 60 | status_ids.collect!(&:to_i) 61 | if options["force"] 62 | tweets = client.unfavorite(status_ids) 63 | tweets.each do |status| 64 | say "@#{@rcfile.active_profile[0]} unfavorited @#{status.user.screen_name}'s status: \"#{status.full_text}\"" 65 | end 66 | else 67 | status_ids.each do |status_id_to_unfavorite| 68 | status = client.status(status_id_to_unfavorite, include_my_retweet: false) 69 | next unless yes? "Are you sure you want to remove @#{status.user.screen_name}'s status: \"#{status.full_text}\" from your favorites? [y/N]" 70 | 71 | client.unfavorite(status_id_to_unfavorite) 72 | say "@#{@rcfile.active_profile[0]} unfavorited @#{status.user.screen_name}'s status: \"#{status.full_text}\"" 73 | end 74 | end 75 | end 76 | map %w[fave favourite] => :favorite 77 | 78 | desc "list LIST", "Delete a list." 79 | method_option "force", aliases: "-f", type: :boolean 80 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify list via ID instead of slug." 81 | def list(list) 82 | if options["id"] 83 | require "t/core_ext/string" 84 | list = list.to_i 85 | end 86 | list = client.list(list) 87 | return if !options["force"] && !(yes? "Are you sure you want to permanently delete the list \"#{list.name}\"? [y/N]") 88 | 89 | client.destroy_list(list) 90 | say "@#{@rcfile.active_profile[0]} deleted the list \"#{list.name}\"." 91 | end 92 | 93 | desc "mute USER [USER...]", "Unmute users." 94 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names." 95 | method_option "force", aliases: "-f", type: :boolean 96 | def mute(user, *users) 97 | unmuted_users, number = fetch_users(users.unshift(user), options) do |users_to_unmute| 98 | client.unmute(users_to_unmute) 99 | end 100 | say "@#{@rcfile.active_profile[0]} unmuted #{pluralize(number, 'user')}." 101 | say 102 | say "Run `#{File.basename($PROGRAM_NAME)} mute #{unmuted_users.collect { |unmuted_user| "@#{unmuted_user.screen_name}" }.join(' ')}` to mute." 103 | end 104 | 105 | desc "account SCREEN_NAME [CONSUMER_KEY]", "delete account or consumer key from t" 106 | def account(account, key = nil) 107 | if key && @rcfile.profiles[account].keys.size > 1 108 | @rcfile.delete_key(account, key) 109 | else 110 | @rcfile.delete_profile(account) 111 | end 112 | end 113 | 114 | desc "status TWEET_ID [TWEET_ID...]", "Delete Tweets." 115 | method_option "force", aliases: "-f", type: :boolean 116 | def status(status_id, *status_ids) 117 | status_ids.unshift(status_id) 118 | require "t/core_ext/string" 119 | status_ids.collect!(&:to_i) 120 | if options["force"] 121 | tweets = client.destroy_status(status_ids, trim_user: true) 122 | tweets.each do |status| 123 | say "@#{@rcfile.active_profile[0]} deleted the Tweet: \"#{status.full_text}\"" 124 | end 125 | else 126 | status_ids.each do |status_id_to_delete| 127 | status = client.status(status_id_to_delete, include_my_retweet: false) 128 | next unless yes? "Are you sure you want to permanently delete @#{status.user.screen_name}'s status: \"#{status.full_text}\"? [y/N]" 129 | 130 | client.destroy_status(status_id_to_delete, trim_user: true) 131 | say "@#{@rcfile.active_profile[0]} deleted the Tweet: \"#{status.full_text}\"" 132 | end 133 | end 134 | end 135 | map %w[post tweet update] => :status 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/t/list.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require "twitter" 3 | require "t/collectable" 4 | require "t/printable" 5 | require "t/rcfile" 6 | require "t/requestable" 7 | require "t/utils" 8 | 9 | module T 10 | class List < Thor 11 | include T::Collectable 12 | include T::Printable 13 | include T::Requestable 14 | include T::Utils 15 | 16 | DEFAULT_NUM_RESULTS = 20 17 | 18 | check_unknown_options! 19 | 20 | def initialize(*) 21 | @rcfile = T::RCFile.instance 22 | super 23 | end 24 | 25 | desc "add LIST USER [USER...]", "Add members to a list." 26 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names." 27 | def add(list_name, user, *users) 28 | added_users, number = fetch_users(users.unshift(user), options) do |users_to_add| 29 | client.add_list_members(list_name, users_to_add) 30 | users_to_add 31 | end 32 | say "@#{@rcfile.active_profile[0]} added #{pluralize(number, 'member')} to the list \"#{list_name}\"." 33 | say 34 | if options["id"] 35 | say "Run `#{File.basename($PROGRAM_NAME)} list remove --id #{list_name} #{added_users.join(' ')}` to undo." 36 | else 37 | say "Run `#{File.basename($PROGRAM_NAME)} list remove #{list_name} #{added_users.collect { |added_user| "@#{added_user}" }.join(' ')}` to undo." 38 | end 39 | end 40 | 41 | desc "create LIST [DESCRIPTION]", "Create a new list." 42 | method_option "private", aliases: "-p", type: :boolean 43 | def create(list_name, description = nil) 44 | opts = description ? {description:} : {} 45 | opts[:mode] = "private" if options["private"] 46 | client.create_list(list_name, opts) 47 | say "@#{@rcfile.active_profile[0]} created the list \"#{list_name}\"." 48 | end 49 | 50 | desc "information [USER/]LIST", "Retrieves detailed information about a Twitter list." 51 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 52 | def information(user_list) 53 | owner, list_name = extract_owner(user_list, options) 54 | list = client.list(owner, list_name) 55 | if options["csv"] 56 | require "csv" 57 | say ["ID", "Description", "Slug", "Screen name", "Created at", "Members", "Subscribers", "Following", "Mode", "URL"].to_csv 58 | say [list.id, list.description, list.slug, list.user.screen_name, csv_formatted_time(list), list.member_count, list.subscriber_count, list.following?, list.mode, list.uri].to_csv 59 | else 60 | array = [] 61 | array << ["ID", list.id.to_s] 62 | array << ["Description", list.description] unless list.description.nil? 63 | array << ["Slug", list.slug] 64 | array << ["Screen name", "@#{list.user.screen_name}"] 65 | array << ["Created at", "#{ls_formatted_time(list, :created_at, false)} (#{time_ago_in_words(list.created_at)} ago)"] 66 | array << ["Members", number_with_delimiter(list.member_count)] 67 | array << ["Subscribers", number_with_delimiter(list.subscriber_count)] 68 | array << ["Status", list.following? ? "Following" : "Not following"] 69 | array << ["Mode", list.mode] 70 | array << ["URL", list.uri] 71 | print_table(array) 72 | end 73 | end 74 | map %w[details] => :information 75 | 76 | desc "members [USER/]LIST", "Returns the members of a Twitter list." 77 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 78 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 79 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 80 | method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort." 81 | method_option "sort", aliases: "-s", type: :string, enum: %w[favorites followers friends listed screen_name since tweets tweeted], default: "screen_name", desc: "Specify the order of the results.", banner: "ORDER" 82 | method_option "unsorted", aliases: "-u", type: :boolean, desc: "Output is not sorted." 83 | def members(user_list) 84 | owner, list_name = extract_owner(user_list, options) 85 | users = client.list_members(owner, list_name).to_a 86 | print_users(users) 87 | end 88 | 89 | desc "remove LIST USER [USER...]", "Remove members from a list." 90 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify input as Twitter user IDs instead of screen names." 91 | def remove(list_name, user, *users) 92 | removed_users, number = fetch_users(users.unshift(user), options) do |users_to_remove| 93 | client.remove_list_members(list_name, users_to_remove) 94 | users_to_remove 95 | end 96 | say "@#{@rcfile.active_profile[0]} removed #{pluralize(number, 'member')} from the list \"#{list_name}\"." 97 | say 98 | if options["id"] 99 | say "Run `#{File.basename($PROGRAM_NAME)} list add --id #{list_name} #{removed_users.join(' ')}` to undo." 100 | else 101 | say "Run `#{File.basename($PROGRAM_NAME)} list add #{list_name} #{removed_users.collect { |removed_user| "@#{removed_user}" }.join(' ')}` to undo." 102 | end 103 | end 104 | 105 | desc "timeline [USER/]LIST", "Show tweet timeline for members of the specified list." 106 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 107 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 108 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 109 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 110 | method_option "number", aliases: "-n", type: :numeric, default: DEFAULT_NUM_RESULTS, desc: "Limit the number of results." 111 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 112 | method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort." 113 | def timeline(user_list) 114 | owner, list_name = extract_owner(user_list, options) 115 | count = options["number"] || DEFAULT_NUM_RESULTS 116 | opts = {} 117 | opts[:include_entities] = !!options["decode_uris"] 118 | tweets = collect_with_count(count) do |count_opts| 119 | client.list_timeline(owner, list_name, count_opts.merge(opts)) 120 | end 121 | print_tweets(tweets) 122 | end 123 | map %w[tl] => :timeline 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /spec/rcfile_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "helper" 4 | 5 | describe T::RCFile do 6 | after do 7 | described_class.instance.reset 8 | FileUtils.rm_f("#{project_path}/tmp/trc") 9 | end 10 | 11 | it "is a singleton" do 12 | expect(described_class).to be_a Class 13 | expect do 14 | described_class.new 15 | end.to raise_error(NoMethodError, /private method (`|')new' called/) 16 | end 17 | 18 | describe "#[]" do 19 | it "returns the profiles for a user" do 20 | rcfile = described_class.instance 21 | rcfile.path = "#{fixture_path}/.trc" 22 | expect(rcfile["testcli"].keys).to eq %w[abc123] 23 | end 24 | end 25 | 26 | describe "#[]=" do 27 | it "adds a profile for a user" do 28 | rcfile = described_class.instance 29 | rcfile.path = "#{project_path}/tmp/trc" 30 | rcfile["testcli"] = { 31 | "abc123" => { 32 | username: "testcli", 33 | consumer_key: "abc123", 34 | consumer_secret: "def456", 35 | token: "ghi789", 36 | secret: "jkl012", 37 | }, 38 | } 39 | expect(rcfile["testcli"].keys).to eq %w[abc123] 40 | end 41 | 42 | it "is not be world writable" do 43 | rcfile = described_class.instance 44 | rcfile.path = "#{project_path}/tmp/trc" 45 | rcfile["testcli"] = { 46 | "abc123" => { 47 | username: "testcli", 48 | consumer_key: "abc123", 49 | consumer_secret: "def456", 50 | token: "ghi789", 51 | secret: "jkl012", 52 | }, 53 | } 54 | expect(File.world_writable?(rcfile.path)).to be_nil 55 | end 56 | 57 | it "is not be world readable" do 58 | rcfile = described_class.instance 59 | rcfile.path = "#{project_path}/tmp/trc" 60 | rcfile["testcli"] = { 61 | "abc123" => { 62 | username: "testcli", 63 | consumer_key: "abc123", 64 | consumer_secret: "def456", 65 | token: "ghi789", 66 | secret: "jkl012", 67 | }, 68 | } 69 | expect(File.world_readable?(rcfile.path)).to be_nil 70 | end 71 | end 72 | 73 | describe "#configuration" do 74 | it "returns configuration" do 75 | rcfile = described_class.instance 76 | rcfile.path = "#{fixture_path}/.trc" 77 | expect(rcfile.configuration.keys).to eq %w[default_profile] 78 | end 79 | end 80 | 81 | describe "#active_consumer_key" do 82 | it "returns default consumer key" do 83 | rcfile = described_class.instance 84 | rcfile.path = "#{fixture_path}/.trc" 85 | expect(rcfile.active_consumer_key).to eq "abc123" 86 | end 87 | end 88 | 89 | describe "#active_consumer_secret" do 90 | it "returns default consumer secret" do 91 | rcfile = described_class.instance 92 | rcfile.path = "#{fixture_path}/.trc" 93 | expect(rcfile.active_consumer_secret).to eq "asdfasd223sd2" 94 | end 95 | end 96 | 97 | describe "#active_profile" do 98 | it "returns default profile" do 99 | rcfile = described_class.instance 100 | rcfile.path = "#{fixture_path}/.trc" 101 | expect(rcfile.active_profile).to eq %w[testcli abc123] 102 | end 103 | end 104 | 105 | describe "#active_profile=" do 106 | it "sets default profile" do 107 | rcfile = described_class.instance 108 | rcfile.path = "#{project_path}/tmp/trc" 109 | rcfile.active_profile = {"username" => "testcli", "consumer_key" => "abc123"} 110 | expect(rcfile.active_profile).to eq %w[testcli abc123] 111 | end 112 | end 113 | 114 | describe "#active_token" do 115 | it "returns default token" do 116 | rcfile = described_class.instance 117 | rcfile.path = "#{fixture_path}/.trc" 118 | expect(rcfile.active_token).to eq "428004849-cebdct6bwobn" 119 | end 120 | end 121 | 122 | describe "#active_secret" do 123 | it "returns default secret" do 124 | rcfile = described_class.instance 125 | rcfile.path = "#{fixture_path}/.trc" 126 | expect(rcfile.active_secret).to eq "epzrjvxtumoc" 127 | end 128 | end 129 | 130 | describe "#delete" do 131 | it "deletes the rcfile" do 132 | path = "#{project_path}/tmp/trc" 133 | File.write(path, YAML.dump({})) 134 | expect(File.exist?(path)).to be true 135 | rcfile = described_class.instance 136 | rcfile.path = path 137 | rcfile.delete 138 | expect(File.exist?(path)).to be false 139 | end 140 | end 141 | 142 | describe "#empty?" do 143 | context "when a non-empty file exists" do 144 | it "returns false" do 145 | rcfile = described_class.instance 146 | rcfile.path = "#{fixture_path}/.trc" 147 | expect(rcfile.empty?).to be false 148 | end 149 | end 150 | 151 | context "when file does not exist at path" do 152 | it "returns true" do 153 | rcfile = described_class.instance 154 | rcfile.path = File.expand_path("fixtures/foo", __dir__) 155 | expect(rcfile.empty?).to be true 156 | end 157 | end 158 | end 159 | 160 | describe "#load_file" do 161 | context "when file exists at path" do 162 | it "loads data from file" do 163 | rcfile = described_class.instance 164 | rcfile.path = "#{fixture_path}/.trc" 165 | expect(rcfile.load_file["profiles"]["testcli"]["abc123"]["username"]).to eq "testcli" 166 | end 167 | end 168 | 169 | context "when file does not exist at path" do 170 | it "loads default structure" do 171 | rcfile = described_class.instance 172 | rcfile.path = File.expand_path("fixtures/foo", __dir__) 173 | expect(rcfile.load_file.keys.sort).to eq %w[configuration profiles] 174 | end 175 | end 176 | end 177 | 178 | describe "#path" do 179 | it "defaults to ~/.trc" do 180 | expect(described_class.instance.path).to eq File.join(File.expand_path("~"), ".trc") 181 | end 182 | end 183 | 184 | describe "#path=" do 185 | it "overrides path" do 186 | rcfile = described_class.instance 187 | rcfile.path = "#{project_path}/tmp/trc" 188 | expect(rcfile.path).to eq "#{project_path}/tmp/trc" 189 | end 190 | 191 | it "reloads data" do 192 | rcfile = described_class.instance 193 | rcfile.path = "#{fixture_path}/.trc" 194 | expect(rcfile["testcli"]["abc123"]["username"]).to eq "testcli" 195 | end 196 | end 197 | 198 | describe "#profiles" do 199 | it "returns profiles" do 200 | rcfile = described_class.instance 201 | rcfile.path = "#{fixture_path}/.trc" 202 | expect(rcfile.profiles.keys).to eq %w[testcli] 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /spec/set_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require "helper" 4 | 5 | describe T::Set do 6 | before do 7 | T::RCFile.instance.path = "#{fixture_path}/.trc" 8 | @set = described_class.new 9 | @old_stderr = $stderr 10 | $stderr = StringIO.new 11 | @old_stdout = $stdout 12 | $stdout = StringIO.new 13 | end 14 | 15 | after do 16 | T::RCFile.instance.reset 17 | $stderr = @old_stderr 18 | $stdout = @old_stdout 19 | end 20 | 21 | describe "#active" do 22 | before do 23 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc_set") 24 | end 25 | 26 | it "has the correct output" do 27 | @set.active("testcli", "abc123") 28 | expect($stdout.string.chomp).to eq "Active account has been updated to testcli." 29 | end 30 | 31 | it "accepts an account name without a consumer key" do 32 | @set.active("testcli") 33 | expect($stdout.string.chomp).to eq "Active account has been updated to testcli." 34 | end 35 | 36 | it "is case insensitive" do 37 | @set.active("TestCLI", "abc123") 38 | expect($stdout.string.chomp).to eq "Active account has been updated to testcli." 39 | end 40 | 41 | it "raises an error if username is ambiguous" do 42 | expect do 43 | @set.active("test", "abc123") 44 | end.to raise_error(ArgumentError, /Username test is ambiguous/) 45 | end 46 | 47 | it "raises an error if the username is not found" do 48 | expect do 49 | @set.active("clitest") 50 | end.to raise_error(ArgumentError, /Username clitest is not found/) 51 | end 52 | end 53 | 54 | describe "#bio" do 55 | before do 56 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 57 | stub_post("/1.1/account/update_profile.json").with(body: {description: "Vagabond."}).to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 58 | end 59 | 60 | it "requests the correct resource" do 61 | @set.bio("Vagabond.") 62 | expect(a_post("/1.1/account/update_profile.json").with(body: {description: "Vagabond."})).to have_been_made 63 | end 64 | 65 | it "has the correct output" do 66 | @set.bio("Vagabond.") 67 | expect($stdout.string.chomp).to eq "@testcli's bio has been updated." 68 | end 69 | end 70 | 71 | describe "#language" do 72 | before do 73 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 74 | stub_post("/1.1/account/settings.json").with(body: {lang: "en"}).to_return(body: fixture("settings.json"), headers: {content_type: "application/json; charset=utf-8"}) 75 | end 76 | 77 | it "requests the correct resource" do 78 | @set.language("en") 79 | expect(a_post("/1.1/account/settings.json").with(body: {lang: "en"})).to have_been_made 80 | end 81 | 82 | it "has the correct output" do 83 | @set.language("en") 84 | expect($stdout.string.chomp).to eq "@testcli's language has been updated." 85 | end 86 | end 87 | 88 | describe "#location" do 89 | before do 90 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 91 | stub_post("/1.1/account/update_profile.json").with(body: {location: "San Francisco"}).to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 92 | end 93 | 94 | it "requests the correct resource" do 95 | @set.location("San Francisco") 96 | expect(a_post("/1.1/account/update_profile.json").with(body: {location: "San Francisco"})).to have_been_made 97 | end 98 | 99 | it "has the correct output" do 100 | @set.location("San Francisco") 101 | expect($stdout.string.chomp).to eq "@testcli's location has been updated." 102 | end 103 | end 104 | 105 | describe "#name" do 106 | before do 107 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 108 | stub_post("/1.1/account/update_profile.json").with(body: {name: "Erik Michaels-Ober"}).to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 109 | end 110 | 111 | it "requests the correct resource" do 112 | @set.name("Erik Michaels-Ober") 113 | expect(a_post("/1.1/account/update_profile.json").with(body: {name: "Erik Michaels-Ober"})).to have_been_made 114 | end 115 | 116 | it "has the correct output" do 117 | @set.name("Erik Michaels-Ober") 118 | expect($stdout.string.chomp).to eq "@testcli's name has been updated." 119 | end 120 | end 121 | 122 | describe "#profile_background_image" do 123 | before do 124 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 125 | stub_post("/1.1/account/update_profile_background_image.json").to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 126 | end 127 | 128 | it "requests the correct resource" do 129 | @set.profile_background_image("#{fixture_path}/we_concept_bg2.png") 130 | expect(a_post("/1.1/account/update_profile_background_image.json")).to have_been_made 131 | end 132 | 133 | it "has the correct output" do 134 | @set.profile_background_image("#{fixture_path}/we_concept_bg2.png") 135 | expect($stdout.string.chomp).to eq "@testcli's background image has been updated." 136 | end 137 | end 138 | 139 | describe "#profile_image" do 140 | before do 141 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 142 | stub_post("/1.1/account/update_profile_image.json").to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 143 | end 144 | 145 | it "requests the correct resource" do 146 | @set.profile_image("#{fixture_path}/me.jpg") 147 | expect(a_post("/1.1/account/update_profile_image.json")).to have_been_made 148 | end 149 | 150 | it "has the correct output" do 151 | @set.profile_image("#{fixture_path}/me.jpg") 152 | expect($stdout.string.chomp).to eq "@testcli's image has been updated." 153 | end 154 | end 155 | 156 | describe "#website" do 157 | before do 158 | @set.options = @set.options.merge("profile" => "#{fixture_path}/.trc") 159 | stub_post("/1.1/account/update_profile.json").with(body: {url: "https://github.com/sferik"}).to_return(body: fixture("sferik.json"), headers: {content_type: "application/json; charset=utf-8"}) 160 | end 161 | 162 | it "requests the correct resource" do 163 | @set.website("https://github.com/sferik") 164 | expect(a_post("/1.1/account/update_profile.json").with(body: {url: "https://github.com/sferik"})).to have_been_made 165 | end 166 | 167 | it "has the correct output" do 168 | @set.website("https://github.com/sferik") 169 | expect($stdout.string.chomp).to eq "@testcli's website has been updated." 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/t/printable.rb: -------------------------------------------------------------------------------- 1 | module T 2 | module Printable # rubocop:disable Metrics/ModuleLength 3 | LIST_HEADINGS = ["ID", "Created at", "Screen name", "Slug", "Members", "Subscribers", "Mode", "Description"].freeze 4 | TWEET_HEADINGS = ["ID", "Posted at", "Screen name", "Text"].freeze 5 | USER_HEADINGS = ["ID", "Since", "Last tweeted at", "Tweets", "Favorites", "Listed", "Following", "Followers", "Screen name", "Name", "Verified", "Protected", "Bio", "Status", "Location", "URL"].freeze 6 | MONTH_IN_SECONDS = 2_592_000 7 | 8 | private 9 | 10 | def build_long_list(list) 11 | [list.id, ls_formatted_time(list), "@#{list.user.screen_name}", list.slug, list.member_count, list.subscriber_count, list.mode, list.description] 12 | end 13 | 14 | def build_long_tweet(tweet) 15 | [tweet.id, ls_formatted_time(tweet), "@#{tweet.user.screen_name}", decode_full_text(tweet, options["decode_uris"]).gsub(/\n+/, " ")] 16 | end 17 | 18 | def build_long_user(user) 19 | [user.id, ls_formatted_time(user), ls_formatted_time(user.status), user.statuses_count, user.favorites_count, user.listed_count, user.friends_count, user.followers_count, "@#{user.screen_name}", user.name, user.verified? ? "Yes" : "No", user.protected? ? "Yes" : "No", user.description.gsub(/\n+/, " "), user.status? ? decode_full_text(user.status, options["decode_uris"]).gsub(/\n+/, " ") : nil, user.location, user.website.to_s] 20 | end 21 | 22 | def csv_formatted_time(object, key = :created_at) 23 | return nil if object.nil? 24 | 25 | time = object.send(key.to_sym).dup 26 | time.utc.strftime("%Y-%m-%d %H:%M:%S %z") 27 | end 28 | 29 | def ls_formatted_time(object, key = :created_at, allow_relative = true) 30 | return "" if object.nil? 31 | 32 | time = T.local_time(object.send(key.to_sym)) 33 | if allow_relative && options["relative_dates"] 34 | "#{distance_of_time_in_words(time)} ago" 35 | elsif time > Time.now - (MONTH_IN_SECONDS * 6) 36 | time.strftime("%b %e %H:%M") 37 | else 38 | time.strftime("%b %e %Y") 39 | end 40 | end 41 | 42 | def print_csv_list(list) 43 | require "csv" 44 | say [list.id, csv_formatted_time(list), list.user.screen_name, list.slug, list.member_count, list.subscriber_count, list.mode, list.description].to_csv 45 | end 46 | 47 | def print_csv_tweet(tweet) 48 | require "csv" 49 | say [tweet.id, csv_formatted_time(tweet), tweet.user.screen_name, decode_full_text(tweet, options["decode_uris"])].to_csv 50 | end 51 | 52 | def print_csv_user(user) 53 | require "csv" 54 | say [user.id, csv_formatted_time(user), csv_formatted_time(user.status), user.statuses_count, user.favorites_count, user.listed_count, user.friends_count, user.followers_count, user.screen_name, user.name, user.verified?, user.protected?, user.description, user.status? ? user.status.full_text : nil, user.location, user.website].to_csv 55 | end 56 | 57 | def print_lists(lists) 58 | unless options["unsorted"] 59 | lists = case options["sort"] 60 | when "members" 61 | lists.sort_by(&:member_count) 62 | when "mode" 63 | lists.sort_by(&:mode) 64 | when "since" 65 | lists.sort_by(&:created_at) 66 | when "subscribers" 67 | lists.sort_by(&:subscriber_count) 68 | else 69 | lists.sort_by { |list| list.slug.downcase } 70 | end 71 | end 72 | lists.reverse! if options["reverse"] 73 | if options["csv"] 74 | require "csv" 75 | say LIST_HEADINGS.to_csv unless lists.empty? 76 | lists.each do |list| 77 | print_csv_list(list) 78 | end 79 | elsif options["long"] 80 | array = lists.collect do |list| 81 | build_long_list(list) 82 | end 83 | format = options["format"] || Array.new(LIST_HEADINGS.size) { "%s" } 84 | print_table_with_headings(array, LIST_HEADINGS, format) 85 | else 86 | print_attribute(lists, :full_name) 87 | end 88 | end 89 | 90 | def print_attribute(array, attribute) 91 | if STDOUT.tty? 92 | print_in_columns(array.collect(&attribute.to_sym)) 93 | else 94 | array.each do |element| 95 | say element.send(attribute.to_sym) 96 | end 97 | end 98 | end 99 | 100 | def print_table_with_headings(array, headings, format) 101 | return if array.flatten.empty? 102 | 103 | if STDOUT.tty? 104 | array.unshift(headings) 105 | require "t/core_ext/kernel" 106 | array.collect! do |row| 107 | row.each_with_index.collect do |element, index| 108 | next if element.nil? 109 | 110 | Kernel.send(element.class.name.to_sym, format[index] % element) 111 | end 112 | end 113 | print_table(array, truncate: true) 114 | else 115 | print_table(array) 116 | end 117 | STDOUT.flush 118 | end 119 | 120 | def print_message(from_user, message) 121 | require "htmlentities" 122 | 123 | case options["color"] 124 | when "icon" 125 | print_identicon(from_user, message) 126 | say 127 | when "auto" 128 | say(" @#{from_user}", %i[bold yellow]) 129 | print_wrapped(HTMLEntities.new.decode(message), indent: 3) 130 | else 131 | say(" @#{from_user}") 132 | print_wrapped(HTMLEntities.new.decode(message), indent: 3) 133 | end 134 | say 135 | end 136 | 137 | def print_identicon(from_user, message) 138 | require "htmlentities" 139 | require "t/identicon" 140 | icon = Identicon.for_user_name(from_user) 141 | 142 | # Save 6 chars for icon, ensure at least 3 lines long 143 | lines = wrapped(HTMLEntities.new.decode(message), indent: 2, width: Thor::Shell::Terminal.terminal_width - (6 + 5)) 144 | lines.unshift(set_color(" @#{from_user}", :bold, :yellow)) 145 | lines.concat(Array.new([3 - lines.length, 0].max) { "" }) 146 | 147 | $stdout.puts(lines.zip(icon.lines).map { |x, i| " #{i || ' '}#{x}" }) 148 | end 149 | 150 | def wrapped(message, options = {}) 151 | indent = options[:indent] || 0 152 | width = options[:width] || (Thor::Shell::Terminal.terminal_width - indent) 153 | paras = message.split("\n\n") 154 | 155 | paras.map! do |unwrapped| 156 | unwrapped.strip.squeeze(" ").gsub(/.{1,#{width}}(?:\s|\Z)/) { (::Regexp.last_match(0) + 5.chr).gsub(/\n\005/, "\n").gsub(/\005/, "\n") } 157 | end 158 | 159 | lines = paras.inject([]) do |memo, para| 160 | memo.concat(para.split("\n").map { |line| line.insert(0, " " * indent) }) 161 | memo.push "" 162 | end 163 | 164 | lines.pop 165 | lines 166 | end 167 | 168 | def print_tweets(tweets) 169 | tweets.reverse! if options["reverse"] 170 | if options["csv"] 171 | require "csv" 172 | say TWEET_HEADINGS.to_csv unless tweets.empty? 173 | tweets.each do |tweet| 174 | print_csv_tweet(tweet) 175 | end 176 | elsif options["long"] 177 | array = tweets.collect do |tweet| 178 | build_long_tweet(tweet) 179 | end 180 | format = options["format"] || Array.new(TWEET_HEADINGS.size) { "%s" } 181 | print_table_with_headings(array, TWEET_HEADINGS, format) 182 | else 183 | tweets.each do |tweet| 184 | print_message(tweet.user.screen_name, decode_uris(tweet.full_text, options["decode_uris"] ? tweet.uris : nil)) 185 | end 186 | end 187 | end 188 | 189 | def print_users(users) # rubocop:disable Metrics/CyclomaticComplexity 190 | unless options["unsorted"] 191 | users = case options["sort"] 192 | when "favorites" 193 | users.sort_by { |user| user.favorites_count.to_i } 194 | when "followers" 195 | users.sort_by { |user| user.followers_count.to_i } 196 | when "friends" 197 | users.sort_by { |user| user.friends_count.to_i } 198 | when "listed" 199 | users.sort_by { |user| user.listed_count.to_i } 200 | when "since" 201 | users.sort_by(&:created_at) 202 | when "tweets" 203 | users.sort_by { |user| user.statuses_count.to_i } 204 | when "tweeted" 205 | users.sort_by { |user| user.status? ? user.status.created_at : Time.at(0) } # rubocop:disable Metrics/BlockNesting 206 | else 207 | users.sort_by { |user| user.screen_name.downcase } 208 | end 209 | end 210 | users.reverse! if options["reverse"] 211 | if options["csv"] 212 | require "csv" 213 | say USER_HEADINGS.to_csv unless users.empty? 214 | users.each do |user| 215 | print_csv_user(user) 216 | end 217 | elsif options["long"] 218 | array = users.collect do |user| 219 | build_long_user(user) 220 | end 221 | format = options["format"] || Array.new(USER_HEADINGS.size) { "%s" } 222 | print_table_with_headings(array, USER_HEADINGS, format) 223 | else 224 | print_attribute(users, :screen_name) 225 | end 226 | end 227 | end 228 | end 229 | -------------------------------------------------------------------------------- /lib/t/stream.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require "t/printable" 3 | require "t/rcfile" 4 | require "t/requestable" 5 | require "t/utils" 6 | 7 | module T 8 | class Stream < Thor 9 | include T::Printable 10 | include T::Requestable 11 | include T::Utils 12 | 13 | TWEET_HEADINGS_FORMATTING = [ 14 | "%-18s", # Add padding to maximum length of a Tweet ID 15 | "%-12s", # Add padding to length of a timestamp formatted with ls_formatted_time 16 | "%-20s", # Add padding to maximum length of a Twitter screen name 17 | "%s", # Last element does not need special formatting 18 | ].freeze 19 | 20 | check_unknown_options! 21 | 22 | def initialize(*) 23 | @rcfile = T::RCFile.instance 24 | super 25 | end 26 | 27 | desc "all", "Stream a random sample of all Tweets (Control-C to stop)" 28 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 29 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 30 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 31 | def all 32 | streaming_client.before_request do 33 | if options["csv"] 34 | require "csv" 35 | say TWEET_HEADINGS.to_csv 36 | elsif options["long"] && STDOUT.tty? 37 | headings = Array.new(TWEET_HEADINGS.size) do |index| 38 | TWEET_HEADINGS_FORMATTING[index] % TWEET_HEADINGS[index] 39 | end 40 | print_table([headings]) 41 | end 42 | end 43 | streaming_client.sample do |tweet| 44 | next unless tweet.is_a?(Twitter::Tweet) 45 | 46 | if options["csv"] 47 | print_csv_tweet(tweet) 48 | elsif options["long"] 49 | array = build_long_tweet(tweet).each_with_index.collect do |element, index| 50 | TWEET_HEADINGS_FORMATTING[index] % element 51 | end 52 | print_table([array], truncate: STDOUT.tty?) 53 | else 54 | print_message(tweet.user.screen_name, tweet.text) 55 | end 56 | end 57 | end 58 | 59 | desc "list [USER/]LIST", "Stream a timeline for members of the specified list (Control-C to stop)" 60 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 61 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 62 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 63 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 64 | method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort." 65 | def list(user_list) 66 | owner, list_name = extract_owner(user_list, options) 67 | require "t/list" 68 | streaming_client.before_request do 69 | list = T::List.new 70 | list.options = list.options.merge(options) 71 | list.options = list.options.merge(reverse: true) 72 | list.options = list.options.merge(format: TWEET_HEADINGS_FORMATTING) 73 | list.timeline(user_list) 74 | end 75 | user_ids = client.list_members(owner, list_name).collect(&:id) 76 | streaming_client.filter(follow: user_ids.join(",")) do |tweet| 77 | next unless tweet.is_a?(Twitter::Tweet) 78 | 79 | if options["csv"] 80 | print_csv_tweet(tweet) 81 | elsif options["long"] 82 | array = build_long_tweet(tweet).each_with_index.collect do |element, index| 83 | TWEET_HEADINGS_FORMATTING[index] % element 84 | end 85 | print_table([array], truncate: STDOUT.tty?) 86 | else 87 | print_message(tweet.user.screen_name, tweet.text) 88 | end 89 | end 90 | end 91 | map %w[tl] => :timeline 92 | 93 | desc "matrix", "Unfortunately, no one can be told what the Matrix is. You have to see it for yourself." 94 | def matrix 95 | require "t/cli" 96 | streaming_client.before_request do 97 | cli = T::CLI.new 98 | cli.matrix 99 | end 100 | streaming_client.sample(language: "ja") do |tweet| 101 | next unless tweet.is_a?(Twitter::Tweet) 102 | 103 | say(tweet.text.gsub(/[^\u3000\u3040-\u309f]/, "").reverse, %i[bold green on_black], false) 104 | end 105 | end 106 | 107 | desc "search KEYWORD [KEYWORD...]", "Stream Tweets that contain specified keywords, joined with logical ORs (Control-C to stop)" 108 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 109 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 110 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 111 | def search(keyword, *keywords) 112 | keywords.unshift(keyword) 113 | require "t/search" 114 | streaming_client.before_request do 115 | search = T::Search.new 116 | search.options = search.options.merge(options) 117 | search.options = search.options.merge(reverse: true) 118 | search.options = search.options.merge(format: TWEET_HEADINGS_FORMATTING) 119 | search.all(keywords.join(" OR ")) 120 | end 121 | streaming_client.filter(track: keywords.join(",")) do |tweet| 122 | next unless tweet.is_a?(Twitter::Tweet) 123 | 124 | if options["csv"] 125 | print_csv_tweet(tweet) 126 | elsif options["long"] 127 | array = build_long_tweet(tweet).each_with_index.collect do |element, index| 128 | TWEET_HEADINGS_FORMATTING[index] % element 129 | end 130 | print_table([array], truncate: STDOUT.tty?) 131 | else 132 | print_message(tweet.user.screen_name, tweet.text) 133 | end 134 | end 135 | end 136 | 137 | desc "timeline", "Stream your timeline (Control-C to stop)" 138 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 139 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 140 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 141 | def timeline 142 | require "t/cli" 143 | streaming_client.before_request do 144 | cli = T::CLI.new 145 | cli.options = cli.options.merge(options) 146 | cli.options = cli.options.merge(reverse: true) 147 | cli.options = cli.options.merge(format: TWEET_HEADINGS_FORMATTING) 148 | cli.timeline 149 | end 150 | streaming_client.user do |tweet| 151 | next unless tweet.is_a?(Twitter::Tweet) 152 | 153 | if options["csv"] 154 | print_csv_tweet(tweet) 155 | elsif options["long"] 156 | array = build_long_tweet(tweet).each_with_index.collect do |element, index| 157 | TWEET_HEADINGS_FORMATTING[index] % element 158 | end 159 | print_table([array], truncate: STDOUT.tty?) 160 | else 161 | print_message(tweet.user.screen_name, tweet.text) 162 | end 163 | end 164 | end 165 | 166 | desc "users USER_ID [USER_ID...]", "Stream Tweets either from or in reply to specified users (Control-C to stop)" 167 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 168 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 169 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 170 | def users(user_id, *user_ids) 171 | user_ids.unshift(user_id) 172 | user_ids.collect!(&:to_i) 173 | streaming_client.before_request do 174 | if options["csv"] 175 | require "csv" 176 | say TWEET_HEADINGS.to_csv 177 | elsif options["long"] && STDOUT.tty? 178 | headings = Array.new(TWEET_HEADINGS.size) do |index| 179 | TWEET_HEADINGS_FORMATTING[index] % TWEET_HEADINGS[index] 180 | end 181 | print_table([headings]) 182 | end 183 | end 184 | streaming_client.filter(follow: user_ids.join(",")) do |tweet| 185 | next unless tweet.is_a?(Twitter::Tweet) 186 | 187 | if options["csv"] 188 | print_csv_tweet(tweet) 189 | elsif options["long"] 190 | array = build_long_tweet(tweet).each_with_index.collect do |element, index| 191 | TWEET_HEADINGS_FORMATTING[index] % element 192 | end 193 | print_table([array], truncate: STDOUT.tty?) 194 | else 195 | print_message(tweet.user.screen_name, tweet.text) 196 | end 197 | end 198 | end 199 | 200 | private 201 | 202 | def streaming_client 203 | return @streaming_client if @streaming_client 204 | 205 | @rcfile.path = options["profile"] if options["profile"] 206 | @streaming_client = Twitter::Streaming::Client.new do |config| 207 | config.consumer_key = @rcfile.active_consumer_key 208 | config.consumer_secret = @rcfile.active_consumer_secret 209 | config.access_token = @rcfile.active_token 210 | config.access_token_secret = @rcfile.active_secret 211 | end 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /spec/fixtures/recommendations.json: -------------------------------------------------------------------------------- 1 | [{"user":{"time_zone":"Eastern Time (US & Canada)","protected":false,"default_profile":true,"profile_use_background_image":true,"name":"John Trupiano","contributors_enabled":false,"created_at":"Sun May 11 19:46:06 +0000 2008","profile_background_color":"C0DEED","listed_count":99,"profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","utc_offset":-18000,"description":"Owner of @smartlogic. I tweet a mixed stream of incoherence and inside jokes. My tweets make me laugh; your mileage will vary.","verified":false,"profile_image_url":"http://a2.twimg.com/profile_images/627637055/thumb_gravatar_normal.jpg","id_str":"14736332","lang":"en","profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","favourites_count":117,"profile_text_color":"333333","status":{"truncated":false,"created_at":"Sat Aug 20 03:27:22 +0000 2011","geo":null,"in_reply_to_user_id":null,"in_reply_to_status_id":null,"favorited":false,"in_reply_to_status_id_str":null,"coordinates":null,"id_str":"104756380472324096","in_reply_to_screen_name":null,"in_reply_to_user_id_str":null,"place":null,"contributors":null,"retweeted":false,"retweet_count":0,"source":"Twitter for iPhone","id":104756380472324096,"text":"Jimmy Johns: the best place to go right after getting pepper sprayed."},"friends_count":545,"profile_sidebar_fill_color":"DDEEF6","profile_image_url_https":"https://si0.twimg.com/profile_images/627637055/thumb_gravatar_normal.jpg","screen_name":"jtrupiano","default_profile_image":false,"show_all_inline_media":false,"geo_enabled":false,"profile_background_tile":false,"location":"Baltimore, MD","notifications":null,"is_translator":false,"profile_link_color":"0084B4","url":"http://smartlogicsolutions.com/john","id":14736332,"follow_request_sent":null,"statuses_count":3850,"following":null,"profile_sidebar_border_color":"C0DEED","followers_count":802},"token":"1"},{"user":{"follow_request_sent":false,"time_zone":"Pacific Time (US & Canada)","protected":false,"profile_use_background_image":true,"name":"Matt Laroche","created_at":"Sun Apr 20 12:05:38 +0000 2008","profile_background_color":"C6E2EE","show_all_inline_media":false,"contributors_enabled":false,"geo_enabled":true,"profile_background_image_url":"http://a1.twimg.com/images/themes/theme2/bg.gif","utc_offset":-28800,"description":"Software engineer, beer advocate, Palo Altan, husband.","listed_count":20,"verified":false,"profile_image_url":"http://a3.twimg.com/profile_images/1255024245/banana_reasonably_small_normal.jpg","id_str":"14451152","lang":"en","favourites_count":10,"profile_text_color":"663B12","status":{"truncated":false,"created_at":"Sun Aug 21 20:59:41 +0000 2011","geo":{"type":"Point","coordinates":[33.8043296,-117.9226607]},"in_reply_to_user_id":20113974,"in_reply_to_status_id":105382259623854081,"favorited":false,"in_reply_to_status_id_str":"105382259623854081","coordinates":{"type":"Point","coordinates":[-117.9226607,33.8043296]},"id_str":"105383592376537088","in_reply_to_screen_name":"chasesterling","in_reply_to_user_id_str":"20113974","place":{"country_code":"US","name":"Anaheim","attributes":{},"full_name":"Anaheim, CA","place_type":"city","country":"United States","bounding_box":{"type":"Polygon","coordinates":[[[-118.017597,33.788835],[-117.674604,33.788835],[-117.674604,33.881456],[-118.017597,33.881456]]]},"id":"0c2e6999105f8070","url":"http://api.twitter.com/1/geo/id/0c2e6999105f8070.json"},"contributors":null,"retweeted":false,"retweet_count":0,"source":"Twitter for Android","id":105383592376537088,"text":"@chasesterling sadly in a fake vending machine at the Monsters Inc ride at Disney's California Adventure."},"statuses_count":6251,"profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme2/bg.gif","profile_sidebar_fill_color":"DAECF4","screen_name":"mlroach","profile_background_tile":false,"friends_count":403,"profile_image_url_https":"https://si0.twimg.com/profile_images/1255024245/banana_reasonably_small_normal.jpg","location":"Palo Alto, California","default_profile_image":false,"notifications":false,"default_profile":false,"profile_link_color":"1F98C7","url":null,"id":14451152,"is_translator":false,"following":false,"profile_sidebar_border_color":"C6E2EE","followers_count":299},"token":"21"},{"user":{"follow_request_sent":false,"statuses_count":183,"time_zone":"Greenland","protected":false,"profile_use_background_image":true,"profile_background_image_url_https":"https://si0.twimg.com/profile_background_images/121725893/3136259685_ed14d06774.jpg","name":"AntonioPires","created_at":"Sat May 16 18:24:33 +0000 2009","profile_background_color":"000000","profile_background_image_url":"http://a0.twimg.com/profile_background_images/121725893/3136259685_ed14d06774.jpg","utc_offset":-10800,"description":"","listed_count":2,"contributors_enabled":false,"verified":false,"geo_enabled":true,"profile_image_url":"http://a2.twimg.com/profile_images/1067377349/s_normal.jpg","id_str":"40514587","lang":"en","favourites_count":2,"profile_text_color":"828282","status":{"truncated":false,"created_at":"Mon Jun 13 16:27:31 +0000 2011","geo":null,"in_reply_to_user_id":null,"in_reply_to_status_id":null,"favorited":false,"possibly_sensitive":false,"in_reply_to_status_id_str":null,"coordinates":null,"id_str":"80310340704931840","in_reply_to_screen_name":null,"in_reply_to_user_id_str":null,"place":null,"contributors":null,"retweeted":false,"retweet_count":0,"source":"web","id":80310340704931840,"text":"Estou concorrendo a 1 ingresso do Alexandre Wollner no Rio – http://t.co/QlJZUqF Siga @design_blog e @pensoeventos e dê RT!"},"show_all_inline_media":false,"profile_sidebar_fill_color":"1c1c1c","screen_name":"antpires","profile_background_tile":false,"location":"Rio","default_profile_image":false,"notifications":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/1067377349/s_normal.jpg","friends_count":198,"profile_link_color":"e86f6f","url":"http://antpires.carbonmade.com","id":40514587,"is_translator":false,"default_profile":false,"following":false,"profile_sidebar_border_color":"000000","followers_count":158},"token":"14"},{"user":{"time_zone":"London","protected":false,"follow_request_sent":false,"profile_use_background_image":true,"name":"Alex MacCaw","created_at":"Fri Mar 23 12:36:14 +0000 2007","profile_background_color":"9ae4e8","contributors_enabled":false,"profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","utc_offset":0,"description":"Ruby/JavaScript developer, O'Reilly author and entrepreneur. ","default_profile":false,"verified":false,"profile_image_url":"http://a1.twimg.com/profile_images/1081483457/Gravatar_for_info_eribium_normal.jpeg","id_str":"2006261","listed_count":171,"lang":"en","favourites_count":9,"profile_text_color":"000000","status":{"truncated":false,"created_at":"Sun Aug 21 23:54:01 +0000 2011","geo":null,"in_reply_to_user_id":null,"in_reply_to_status_id":null,"favorited":false,"possibly_sensitive":false,"in_reply_to_status_id_str":null,"coordinates":null,"id_str":"105427467593981952","in_reply_to_screen_name":null,"in_reply_to_user_id_str":null,"place":null,"contributors":null,"retweeted":false,"retweet_count":0,"source":"Tweet Button","id":105427467593981952,"text":"Visualizing WebKit's hardware acceleration http://t.co/nX5pvPl via @thomasfuchs"},"profile_sidebar_fill_color":"e0ff92","screen_name":"maccman","show_all_inline_media":false,"geo_enabled":true,"profile_background_tile":false,"location":"London","notifications":false,"is_translator":false,"default_profile_image":false,"profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","profile_link_color":"0000ff","url":"http://alexmaccaw.co.uk","id":2006261,"statuses_count":4497,"following":false,"friends_count":967,"profile_sidebar_border_color":"87bc44","followers_count":2028,"profile_image_url_https":"https://si0.twimg.com/profile_images/1081483457/Gravatar_for_info_eribium_normal.jpeg"},"token":"11"},{"user":{"follow_request_sent":false,"statuses_count":24,"time_zone":"Pacific Time (US & Canada)","protected":false,"profile_use_background_image":true,"profile_background_image_url_https":"https://si0.twimg.com/images/themes/theme1/bg.png","name":"stuntmann82","created_at":"Sat Aug 30 08:22:57 +0000 2008","profile_background_color":"C0DEED","profile_background_image_url":"http://a0.twimg.com/images/themes/theme1/bg.png","utc_offset":-28800,"description":"","listed_count":1,"contributors_enabled":false,"verified":false,"geo_enabled":false,"profile_image_url":"http://a1.twimg.com/profile_images/249396146/images_normal.jpg","id_str":"16052754","lang":"en","favourites_count":0,"profile_text_color":"333333","status":{"truncated":false,"created_at":"Wed Nov 25 06:20:05 +0000 2009","geo":null,"in_reply_to_user_id":2889221,"in_reply_to_status_id":null,"favorited":false,"in_reply_to_status_id_str":null,"coordinates":null,"id_str":"6042752864","in_reply_to_screen_name":"vitaminjeff","in_reply_to_user_id_str":"2889221","place":null,"contributors":null,"retweeted":false,"retweet_count":0,"source":"TweetDeck","id":6042752864,"text":"@vitaminjeff Sup bro!"},"show_all_inline_media":false,"profile_sidebar_fill_color":"DDEEF6","screen_name":"stuntmann82","profile_background_tile":false,"location":"","default_profile_image":false,"notifications":false,"profile_image_url_https":"https://si0.twimg.com/profile_images/249396146/images_normal.jpg","friends_count":5,"profile_link_color":"0084B4","url":null,"id":16052754,"is_translator":false,"default_profile":true,"following":false,"profile_sidebar_border_color":"C0DEED","followers_count":42},"token":"14"}] -------------------------------------------------------------------------------- /spec/fixtures/geo_no_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "results" : [ 3 | { 4 | "address_components" : [ 5 | { 6 | "long_name" : "United States", 7 | "short_name" : "US", 8 | "types" : [ "country", "political" ] 9 | } 10 | ], 11 | "formatted_address" : "USA", 12 | "geometry" : { 13 | "location" : { 14 | "lat" : 37.759658, 15 | "lng" : -122.410229 16 | }, 17 | "location_type" : "ROOFTOP", 18 | "viewport" : { 19 | "northeast" : { 20 | "lat" : 37.7610069802915, 21 | "lng" : -122.4088800197085 22 | }, 23 | "southwest" : { 24 | "lat" : 37.75830901970851, 25 | "lng" : -122.4115779802915 26 | } 27 | } 28 | }, 29 | "types" : [ "street_address" ] 30 | }, 31 | { 32 | "address_components" : [ 33 | { 34 | "long_name" : "United States", 35 | "short_name" : "US", 36 | "types" : [ "country", "political" ] 37 | } 38 | ], 39 | "formatted_address" : "USA", 40 | "geometry" : { 41 | "bounds" : { 42 | "northeast" : { 43 | "lat" : 37.7719876, 44 | "lng" : -122.4027825 45 | }, 46 | "southwest" : { 47 | "lat" : 37.7478217, 48 | "lng" : -122.4309111 49 | } 50 | }, 51 | "location" : { 52 | "lat" : 37.7598648, 53 | "lng" : -122.4147977 54 | }, 55 | "location_type" : "APPROXIMATE", 56 | "viewport" : { 57 | "northeast" : { 58 | "lat" : 37.7719876, 59 | "lng" : -122.4027825 60 | }, 61 | "southwest" : { 62 | "lat" : 37.7478217, 63 | "lng" : -122.4309111 64 | } 65 | } 66 | }, 67 | "types" : [ "neighborhood", "political" ] 68 | }, 69 | { 70 | "address_components" : [ 71 | { 72 | "long_name" : "United States", 73 | "short_name" : "US", 74 | "types" : [ "country", "political" ] 75 | } 76 | ], 77 | "formatted_address" : "USA", 78 | "geometry" : { 79 | "bounds" : { 80 | "northeast" : { 81 | "lat" : 37.76578300000001, 82 | "lng" : -122.402855 83 | }, 84 | "southwest" : { 85 | "lat" : 37.731608, 86 | "lng" : -122.427269 87 | } 88 | }, 89 | "location" : { 90 | "lat" : 37.7485824, 91 | "lng" : -122.4184108 92 | }, 93 | "location_type" : "APPROXIMATE", 94 | "viewport" : { 95 | "northeast" : { 96 | "lat" : 37.76578300000001, 97 | "lng" : -122.402855 98 | }, 99 | "southwest" : { 100 | "lat" : 37.731608, 101 | "lng" : -122.427269 102 | } 103 | } 104 | }, 105 | "types" : [ "postal_code" ] 106 | }, 107 | { 108 | "address_components" : [ 109 | { 110 | "long_name" : "United States", 111 | "short_name" : "US", 112 | "types" : [ "country", "political" ] 113 | } 114 | ], 115 | "formatted_address" : "USA", 116 | "geometry" : { 117 | "bounds" : { 118 | "northeast" : { 119 | "lat" : 37.9297707, 120 | "lng" : -122.3279148 121 | }, 122 | "southwest" : { 123 | "lat" : 37.6933354, 124 | "lng" : -123.1077733 125 | } 126 | }, 127 | "location" : { 128 | "lat" : 37.7749295, 129 | "lng" : -122.4194155 130 | }, 131 | "location_type" : "APPROXIMATE", 132 | "viewport" : { 133 | "northeast" : { 134 | "lat" : 37.812, 135 | "lng" : -122.3482 136 | }, 137 | "southwest" : { 138 | "lat" : 37.70339999999999, 139 | "lng" : -122.527 140 | } 141 | } 142 | }, 143 | "types" : [ "locality", "political" ] 144 | }, 145 | { 146 | "address_components" : [ 147 | { 148 | "long_name" : "United States", 149 | "short_name" : "US", 150 | "types" : [ "country", "political" ] 151 | } 152 | ], 153 | "formatted_address" : "USA", 154 | "geometry" : { 155 | "bounds" : { 156 | "northeast" : { 157 | "lat" : 37.9297707, 158 | "lng" : -122.3279148 159 | }, 160 | "southwest" : { 161 | "lat" : 37.6933354, 162 | "lng" : -123.1077733 163 | } 164 | }, 165 | "location" : { 166 | "lat" : 37.7749073, 167 | "lng" : -122.4193878 168 | }, 169 | "location_type" : "APPROXIMATE", 170 | "viewport" : { 171 | "northeast" : { 172 | "lat" : 37.833827, 173 | "lng" : -122.3551997 174 | }, 175 | "southwest" : { 176 | "lat" : 37.708086, 177 | "lng" : -122.5300999 178 | } 179 | } 180 | }, 181 | "types" : [ "administrative_area_level_2", "political" ] 182 | }, 183 | { 184 | "address_components" : [ 185 | { 186 | "long_name" : "United States", 187 | "short_name" : "US", 188 | "types" : [ "country", "political" ] 189 | } 190 | ], 191 | "formatted_address" : "United States", 192 | "geometry" : { 193 | "bounds" : { 194 | "northeast" : { 195 | "lat" : 38.320945, 196 | "lng" : -121.469275 197 | }, 198 | "southwest" : { 199 | "lat" : 37.1073458, 200 | "lng" : -123.024066 201 | } 202 | }, 203 | "location" : { 204 | "lat" : 37.8043507, 205 | "lng" : -121.8107079 206 | }, 207 | "location_type" : "APPROXIMATE", 208 | "viewport" : { 209 | "northeast" : { 210 | "lat" : 38.320945, 211 | "lng" : -121.469275 212 | }, 213 | "southwest" : { 214 | "lat" : 37.1073458, 215 | "lng" : -123.024066 216 | } 217 | } 218 | }, 219 | "types" : [ "political" ] 220 | }, 221 | { 222 | "address_components" : [ 223 | { 224 | "long_name" : "United States", 225 | "short_name" : "US", 226 | "types" : [ "country", "political" ] 227 | } 228 | ], 229 | "formatted_address" : "USA", 230 | "geometry" : { 231 | "bounds" : { 232 | "northeast" : { 233 | "lat" : 42.0095169, 234 | "lng" : -114.131211 235 | }, 236 | "southwest" : { 237 | "lat" : 32.5342321, 238 | "lng" : -124.4096195 239 | } 240 | }, 241 | "location" : { 242 | "lat" : 36.778261, 243 | "lng" : -119.4179324 244 | }, 245 | "location_type" : "APPROXIMATE", 246 | "viewport" : { 247 | "northeast" : { 248 | "lat" : 42.0095169, 249 | "lng" : -114.131211 250 | }, 251 | "southwest" : { 252 | "lat" : 32.5342321, 253 | "lng" : -124.4096195 254 | } 255 | } 256 | }, 257 | "types" : [ "administrative_area_level_1", "political" ] 258 | }, 259 | { 260 | "address_components" : [ 261 | { 262 | "long_name" : "United States", 263 | "short_name" : "US", 264 | "types" : [ "country", "political" ] 265 | } 266 | ], 267 | "formatted_address" : "United States", 268 | "geometry" : { 269 | "bounds" : { 270 | "northeast" : { 271 | "lat" : 90, 272 | "lng" : 180 273 | }, 274 | "southwest" : { 275 | "lat" : -90, 276 | "lng" : -180 277 | } 278 | }, 279 | "location" : { 280 | "lat" : 37.09024, 281 | "lng" : -95.712891 282 | }, 283 | "location_type" : "APPROXIMATE", 284 | "viewport" : { 285 | "northeast" : { 286 | "lat" : 49.38, 287 | "lng" : -66.94 288 | }, 289 | "southwest" : { 290 | "lat" : 25.82, 291 | "lng" : -124.39 292 | } 293 | } 294 | }, 295 | "types" : [ "country", "political" ] 296 | } 297 | ], 298 | "status" : "OK" 299 | } 300 | -------------------------------------------------------------------------------- /lib/t/search.rb: -------------------------------------------------------------------------------- 1 | require "thor" 2 | require "twitter" 3 | require "t/collectable" 4 | require "t/printable" 5 | require "t/rcfile" 6 | require "t/requestable" 7 | require "t/utils" 8 | 9 | module T 10 | class Search < Thor 11 | include T::Collectable 12 | include T::Printable 13 | include T::Requestable 14 | include T::Utils 15 | 16 | DEFAULT_NUM_RESULTS = 20 17 | MAX_NUM_RESULTS = 200 18 | MAX_SEARCH_RESULTS = 100 19 | 20 | check_unknown_options! 21 | 22 | def initialize(*) 23 | @rcfile = T::RCFile.instance 24 | super 25 | end 26 | 27 | desc "all QUERY", "Returns the #{DEFAULT_NUM_RESULTS} most recent Tweets that match the specified query." 28 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 29 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 30 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 31 | method_option "number", aliases: "-n", type: :numeric, default: DEFAULT_NUM_RESULTS 32 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 33 | def all(query) 34 | count = options["number"] || DEFAULT_NUM_RESULTS 35 | opts = {count: MAX_SEARCH_RESULTS} 36 | opts[:include_entities] = !!options["decode_uris"] 37 | tweets = client.search(query, opts).take(count) 38 | tweets.reverse! if options["reverse"] 39 | if options["csv"] 40 | require "csv" 41 | say TWEET_HEADINGS.to_csv unless tweets.empty? 42 | tweets.each do |tweet| 43 | say [tweet.id, csv_formatted_time(tweet), tweet.user.screen_name, decode_full_text(tweet, options["decode_uris"])].to_csv 44 | end 45 | elsif options["long"] 46 | array = tweets.collect do |tweet| 47 | [tweet.id, ls_formatted_time(tweet), "@#{tweet.user.screen_name}", decode_full_text(tweet, options["decode_uris"]).gsub(/\n+/, " ")] 48 | end 49 | format = options["format"] || Array.new(TWEET_HEADINGS.size) { "%s" } 50 | print_table_with_headings(array, TWEET_HEADINGS, format) 51 | else 52 | say unless tweets.empty? 53 | tweets.each do |tweet| 54 | print_message(tweet.user.screen_name, decode_full_text(tweet, options["decode_uris"])) 55 | end 56 | end 57 | end 58 | 59 | desc "favorites [USER] QUERY", "Returns Tweets you've favorited that match the specified query." 60 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 61 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 62 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 63 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 64 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 65 | def favorites(*args) 66 | query = args.pop 67 | user = args.pop 68 | opts = {count: MAX_NUM_RESULTS} 69 | opts[:include_entities] = !!options["decode_uris"] 70 | if user 71 | require "t/core_ext/string" 72 | user = options["id"] ? user.to_i : user.strip_ats 73 | tweets = collect_with_max_id do |max_id| 74 | opts[:max_id] = max_id unless max_id.nil? 75 | client.favorites(user, opts) 76 | end 77 | else 78 | tweets = collect_with_max_id do |max_id| 79 | opts[:max_id] = max_id unless max_id.nil? 80 | client.favorites(opts) 81 | end 82 | end 83 | tweets = tweets.select do |tweet| 84 | /#{query}/i.match(tweet.full_text) 85 | end 86 | print_tweets(tweets) 87 | end 88 | map %w[faves] => :favorites 89 | 90 | desc "list [USER/]LIST QUERY", "Returns Tweets on a list that match the specified query." 91 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 92 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 93 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 94 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 95 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 96 | def list(user_list, query) 97 | owner, list_name = extract_owner(user_list, options) 98 | opts = {count: MAX_NUM_RESULTS} 99 | opts[:include_entities] = !!options["decode_uris"] 100 | tweets = collect_with_max_id do |max_id| 101 | opts[:max_id] = max_id unless max_id.nil? 102 | client.list_timeline(owner, list_name, opts) 103 | end 104 | tweets = tweets.select do |tweet| 105 | /#{query}/i.match(tweet.full_text) 106 | end 107 | print_tweets(tweets) 108 | end 109 | 110 | desc "mentions QUERY", "Returns Tweets mentioning you that match the specified query." 111 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 112 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 113 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 114 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 115 | def mentions(query) 116 | opts = {count: MAX_NUM_RESULTS} 117 | opts[:include_entities] = !!options["decode_uris"] 118 | tweets = collect_with_max_id do |max_id| 119 | opts[:max_id] = max_id unless max_id.nil? 120 | client.mentions(opts) 121 | end 122 | tweets = tweets.select do |tweet| 123 | /#{query}/i.match(tweet.full_text) 124 | end 125 | print_tweets(tweets) 126 | end 127 | map %w[replies] => :mentions 128 | 129 | desc "retweets [USER] QUERY", "Returns Tweets you've retweeted that match the specified query." 130 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 131 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 132 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 133 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 134 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 135 | def retweets(*args) 136 | query = args.pop 137 | user = args.pop 138 | opts = {count: MAX_NUM_RESULTS} 139 | opts[:include_entities] = !!options["decode_uris"] 140 | if user 141 | require "t/core_ext/string" 142 | user = options["id"] ? user.to_i : user.strip_ats 143 | tweets = collect_with_max_id do |max_id| 144 | opts[:max_id] = max_id unless max_id.nil? 145 | client.retweeted_by_user(user, opts) 146 | end 147 | else 148 | tweets = collect_with_max_id do |max_id| 149 | opts[:max_id] = max_id unless max_id.nil? 150 | client.retweeted_by_me(opts) 151 | end 152 | end 153 | tweets = tweets.select do |tweet| 154 | /#{query}/i.match(tweet.full_text) 155 | end 156 | print_tweets(tweets) 157 | end 158 | map %w[rts] => :retweets 159 | 160 | desc "timeline [USER] QUERY", "Returns Tweets in your timeline that match the specified query." 161 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 162 | method_option "decode_uris", aliases: "-d", type: :boolean, desc: "Decodes t.co URLs into their original form." 163 | method_option "exclude", aliases: "-e", type: :string, enum: %w[replies retweets], desc: "Exclude certain types of Tweets from the results.", banner: "TYPE" 164 | method_option "id", aliases: "-i", type: :boolean, desc: "Specify user via ID instead of screen name." 165 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 166 | method_option "max_id", aliases: "-m", type: :numeric, desc: "Returns only the results with an ID less than the specified ID." 167 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 168 | method_option "since_id", aliases: "-s", type: :numeric, desc: "Returns only the results with an ID greater than the specified ID." 169 | def timeline(*args) 170 | query = args.pop 171 | user = args.pop 172 | opts = {count: MAX_NUM_RESULTS} 173 | opts[:exclude_replies] = true if options["exclude"] == "replies" 174 | opts[:include_entities] = !!options["decode_uris"] 175 | opts[:include_rts] = false if options["exclude"] == "retweets" 176 | opts[:max_id] = options["max_id"] if options["max_id"] 177 | opts[:since_id] = options["since_id"] if options["since_id"] 178 | if user 179 | require "t/core_ext/string" 180 | user = options["id"] ? user.to_i : user.strip_ats 181 | tweets = collect_with_max_id do |max_id| 182 | opts[:max_id] = max_id unless max_id.nil? 183 | client.user_timeline(user, opts) 184 | end 185 | else 186 | tweets = collect_with_max_id do |max_id| 187 | opts[:max_id] = max_id unless max_id.nil? 188 | client.home_timeline(opts) 189 | end 190 | end 191 | tweets = tweets.select do |tweet| 192 | /#{query}/i.match(tweet.full_text) 193 | end 194 | print_tweets(tweets) 195 | end 196 | map %w[tl] => :timeline 197 | 198 | desc "users QUERY", "Returns users that match the specified query." 199 | method_option "csv", aliases: "-c", type: :boolean, desc: "Output in CSV format." 200 | method_option "long", aliases: "-l", type: :boolean, desc: "Output in long format." 201 | method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates." 202 | method_option "reverse", aliases: "-r", type: :boolean, desc: "Reverse the order of the sort." 203 | method_option "sort", aliases: "-s", type: :string, enum: %w[favorites followers friends listed screen_name since tweets tweeted], default: "screen_name", desc: "Specify the order of the results.", banner: "ORDER" 204 | method_option "unsorted", aliases: "-u", type: :boolean, desc: "Output is not sorted." 205 | def users(query) 206 | users = collect_with_page do |page| 207 | client.user_search(query, page:) 208 | end 209 | print_users(users) 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /spec/fixtures/geo_no_city.json: -------------------------------------------------------------------------------- 1 | { 2 | "results" : [ 3 | { 4 | "address_components" : [ 5 | { 6 | "long_name" : "California", 7 | "short_name" : "CA", 8 | "types" : [ "administrative_area_level_1", "political" ] 9 | }, 10 | { 11 | "long_name" : "United States", 12 | "short_name" : "US", 13 | "types" : [ "country", "political" ] 14 | }, 15 | { 16 | "long_name" : "94110", 17 | "short_name" : "94110", 18 | "types" : [ "postal_code" ] 19 | } 20 | ], 21 | "formatted_address" : "CA, USA", 22 | "geometry" : { 23 | "location" : { 24 | "lat" : 37.759658, 25 | "lng" : -122.410229 26 | }, 27 | "location_type" : "ROOFTOP", 28 | "viewport" : { 29 | "northeast" : { 30 | "lat" : 37.7610069802915, 31 | "lng" : -122.4088800197085 32 | }, 33 | "southwest" : { 34 | "lat" : 37.75830901970851, 35 | "lng" : -122.4115779802915 36 | } 37 | } 38 | }, 39 | "types" : [ "street_address" ] 40 | }, 41 | { 42 | "address_components" : [ 43 | { 44 | "long_name" : "California", 45 | "short_name" : "CA", 46 | "types" : [ "administrative_area_level_1", "political" ] 47 | }, 48 | { 49 | "long_name" : "United States", 50 | "short_name" : "US", 51 | "types" : [ "country", "political" ] 52 | } 53 | ], 54 | "formatted_address" : "CA, USA", 55 | "geometry" : { 56 | "bounds" : { 57 | "northeast" : { 58 | "lat" : 37.7719876, 59 | "lng" : -122.4027825 60 | }, 61 | "southwest" : { 62 | "lat" : 37.7478217, 63 | "lng" : -122.4309111 64 | } 65 | }, 66 | "location" : { 67 | "lat" : 37.7598648, 68 | "lng" : -122.4147977 69 | }, 70 | "location_type" : "APPROXIMATE", 71 | "viewport" : { 72 | "northeast" : { 73 | "lat" : 37.7719876, 74 | "lng" : -122.4027825 75 | }, 76 | "southwest" : { 77 | "lat" : 37.7478217, 78 | "lng" : -122.4309111 79 | } 80 | } 81 | }, 82 | "types" : [ "neighborhood", "political" ] 83 | }, 84 | { 85 | "address_components" : [ 86 | { 87 | "long_name" : "California", 88 | "short_name" : "CA", 89 | "types" : [ "administrative_area_level_1", "political" ] 90 | }, 91 | { 92 | "long_name" : "United States", 93 | "short_name" : "US", 94 | "types" : [ "country", "political" ] 95 | } 96 | ], 97 | "formatted_address" : "CA 94110, USA", 98 | "geometry" : { 99 | "bounds" : { 100 | "northeast" : { 101 | "lat" : 37.76578300000001, 102 | "lng" : -122.402855 103 | }, 104 | "southwest" : { 105 | "lat" : 37.731608, 106 | "lng" : -122.427269 107 | } 108 | }, 109 | "location" : { 110 | "lat" : 37.7485824, 111 | "lng" : -122.4184108 112 | }, 113 | "location_type" : "APPROXIMATE", 114 | "viewport" : { 115 | "northeast" : { 116 | "lat" : 37.76578300000001, 117 | "lng" : -122.402855 118 | }, 119 | "southwest" : { 120 | "lat" : 37.731608, 121 | "lng" : -122.427269 122 | } 123 | } 124 | }, 125 | "types" : [ "postal_code" ] 126 | }, 127 | { 128 | "address_components" : [ 129 | { 130 | "long_name" : "California", 131 | "short_name" : "CA", 132 | "types" : [ "administrative_area_level_1", "political" ] 133 | }, 134 | { 135 | "long_name" : "United States", 136 | "short_name" : "US", 137 | "types" : [ "country", "political" ] 138 | } 139 | ], 140 | "formatted_address" : "CA, USA", 141 | "geometry" : { 142 | "bounds" : { 143 | "northeast" : { 144 | "lat" : 37.9297707, 145 | "lng" : -122.3279148 146 | }, 147 | "southwest" : { 148 | "lat" : 37.6933354, 149 | "lng" : -123.1077733 150 | } 151 | }, 152 | "location" : { 153 | "lat" : 37.7749295, 154 | "lng" : -122.4194155 155 | }, 156 | "location_type" : "APPROXIMATE", 157 | "viewport" : { 158 | "northeast" : { 159 | "lat" : 37.812, 160 | "lng" : -122.3482 161 | }, 162 | "southwest" : { 163 | "lat" : 37.70339999999999, 164 | "lng" : -122.527 165 | } 166 | } 167 | }, 168 | "types" : [ "locality", "political" ] 169 | }, 170 | { 171 | "address_components" : [ 172 | { 173 | "long_name" : "California", 174 | "short_name" : "CA", 175 | "types" : [ "administrative_area_level_1", "political" ] 176 | }, 177 | { 178 | "long_name" : "United States", 179 | "short_name" : "US", 180 | "types" : [ "country", "political" ] 181 | } 182 | ], 183 | "formatted_address" : "CA, USA", 184 | "geometry" : { 185 | "bounds" : { 186 | "northeast" : { 187 | "lat" : 37.9297707, 188 | "lng" : -122.3279148 189 | }, 190 | "southwest" : { 191 | "lat" : 37.6933354, 192 | "lng" : -123.1077733 193 | } 194 | }, 195 | "location" : { 196 | "lat" : 37.7749073, 197 | "lng" : -122.4193878 198 | }, 199 | "location_type" : "APPROXIMATE", 200 | "viewport" : { 201 | "northeast" : { 202 | "lat" : 37.833827, 203 | "lng" : -122.3551997 204 | }, 205 | "southwest" : { 206 | "lat" : 37.708086, 207 | "lng" : -122.5300999 208 | } 209 | } 210 | }, 211 | "types" : [ "administrative_area_level_2", "political" ] 212 | }, 213 | { 214 | "address_components" : [ 215 | { 216 | "long_name" : "サン・フランシスコ=オークランド=フリモント", 217 | "short_name" : "サン・フランシスコ=オークランド=フリモント", 218 | "types" : [ "political" ] 219 | }, 220 | { 221 | "long_name" : "California", 222 | "short_name" : "CA", 223 | "types" : [ "administrative_area_level_1", "political" ] 224 | }, 225 | { 226 | "long_name" : "United States", 227 | "short_name" : "US", 228 | "types" : [ "country", "political" ] 229 | } 230 | ], 231 | "formatted_address" : "United States, California, サン・フランシスコ=オークランド=フリモント", 232 | "geometry" : { 233 | "bounds" : { 234 | "northeast" : { 235 | "lat" : 38.320945, 236 | "lng" : -121.469275 237 | }, 238 | "southwest" : { 239 | "lat" : 37.1073458, 240 | "lng" : -123.024066 241 | } 242 | }, 243 | "location" : { 244 | "lat" : 37.8043507, 245 | "lng" : -121.8107079 246 | }, 247 | "location_type" : "APPROXIMATE", 248 | "viewport" : { 249 | "northeast" : { 250 | "lat" : 38.320945, 251 | "lng" : -121.469275 252 | }, 253 | "southwest" : { 254 | "lat" : 37.1073458, 255 | "lng" : -123.024066 256 | } 257 | } 258 | }, 259 | "types" : [ "political" ] 260 | }, 261 | { 262 | "address_components" : [ 263 | { 264 | "long_name" : "California", 265 | "short_name" : "CA", 266 | "types" : [ "administrative_area_level_1", "political" ] 267 | }, 268 | { 269 | "long_name" : "United States", 270 | "short_name" : "US", 271 | "types" : [ "country", "political" ] 272 | } 273 | ], 274 | "formatted_address" : "California, USA", 275 | "geometry" : { 276 | "bounds" : { 277 | "northeast" : { 278 | "lat" : 42.0095169, 279 | "lng" : -114.131211 280 | }, 281 | "southwest" : { 282 | "lat" : 32.5342321, 283 | "lng" : -124.4096195 284 | } 285 | }, 286 | "location" : { 287 | "lat" : 36.778261, 288 | "lng" : -119.4179324 289 | }, 290 | "location_type" : "APPROXIMATE", 291 | "viewport" : { 292 | "northeast" : { 293 | "lat" : 42.0095169, 294 | "lng" : -114.131211 295 | }, 296 | "southwest" : { 297 | "lat" : 32.5342321, 298 | "lng" : -124.4096195 299 | } 300 | } 301 | }, 302 | "types" : [ "administrative_area_level_1", "political" ] 303 | }, 304 | { 305 | "address_components" : [ 306 | { 307 | "long_name" : "United States", 308 | "short_name" : "US", 309 | "types" : [ "country", "political" ] 310 | } 311 | ], 312 | "formatted_address" : "United States", 313 | "geometry" : { 314 | "bounds" : { 315 | "northeast" : { 316 | "lat" : 90, 317 | "lng" : 180 318 | }, 319 | "southwest" : { 320 | "lat" : -90, 321 | "lng" : -180 322 | } 323 | }, 324 | "location" : { 325 | "lat" : 37.09024, 326 | "lng" : -95.712891 327 | }, 328 | "location_type" : "APPROXIMATE", 329 | "viewport" : { 330 | "northeast" : { 331 | "lat" : 49.38, 332 | "lng" : -66.94 333 | }, 334 | "southwest" : { 335 | "lat" : 25.82, 336 | "lng" : -124.39 337 | } 338 | } 339 | }, 340 | "types" : [ "country", "political" ] 341 | } 342 | ], 343 | "status" : "OK" 344 | } 345 | -------------------------------------------------------------------------------- /spec/fixtures/direct_message_events.json: -------------------------------------------------------------------------------- 1 | { 2 | "events":[ 3 | { 4 | "type":"message_create", 5 | "id":"856574281366605831", 6 | "created_timestamp":"1493058197715", 7 | "message_create":{ 8 | "target":{ 9 | "recipient_id":"7505382" 10 | }, 11 | "sender_id":"358486183", 12 | "message_data":{ 13 | "text":"Thanks https://t.co/ZxBEw35k5z", 14 | "entities":{ 15 | "hashtags":[], 16 | "symbols":[], 17 | "user_mentions":[], 18 | "urls":[ 19 | { 20 | "url":"https://t.co/ZxBEw35k5z", 21 | "expanded_url":"https://twitter.com/i/stickers/image/10011", 22 | "display_url":"twitter.com/i/stickers/ima…", 23 | "indices":[1,24] 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | }, 30 | { 31 | "type":"message_create", 32 | "id":"856571192978927619", 33 | "created_timestamp":"1493057461386", 34 | "message_create":{ 35 | "target":{ 36 | "recipient_id":"7505382" 37 | }, 38 | "sender_id":"311650899", 39 | "message_data":{ 40 | "text":"❤️", 41 | "entities":{ 42 | "hashtags":[], 43 | "symbols":[], 44 | "user_mentions":[], 45 | "urls":[] 46 | } 47 | } 48 | } 49 | }, 50 | { 51 | "type":"message_create", 52 | "id":"856554872984018948", 53 | "created_timestamp":"1493053570396", 54 | "message_create":{ 55 | "target":{ 56 | "recipient_id":"7505382" 57 | }, 58 | "sender_id":"422190131", 59 | "message_data":{ 60 | "text":"😍", 61 | "entities":{ 62 | "hashtags":[], 63 | "symbols":[], 64 | "user_mentions":[], 65 | "urls":[] 66 | } 67 | } 68 | } 69 | }, 70 | { 71 | "type":"message_create", 72 | "id":"856538753409703939", 73 | "created_timestamp":"1493049727190", 74 | "message_create":{ 75 | "target":{"recipient_id":"7505382"}, 76 | "sender_id":"759849327200047104", 77 | "message_data":{ 78 | "text":"obrigada!!! bj", 79 | "entities":{ 80 | "hashtags":[], 81 | "symbols":[], 82 | "user_mentions":[], 83 | "urls":[] 84 | } 85 | } 86 | } 87 | }, 88 | { 89 | "type":"message_create", 90 | "id":"856533644445396996", 91 | "created_timestamp":"1493048509118", 92 | "message_create":{ 93 | "target":{ 94 | "recipient_id":"7505382" 95 | }, 96 | "sender_id":"73660881", 97 | "message_data":{ 98 | "text":" https://t.co/ZxBEw35k5z", 99 | "entities":{ 100 | "hashtags":[], 101 | "symbols":[], 102 | "user_mentions":[], 103 | "urls":[ 104 | { 105 | "url":"https://t.co/ZxBEw35k5z", 106 | "expanded_url":"https://twitter.com/i/stickers/image/10011", 107 | "display_url":"twitter.com/i/stickers/ima…","indices":[1,24] 108 | } 109 | ] 110 | } 111 | } 112 | } 113 | }, 114 | { 115 | "type":"message_create", 116 | "id":"856526573545062407", 117 | "created_timestamp":"1493046823284", 118 | "message_create":{ 119 | "target":{"recipient_id":"7505382"}, 120 | "sender_id":"328677087", 121 | "message_data":{ 122 | "text":"OBRIGADO MINHA LINDA SERÁ INCRÍVEL ASSISTIR O TEU SHOW, VOU FAZER O POSSÍVEL PARA TE PRESTIGIAR. SUCESSO", 123 | "entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]} 124 | } 125 | } 126 | }, 127 | { 128 | "type":"message_create", 129 | "id":"856523843892129796", 130 | "created_timestamp":"1493046172484", 131 | "message_create":{ 132 | "target":{"recipient_id":"422190131"}, 133 | "sender_id":"7505382", 134 | "message_data":{ 135 | "text":" https://t.co/KQcQAF6hVS", 136 | "entities":{ 137 | "hashtags":[], 138 | "symbols":[], 139 | "user_mentions":[], 140 | "urls":[ 141 | { 142 | "url":"https://t.co/KQcQAF6hVS", 143 | "expanded_url":"https://twitter.com/i/stickers/image/10018", 144 | "display_url":"twitter.com/i/stickers/ima…", 145 | "indices":[1,24] 146 | } 147 | ] 148 | } 149 | } 150 | } 151 | }, 152 | { 153 | "type":"message_create", 154 | "id":"856523768910544899", 155 | "created_timestamp":"1493046154607", 156 | "message_create":{ 157 | "target":{"recipient_id":"4374876088"}, 158 | "sender_id":"7505382", 159 | "message_data":{ 160 | "text":" https://t.co/MG2QdVuPGa", 161 | "entities":{ 162 | "hashtags":[], 163 | "symbols":[], 164 | "user_mentions":[], 165 | "urls":[ 166 | { 167 | "url":"https://t.co/MG2QdVuPGa", 168 | "expanded_url":"https://twitter.com/i/stickers/image/10017", 169 | "display_url":"twitter.com/i/stickers/ima…", 170 | "indices":[1,24] 171 | } 172 | ] 173 | } 174 | } 175 | } 176 | }, 177 | { 178 | "type":"message_create", 179 | "id":"856516885524951043","created_timestamp":"1493044513480", 180 | "message_create":{ 181 | "target":{"recipient_id":"7505382"}, 182 | "sender_id":"4374876088", 183 | "message_data":{ 184 | "text":"Obrigado. Vou adquiri-lo. Muito sucesso!","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}} 185 | }, 186 | { 187 | "type":"message_create", 188 | "id":"856502352299405315", 189 | "created_timestamp":"1493041048489", 190 | "message_create":{ 191 | "target":{"recipient_id":"7505382"}, 192 | "sender_id":"422190131", 193 | "message_data":{ 194 | "text":"COM CERTEZA QDO ESTIVER EM SAO PAUÇO IREI COM O MAIOR PRAZER SUCESSO LINDA", 195 | "entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]} 196 | } 197 | } 198 | }, 199 | { 200 | "type":"message_create", 201 | "id":"856480385957548035", 202 | "created_timestamp":"1493035811305", 203 | "message_create":{ 204 | "target":{"recipient_id":"2924245126"}, 205 | "sender_id":"7505382", 206 | "message_data":{ 207 | "text":"Obrigada Jacques", 208 | "entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]} 209 | } 210 | } 211 | }, 212 | { 213 | "type":"message_create","id":"856480124421771268","created_timestamp":"1493035748950","message_create":{"target":{"recipient_id":"7505382"},"sender_id":"2924245126","message_data":{"text":"😍 Música boa para seu espetáculo em São-Paulo com seu amigo","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}},{"type":"message_create","id":"856478933260410883","created_timestamp":"1493035464955","message_create":{"target":{"recipient_id":"7505382"},"sender_id":"2924245126","message_data":{"text":"Jardim urbano","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}} 214 | }, 215 | { 216 | "type":"message_create", 217 | "id":"856478621090942979", 218 | "created_timestamp":"1493035390528", 219 | "message_create":{ 220 | "target":{"recipient_id":"7505382"}, 221 | "sender_id":"2924245126", 222 | "message_data":{ 223 | "text":" https://t.co/1ojXzm8bKx", 224 | "entities":{ 225 | "hashtags":[], 226 | "symbols":[], 227 | "user_mentions":[], 228 | "urls":[ 229 | { 230 | "url":"https://t.co/1ojXzm8bKx", 231 | "expanded_url":"https://twitter.com/messages/media/856478621090942979", 232 | "display_url":"pic.twitter.com/1ojXzm8bKx", 233 | "indices":[1,24] 234 | } 235 | ] 236 | }, 237 | "attachment":{ 238 | "type":"media", 239 | "media":{ 240 | "id":856478542527385601, 241 | "id_str":"856478542527385601", 242 | "indices":[1,24], 243 | "media_url":"https://ton.twitter.com/1.1/ton/data/dm/856478621090942979/856478542527385601/d3LfgVMN.jpg", 244 | "media_url_https":"https://ton.twitter.com/1.1/ton/data/dm/856478621090942979/856478542527385601/d3LfgVMN.jpg", 245 | "url":"https://t.co/1ojXzm8bKx", 246 | "display_url":"pic.twitter.com/1ojXzm8bKx", 247 | "expanded_url":"https://twitter.com/messages/media/856478621090942979", 248 | "type":"photo", 249 | "sizes":{ 250 | "small":{"w":340,"h":255,"resize":"fit"}, 251 | "medium":{"w":600,"h":450,"resize":"fit"}, 252 | "thumb":{"w":150,"h":150,"resize":"crop"}, 253 | "large":{"w":997,"h":748,"resize":"fit"} 254 | } 255 | } 256 | } 257 | } 258 | } 259 | }, 260 | { 261 | "type":"message_create", 262 | "id":"856477958885834755", 263 | "created_timestamp":"1493035232646", 264 | "message_create":{ 265 | "target":{"recipient_id":"7505382"}, 266 | "sender_id":"2924245126", 267 | "message_data":{ 268 | "text":"Os amantes em face a o mar", 269 | "entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]} 270 | } 271 | } 272 | }, 273 | { 274 | "type":"message_create", 275 | "id":"856477710595624963", 276 | "created_timestamp":"1493035173449", 277 | "message_create":{ 278 | "target":{"recipient_id":"7505382"}, 279 | "sender_id":"2924245126", 280 | "message_data":{ 281 | "text":" https://t.co/RrE2qo9upr", 282 | "entities":{ 283 | "hashtags":[], 284 | "symbols":[], 285 | "user_mentions":[], 286 | "urls":[ 287 | { 288 | "url":"https://t.co/RrE2qo9upr", 289 | "expanded_url":"https://twitter.com/messages/media/856477710595624963", 290 | "display_url":"pic.twitter.com/RrE2qo9upr", 291 | "indices":[1,24] 292 | } 293 | ] 294 | }, 295 | "attachment":{ 296 | "type":"media", 297 | "media":{ 298 | "id":856477689447841792, 299 | "id_str":"856477689447841792", 300 | "indices":[1,24], 301 | "media_url":"https://ton.twitter.com/1.1/ton/data/dm/856477710595624963/856477689447841792/i3ViseFg.jpg", 302 | "media_url_https":"https://ton.twitter.com/1.1/ton/data/dm/856477710595624963/856477689447841792/i3ViseFg.jpg", 303 | "url":"https://t.co/RrE2qo9upr", 304 | "display_url":"pic.twitter.com/RrE2qo9upr", 305 | "expanded_url":"https://twitter.com/messages/media/856477710595624963", 306 | "type":"photo", 307 | "sizes":{ 308 | "small":{"w":340,"h":453,"resize":"fit"}, 309 | "thumb":{"w":150,"h":150,"resize":"crop"}, 310 | "large":{"w":502,"h":669,"resize":"fit"}, 311 | "medium":{"w":502,"h":669,"resize":"fit"} 312 | } 313 | } 314 | } 315 | } 316 | } 317 | } 318 | ], 319 | "next_cursor":0 320 | } 321 | -------------------------------------------------------------------------------- /spec/stream_spec.rb: -------------------------------------------------------------------------------- 1 | require "helper" 2 | 3 | describe T::Stream do 4 | let(:t_class) do 5 | klass = Class.new 6 | allow(klass).to receive(:options=) 7 | allow(klass).to receive(:options).and_return({}) 8 | klass 9 | end 10 | 11 | before :all do 12 | @tweet = tweet_from_fixture("status.json") 13 | end 14 | 15 | before do 16 | T::RCFile.instance.path = "#{fixture_path}/.trc" 17 | @streaming_client = double("Twitter::Streaming::Client").as_null_object 18 | @stream = described_class.new 19 | allow(@stream).to receive(:streaming_client) { @streaming_client } 20 | allow(@stream).to receive(:say) 21 | allow(STDOUT).to receive(:tty?).and_return(true) 22 | end 23 | 24 | describe "#all" do 25 | before do 26 | allow(@streaming_client).to receive(:sample).and_yield(@tweet) 27 | end 28 | 29 | it "prints the tweet" do 30 | expect(@stream).to receive(:print_message) 31 | @stream.all 32 | end 33 | 34 | context "--csv" do 35 | before do 36 | @stream.options = @stream.options.merge("csv" => true) 37 | end 38 | 39 | it "outputs headings when the stream initializes" do 40 | allow(@streaming_client).to receive(:before_request).and_yield 41 | allow(@streaming_client).to receive(:sample) 42 | expect(@stream).to receive(:say).with("ID,Posted at,Screen name,Text\n") 43 | @stream.all 44 | end 45 | 46 | it "outputs in CSV format" do 47 | allow(@streaming_client).to receive(:before_request) 48 | allow(@streaming_client).to receive(:sample).and_yield(@tweet) 49 | expect(@stream).to receive(:print_csv_tweet).with(any_args) 50 | @stream.all 51 | end 52 | end 53 | 54 | context "--long" do 55 | before do 56 | @stream.options = @stream.options.merge("long" => true) 57 | end 58 | 59 | it "outputs headings when the stream initializes" do 60 | allow(@streaming_client).to receive(:before_request).and_yield 61 | allow(@streaming_client).to receive(:sample) 62 | expect(@stream).to receive(:print_table).with(any_args) 63 | @stream.all 64 | end 65 | 66 | it "outputs in long text format" do 67 | allow(@streaming_client).to receive(:before_request) 68 | allow(@streaming_client).to receive(:sample).and_yield(@tweet) 69 | expect(@stream).to receive(:print_table).with(any_args) 70 | @stream.all 71 | end 72 | end 73 | 74 | it "invokes Twitter::Streaming::Client#sample" do 75 | allow(@streaming_client).to receive(:before_request) 76 | allow(@streaming_client).to receive(:sample) 77 | expect(@streaming_client).to receive(:sample) 78 | @stream.all 79 | end 80 | end 81 | 82 | describe "#list" do 83 | before do 84 | stub_get("/1.1/lists/members.json").with(query: {cursor: "-1", owner_screen_name: "testcli", slug: "presidents"}).to_return(body: fixture("users_list.json"), headers: {content_type: "application/json; charset=utf-8"}) 85 | end 86 | 87 | it "prints the tweet" do 88 | expect(@stream).to receive(:print_message) 89 | allow(@streaming_client).to receive(:filter).and_yield(@tweet) 90 | @stream.list("presidents") 91 | end 92 | 93 | it "requests the correct resource" do 94 | @stream.list("presidents") 95 | expect(a_get("/1.1/lists/members.json").with(query: {cursor: "-1", owner_screen_name: "testcli", slug: "presidents"})).to have_been_made 96 | end 97 | 98 | context "--csv" do 99 | before do 100 | @stream.options = @stream.options.merge("csv" => true) 101 | end 102 | 103 | it "outputs in CSV format" do 104 | allow(@streaming_client).to receive(:before_request) 105 | allow(@streaming_client).to receive(:filter).and_yield(@tweet) 106 | expect(@stream).to receive(:print_csv_tweet).with(any_args) 107 | @stream.list("presidents") 108 | end 109 | 110 | it "requests the correct resource" do 111 | @stream.list("presidents") 112 | expect(a_get("/1.1/lists/members.json").with(query: {cursor: "-1", owner_screen_name: "testcli", slug: "presidents"})).to have_been_made 113 | end 114 | end 115 | 116 | context "--long" do 117 | before do 118 | @stream.options = @stream.options.merge("long" => true) 119 | end 120 | 121 | it "outputs in long text format" do 122 | allow(@streaming_client).to receive(:before_request) 123 | allow(@streaming_client).to receive(:filter).and_yield(@tweet) 124 | expect(@stream).to receive(:print_table).with(any_args) 125 | @stream.list("presidents") 126 | end 127 | 128 | it "requests the correct resource" do 129 | @stream.list("presidents") 130 | expect(a_get("/1.1/lists/members.json").with(query: {cursor: "-1", owner_screen_name: "testcli", slug: "presidents"})).to have_been_made 131 | end 132 | end 133 | 134 | it "performs a REST search when the stream initializes" do 135 | allow(@streaming_client).to receive(:before_request).and_yield 136 | allow(@streaming_client).to receive(:filter) 137 | allow(T::List).to receive(:new).and_return(t_class) 138 | expect(t_class).to receive(:timeline) 139 | @stream.list("presidents") 140 | end 141 | 142 | it "invokes Twitter::Streaming::Client#userstream" do 143 | allow(@streaming_client).to receive(:filter) 144 | expect(@streaming_client).to receive(:filter) 145 | @stream.list("presidents") 146 | end 147 | end 148 | 149 | describe "#matrix" do 150 | before do 151 | stub_get("/1.1/search/tweets.json").with(query: {q: "lang:ja", count: 100, include_entities: "false"}).to_return(body: fixture("empty_cursor.json"), headers: {content_type: "application/json; charset=utf-8"}) 152 | end 153 | 154 | it "outputs the tweet" do 155 | allow(@streaming_client).to receive(:before_request) 156 | allow(@streaming_client).to receive(:sample).and_yield(@tweet) 157 | expect(@stream).to receive(:say).with(any_args) 158 | @stream.matrix 159 | end 160 | 161 | it "invokes Twitter::Streaming::Client#sample" do 162 | allow(@streaming_client).to receive(:before_request) 163 | allow(@streaming_client).to receive(:sample).and_yield(@tweet) 164 | expect(@streaming_client).to receive(:sample) 165 | @stream.matrix 166 | end 167 | 168 | it "requests the correct resource" do 169 | allow(@streaming_client).to receive(:before_request).and_yield 170 | @stream.matrix 171 | expect(a_get("/1.1/search/tweets.json").with(query: {q: "lang:ja", count: 100, include_entities: "false"})).to have_been_made 172 | end 173 | end 174 | 175 | describe "#search" do 176 | before do 177 | allow(@streaming_client).to receive(:filter).with(track: "twitter,gem").and_yield(@tweet) 178 | end 179 | 180 | it "prints the tweet" do 181 | expect(@stream).to receive(:print_message) 182 | @stream.search(%w[twitter gem]) 183 | end 184 | 185 | context "--csv" do 186 | before do 187 | @stream.options = @stream.options.merge("csv" => true) 188 | end 189 | 190 | it "outputs in CSV format" do 191 | allow(@streaming_client).to receive(:before_request) 192 | expect(@stream).to receive(:print_csv_tweet).with(any_args) 193 | @stream.search(%w[twitter gem]) 194 | end 195 | end 196 | 197 | context "--long" do 198 | before do 199 | @stream.options = @stream.options.merge("long" => true) 200 | end 201 | 202 | it "outputs in long text format" do 203 | allow(@streaming_client).to receive(:before_request) 204 | allow(@streaming_client).to receive(:filter).with(track: "twitter,gem").and_yield(@tweet) 205 | expect(@stream).to receive(:print_table).with(any_args) 206 | @stream.search(%w[twitter gem]) 207 | end 208 | end 209 | 210 | it "performs a REST search when the stream initializes" do 211 | allow(@streaming_client).to receive(:before_request).and_yield 212 | allow(@streaming_client).to receive(:filter) 213 | allow(T::Search).to receive(:new).and_return(t_class) 214 | expect(t_class).to receive(:all).with("t OR gem") 215 | @stream.search("t", "gem") 216 | end 217 | 218 | it "invokes Twitter::Streaming::Client#filter" do 219 | allow(@streaming_client).to receive(:filter) 220 | expect(@streaming_client).to receive(:filter).with(track: "twitter,gem") 221 | @stream.search(%w[twitter gem]) 222 | end 223 | end 224 | 225 | describe "#timeline" do 226 | before do 227 | allow(@streaming_client).to receive(:user).and_yield(@tweet) 228 | end 229 | 230 | it "prints the tweet" do 231 | expect(@stream).to receive(:print_message) 232 | @stream.timeline 233 | end 234 | 235 | context "--csv" do 236 | before do 237 | @stream.options = @stream.options.merge("csv" => true) 238 | end 239 | 240 | it "outputs in CSV format" do 241 | allow(@streaming_client).to receive(:before_request) 242 | expect(@stream).to receive(:print_csv_tweet).with(any_args) 243 | @stream.timeline 244 | end 245 | end 246 | 247 | context "--long" do 248 | before do 249 | @stream.options = @stream.options.merge("long" => true) 250 | end 251 | 252 | it "outputs in long text format" do 253 | allow(@streaming_client).to receive(:before_request) 254 | expect(@stream).to receive(:print_table).with(any_args) 255 | @stream.timeline 256 | end 257 | end 258 | 259 | it "performs a REST search when the stream initializes" do 260 | allow(@streaming_client).to receive(:before_request).and_yield 261 | allow(@streaming_client).to receive(:user) 262 | allow(T::CLI).to receive(:new).and_return(t_class) 263 | expect(t_class).to receive(:timeline) 264 | @stream.timeline 265 | end 266 | 267 | it "invokes Twitter::Streaming::Client#userstream" do 268 | allow(@streaming_client).to receive(:user) 269 | expect(@streaming_client).to receive(:user) 270 | @stream.timeline 271 | end 272 | end 273 | 274 | describe "#users" do 275 | before do 276 | allow(@streaming_client).to receive(:filter).and_yield(@tweet) 277 | end 278 | 279 | it "prints the tweet" do 280 | expect(@stream).to receive(:print_message) 281 | @stream.users("123") 282 | end 283 | 284 | context "--csv" do 285 | before do 286 | @stream.options = @stream.options.merge("csv" => true) 287 | end 288 | 289 | it "outputs headings when the stream initializes" do 290 | allow(@streaming_client).to receive(:before_request).and_yield 291 | allow(@streaming_client).to receive(:filter) 292 | expect(@stream).to receive(:say).with("ID,Posted at,Screen name,Text\n") 293 | @stream.users("123") 294 | end 295 | 296 | it "outputs in CSV format" do 297 | allow(@streaming_client).to receive(:before_request) 298 | expect(@stream).to receive(:print_csv_tweet).with(any_args) 299 | @stream.users("123") 300 | end 301 | end 302 | 303 | context "--long" do 304 | before do 305 | @stream.options = @stream.options.merge("long" => true) 306 | end 307 | 308 | it "outputs headings when the stream initializes" do 309 | allow(@streaming_client).to receive(:before_request).and_yield 310 | allow(@streaming_client).to receive(:filter) 311 | expect(@stream).to receive(:print_table).with(any_args) 312 | @stream.users("123") 313 | end 314 | 315 | it "outputs in long text format" do 316 | allow(@streaming_client).to receive(:before_request) 317 | allow(@streaming_client).to receive(:filter).and_yield(@tweet) 318 | expect(@stream).to receive(:print_table).with(any_args) 319 | @stream.users("123") 320 | end 321 | end 322 | 323 | it "invokes Twitter::Streaming::Client#follow" do 324 | allow(@streaming_client).to receive(:filter) 325 | expect(@streaming_client).to receive(:filter).with(follow: "123,456,789") 326 | @stream.users("123", "456", "789") 327 | end 328 | end 329 | end 330 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [![Application icon](https://github.com/sferik/t/raw/master/icon/t.png)][icon] 2 | [icon]: https://github.com/sferik/t/raw/master/icon/t.png 3 | 4 | # Twitter CLI 5 | [![Gem Version](https://img.shields.io/gem/v/t.svg)][gem] 6 | [![Build Status](https://img.shields.io/travis/sferik/t.svg)][travis] 7 | [![Dependency Status](https://img.shields.io/gemnasium/sferik/t.svg)][gemnasium] 8 | [![tip for next commit](https://tip4commit.com/projects/102.svg)](https://tip4commit.com/github/sferik/t) 9 | 10 | [gem]: https://rubygems.org/gems/t 11 | [travis]: https://travis-ci.org/sferik/t 12 | [gemnasium]: https://gemnasium.com/sferik/t 13 | 14 | #### A command-line power tool for Twitter. 15 | The CLI takes syntactic cues from the [Twitter SMS commands][sms], but it 16 | offers vastly more commands and capabilities than are available via SMS. 17 | 18 | [sms]: https://support.twitter.com/articles/14020-twitter-sms-command 19 | 20 | ## Dependencies 21 | First, make sure you have Ruby installed. 22 | 23 | **On a Mac**, open `/Applications/Utilities/Terminal.app` and type: 24 | 25 | ruby -v 26 | 27 | If the output looks something like this, you're in good shape: 28 | 29 | ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin24] 30 | 31 | If the output looks more like this, you need to [install Ruby][ruby]: 32 | 33 | [ruby]: https://www.ruby-lang.org/en/downloads/ 34 | 35 | ruby: command not found 36 | 37 | **On Linux**, for Debian-based systems, open a terminal and type: 38 | 39 | sudo apt-get install ruby-dev 40 | 41 | or for Red Hat-based distros like Fedora and CentOS, type: 42 | 43 | sudo yum install ruby-devel 44 | 45 | (if necessary, adapt for your package manager) 46 | 47 | **On Windows**, you can install Ruby with [RubyInstaller][rubyinstaller]. 48 | 49 | [rubyinstaller]: http://rubyinstaller.org/downloads/ 50 | 51 | ## Installation 52 | Once you've verified that Ruby is installed: 53 | 54 | gem install t 55 | 56 | ## Configuration 57 | Twitter API v1.1 requires OAuth for all of its functionality, so you'll need a 58 | registered Twitter application. If you've never registered a Twitter 59 | application before, it's easy! Just sign-in using your Twitter account and then 60 | fill out the short form at . If you've 61 | previously registered a Twitter application, it should be listed at 62 | . Once you've registered an application, make sure 63 | to set your application's Access Level to "Read, Write and Access direct 64 | messages", otherwise you'll receive an error that looks like this: 65 | 66 | Error processing your OAuth request: Read-only application cannot POST 67 | 68 | A mobile phone number must be associated with your account in order to obtain write privileges. If your carrier is not supported by Twitter and you are unable to add a number, contact Twitter using , selecting the last checkbox. Some users have reported success adding their number using the mobile site, , which seems to bypass the carrier check at the moment. 69 | 70 | Now, you're ready to authorize a Twitter account with your application. To 71 | proceed, type the following command at the prompt and follow the instructions: 72 | 73 | t authorize 74 | 75 | This command will direct you to a URL where you can sign-in to Twitter, 76 | authorize the application, and then enter the returned PIN back into the 77 | terminal. If you type the PIN correctly, you should now be authorized to use 78 | `t` as that user. To authorize multiple accounts, simply repeat the last step, 79 | signing into Twitter as a different user. 80 | 81 | **NOTE**: If you have problems authorizing multiple accounts, open a new window in your browser in incognito/private-browsing mode and repeat the `t authorize` steps. This is apparently due to a bug in twitter's cookie handling. 82 | 83 | You can see a list of all the accounts you've authorized by typing the command: 84 | 85 | t accounts 86 | 87 | The output of which will be structured like this: 88 | 89 | sferik 90 | UDfNTpOz5ZDG4a6w7dIWj 91 | uuP7Xbl2mEfGMiDu1uIyFN 92 | gem 93 | thG9EfWoADtIr6NjbL9ON (active) 94 | 95 | **Note**: One of your authorized accounts (specifically, the last one 96 | authorized) will be set as active. To change the active account, use the `set` 97 | subcommand, passing either just a username, if it's unambiguous, or a username 98 | and consumer key pair, like this: 99 | 100 | t set active sferik UDfNTpOz5ZDG4a6w7dIWj 101 | 102 | Account information is stored in a YAML-formatted file located at `~/.trc`. 103 | 104 | **Note**: Anyone with access to this file can impersonate you on Twitter, so 105 | it's important to keep it secure, just as you would treat your SSH private key. 106 | For this reason, the file is hidden and has the permission bits set to `0600`. 107 | 108 | ## Usage Examples 109 | Typing `t help` will list all the available commands. You can type `t help 110 | TASK` to get help for a specific command. 111 | 112 | t help 113 | 114 | #### Update your status 115 | t update "I'm tweeting from the command line. Isn't that special?" 116 | 117 | **Note**: If your tweet includes special characters (e.g. `!`), make sure to 118 | wrap it in single quotes instead of double quotes, so those characters are not 119 | interpreted by your shell. 120 | If you use single quotes, your Tweet obviously can't contain any 121 | apostrophes unless you prefix them with a backslash `\`: 122 | 123 | t update 'I\'m tweeting from the command line. Isn\'t that special?' 124 | 125 | #### Retrieve detailed information about a Twitter user 126 | t whois @sferik 127 | 128 | #### Retrieve stats for multiple users 129 | t users -l @sferik @gem 130 | 131 | #### Follow users 132 | t follow @sferik @gem 133 | 134 | #### Check whether one user follows another 135 | t does_follow @ev @sferik 136 | 137 | **Note**: If the first user does not follow the second, `t` will exit with a 138 | non-zero exit code. This allows you to execute commands conditionally. For 139 | example, here's how to send a user a direct message only if they already follow you: 140 | 141 | t does_follow @ev && t dm @ev "What's up, bro?" 142 | 143 | #### Create a list for everyone you're following 144 | t list create following-`date "+%Y-%m-%d"` 145 | 146 | #### Add everyone you're following to that list (up to 500 users) 147 | t followings | xargs t list add following-`date "+%Y-%m-%d"` 148 | 149 | #### List all the members of a list, in long format 150 | t list members -l following-`date "+%Y-%m-%d"` 151 | 152 | #### List all your lists, in long format 153 | t lists -l 154 | 155 | #### List all your friends, in long format, ordered by number of followers 156 | t friends -l --sort=followers 157 | 158 | #### List all your leaders (people you follow who don't follow you back) 159 | t leaders -l --sort=followers 160 | 161 | #### Mute everyone you follow 162 | t followings | xargs t mute 163 | 164 | #### Unfollow everyone you follow who doesn't follow you back 165 | t leaders | xargs t unfollow 166 | 167 | #### Unfollow 10 people who haven't tweeted in the longest time 168 | t followings -l --sort=tweeted | head -10 | awk '{print $1}' | xargs t unfollow -i 169 | 170 | #### Twitter roulette: randomly follow someone who follows you (who you don't already follow) 171 | t groupies | shuf | head -1 | xargs t follow 172 | 173 | #### Favorite the last 10 tweets that mention you 174 | t mentions -n 10 -l | awk '{print $1}' | xargs t favorite 175 | 176 | #### Output the last 200 tweets in your timeline to a CSV file 177 | t timeline -n 200 --csv > timeline.csv 178 | 179 | #### Start streaming your timeline (Control-C to stop) 180 | t stream timeline 181 | 182 | #### Count the number of official Twitter engineering accounts 183 | t list members twitter/engineering | wc -l 184 | 185 | #### Search Twitter for the 20 most recent Tweets that match a specified query 186 | t search all "query" 187 | 188 | #### Download the latest Linux kernel via BitTorrent (possibly NSFW, depending on where you work) 189 | t search all "lang:en filter:links linux torrent" -n 1 | grep -o "http://t.co/[0-9A-Za-z]*" | xargs open 190 | 191 | #### Search Tweets you've favorited that match a specified query 192 | t search favorites "query" 193 | 194 | #### Search Tweets mentioning you that match a specified query 195 | t search mentions "query" 196 | 197 | #### Search Tweets you've retweeted that match a specified query 198 | t search retweets "query" 199 | 200 | #### Search Tweets in your home timeline that match a specified query 201 | t search timeline "query" 202 | **Note**: In Twitter API parlance, your “home timeline” is your “Newsfeed” whereas your “user timeline” are the tweets tweeted (and retweeted) by you. 203 | 204 | #### Search Tweets in a specified user’s timeline 205 | t search timeline @sferik "query" 206 | 207 | ## Features 208 | * Deep search: Instead of using the Twitter Search API, [which only goes 209 | back 6-9 days][search], `t search` fetches up to 3,200 tweets via the REST API 210 | and then checks each one against a regular expression. 211 | * Multi-threaded: Whenever possible, Twitter API requests are made in parallel, 212 | resulting in faster performance for bulk operations. 213 | * Designed for Unix: Output is designed to be piped to other Unix utilities, 214 | like grep, comm, cut, awk, bc, wc, and xargs for advanced text processing. 215 | * Generate spreadsheets: Convert the output of any command to CSV format simply 216 | by adding the `--csv` flag. 217 | * 95% C0 Code Coverage: Well tested, with a 2.5:1 test-to-code ratio. 218 | 219 | [search]: https://dev.twitter.com/rest/public/search 220 | 221 | ## Using T for Backup 222 | [@jphpsf][jphpsf] wrote a [blog post][blog] explaining how to use `t` to backup 223 | your Twitter account. 224 | 225 | [jphpsf]: https://github.com/jphpsf 226 | [blog]: http://blog.jphpsf.com/2012/05/07/backing-up-your-twitter-account-with-t/ 227 | 228 | `t` was also mentioned on [an episode of the Ruby 5 podcast][ruby5]. 229 | 230 | `t` was also discussed on [an episode of the Ruby Rogues podcast][rubyrogues]. 231 | 232 | [ruby5]: https://ruby5.codeschool.com/episodes/273-episode-269-may-4th-2012/stories/2400-t-command-line-power-tool-for-twitter 233 | 234 | [rubyrogues]: https://devchat.tv/ruby-rogues/127-rr-erik-michaels-ober 235 | 236 | If you discuss `t` in a blog post or podcast, [let me know][email] and I'll 237 | link it here. 238 | 239 | [email]: mailto:sferik@gmail.com 240 | 241 | ## Relationship Terminology 242 | There is some ambiguity in the terminology used to describe relationships on 243 | Twitter. For example, some people use the term "friends" to mean everyone you 244 | follow. In `t`, "friends" refers to just the subset of people who follow you 245 | back (i.e., friendship is bidirectional). Here is the full table of terminology 246 | used by `t`: 247 | 248 | ___________________________________________________ 249 | | | | 250 | | YOU FOLLOW THEM | YOU DON'T FOLLOW THEM | 251 | _________________________|_________________________|_________________________|_________________________ 252 | | | | | | 253 | | THEY FOLLOW YOU | friends | groupies | followers | 254 | |_________________________|_________________________|_________________________|_________________________| 255 | | | | 256 | | THEY DON'T FOLLOW YOU | leaders | 257 | |_________________________|_________________________| 258 | | | 259 | | followings | 260 | |_________________________| 261 | 262 | ## Screenshots 263 | ![Timeline](https://github.com/sferik/t/raw/master/screenshots/timeline.png) 264 | ![List](https://github.com/sferik/t/raw/master/screenshots/list.png) 265 | 266 | ## Shell completion 267 | If you're running Zsh or Bash, you can source one of the [bundled completion 268 | files][completion] to get shell completion for `t` commands, subcommands, and 269 | flags. 270 | 271 | Don't run Zsh or Bash? Why not [contribute][] completion support for your 272 | favorite shell? 273 | 274 | [completion]: https://github.com/sferik/t/tree/master/etc 275 | [contribute]: https://github.com/sferik/t/blob/master/CONTRIBUTING.md 276 | 277 | ## History 278 | The [twitter gem][gem] previously contained a command-line interface, up until 279 | version 0.5.0, when it was [removed][]. This project is offered as a successor 280 | to that effort, however it is a clean room implementation that contains none of 281 | the original code. 282 | 283 | [gem]: https://rubygems.org/gems/twitter 284 | [removed]: https://github.com/jnunemaker/twitter/commit/dd2445e3e2c97f38b28a3f32ea902536b3897adf 285 | ![History](https://github.com/sferik/t/raw/master/screenshots/history.png) 286 | 287 | ## Supported Ruby Versions 288 | This library aims to support and is [tested against][travis] the following Ruby 289 | implementations: 290 | 291 | * Ruby 3.2 292 | * Ruby 3.3 293 | * Ruby 3.4 294 | 295 | If something doesn't work on one of these Ruby versions, it's a bug. 296 | 297 | This library may inadvertently work (or seem to work) on other Ruby 298 | implementations, however support will only be provided for the versions listed 299 | above. 300 | 301 | If you would like this library to support another Ruby version, you may 302 | volunteer to be a maintainer. Being a maintainer entails making sure all tests 303 | run and pass on that implementation. When something breaks on your 304 | implementation, you will be responsible for providing patches in a timely 305 | fashion. If critical issues for a particular implementation exist at the time 306 | of a major release, support for that Ruby version may be dropped. 307 | 308 | ## Troubleshooting 309 | If you are running t on a remote computer you can use the flag --display-uri during authorize process to display the url instead of opening the web browser. 310 | 311 | t authorize --display-uri 312 | 313 | ## Copyright 314 | Copyright (c) 2011-2025 Erik Berlin. See [LICENSE][] for details. 315 | Application icon by [@nvk][nvk]. 316 | 317 | [license]: https://github.com/sferik/t/blob/master/LICENSE.md 318 | [nvk]: http://www.rnvk.org 319 | --------------------------------------------------------------------------------