├── 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 | $
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 | # [][icon]
2 | [icon]: https://github.com/sferik/t/raw/master/icon/t.png
3 |
4 | # Twitter CLI
5 | [][gem]
6 | [][travis]
7 | [][gemnasium]
8 | [](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 | 
264 | 
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 | 
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 |
--------------------------------------------------------------------------------