t 4.1.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ module T
2
+ module RequestableAPI
3
+ module TweetNormalization
4
+ private
5
+
6
+ def extract_tweets(value)
7
+ return value if value.is_a?(Array)
8
+ return value.fetch("statuses", []) if value["statuses"].is_a?(Array)
9
+
10
+ users_by_id = index_items_by_id(value.dig("includes", "users"))
11
+ places_by_id = index_items_by_id(value.dig("includes", "places"))
12
+ return value["data"].map { |tweet| normalize_v2_tweet(tweet, users_by_id, places_by_id) } if value["data"].is_a?(Array)
13
+ return [normalize_v2_tweet(value["data"], users_by_id, places_by_id)] if value["data"].is_a?(Hash)
14
+
15
+ []
16
+ end
17
+
18
+ def normalize_v2_tweet(tweet, users_by_id, places_by_id)
19
+ return tweet if tweet.is_a?(Hash) && tweet.key?("user")
20
+
21
+ object = build_v2_tweet_core(tweet)
22
+ apply_v2_tweet_entities(object, tweet)
23
+ apply_v2_tweet_metrics(object, tweet)
24
+ apply_v2_tweet_author(object, tweet, users_by_id)
25
+ apply_v2_tweet_geo(object, tweet, places_by_id)
26
+ object
27
+ end
28
+
29
+ def build_v2_tweet_core(tweet)
30
+ object = {}
31
+ apply_v2_id(object, tweet)
32
+ text = tweet["full_text"] || tweet["text"]
33
+ if text
34
+ object["text"] = text
35
+ object["full_text"] = text
36
+ end
37
+ object["created_at"] = tweet["created_at"] if tweet["created_at"]
38
+ object["source"] = tweet["source"] if tweet["source"]
39
+ object
40
+ end
41
+
42
+ def apply_v2_tweet_entities(object, tweet)
43
+ return unless tweet["entities"]
44
+
45
+ object["entities"] = tweet["entities"]
46
+ object["uris"] = tweet.dig("entities", "urls") if tweet.dig("entities", "urls")
47
+ end
48
+
49
+ def apply_v2_tweet_metrics(object, tweet)
50
+ return unless tweet["public_metrics"].is_a?(Hash)
51
+
52
+ metrics = tweet["public_metrics"]
53
+ object["retweet_count"] = metrics["retweet_count"] if metrics.key?("retweet_count")
54
+ object["favorite_count"] = metrics["like_count"] if metrics.key?("like_count")
55
+ end
56
+
57
+ def apply_v2_tweet_author(object, tweet, users_by_id)
58
+ author_id = tweet["author_id"]
59
+ object["user"] = normalize_v2_user(users_by_id[author_id] || {"id" => author_id, "username" => author_id}) if author_id
60
+ end
61
+
62
+ def apply_v2_tweet_geo(object, tweet, places_by_id)
63
+ geo = tweet["geo"]
64
+ return unless geo.is_a?(Hash)
65
+
66
+ place_id = geo["place_id"]
67
+ object["place"] = places_by_id[place_id] if place_id && places_by_id[place_id]
68
+ coords = geo.dig("coordinates", "coordinates")
69
+ object["geo"] = {"type" => "Point", "coordinates" => coords} if coords.is_a?(Array) && coords.size == 2 && !place_id
70
+ end
71
+
72
+ def extract_ids(response)
73
+ data = response["data"]
74
+ return [] unless data.is_a?(Array)
75
+
76
+ data.filter_map { |entry| value_id(entry) }
77
+ end
78
+
79
+ def index_items_by_id(values)
80
+ Array(values).each_with_object({}) do |entry, memo|
81
+ id = value_id(entry)
82
+ memo[id] = entry if id
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,82 @@
1
+ module T
2
+ module RequestableAPI
3
+ module UserEndpoints
4
+ def x_verify_credentials
5
+ extract_users(t_get_v2("users/me", user_lookup_params)).first || {}
6
+ rescue X::Error
7
+ t_get_v1("account/verify_credentials.json")
8
+ end
9
+
10
+ def x_user(user = nil, _opts = {}, &)
11
+ if block_given? && user.nil?
12
+ @requestable_api_before_request&.call
13
+ x_home_timeline(count: 100).each(&)
14
+ return
15
+ end
16
+
17
+ fetch_single_user(user)
18
+ rescue X::ServiceUnavailable
19
+ t_get_v1("users/show.json", screen_name: strip_at(user.to_s))
20
+ end
21
+
22
+ def x_users(users)
23
+ users = Array(users).flatten.compact
24
+ return [] if users.empty?
25
+
26
+ ids, names = users.partition { |entry| numeric_identifier?(entry) }
27
+ results = []
28
+ ids.each_slice(100) do |chunk|
29
+ results.concat(extract_users(t_get_v2("users", user_lookup_params.merge(ids: chunk.join(",")))))
30
+ end
31
+ names.each_slice(100) do |chunk|
32
+ results.concat(extract_users(t_get_v2("users/by", user_lookup_params.merge(usernames: chunk.map { |name| strip_at(name) }.join(",")))))
33
+ end
34
+ results
35
+ end
36
+
37
+ def x_user_search(query, page:)
38
+ page = page.to_i
39
+ return [] if page > 1 && @requestable_api_user_search_tokens[[query, page - 1]].to_s.empty?
40
+
41
+ params = {
42
+ query: query.to_s,
43
+ max_results: "100",
44
+ "user.fields": V2_USER_FIELDS,
45
+ expansions: V2_USER_EXPANSIONS,
46
+ "tweet.fields": V2_TWEET_FIELDS,
47
+ }
48
+ params[:next_token] = @requestable_api_user_search_tokens[[query, page - 1]] if page > 1
49
+ response = t_get_v2("users/search", params)
50
+ @requestable_api_user_search_tokens[[query, page]] = response.dig("meta", "next_token")
51
+ extract_users(response)
52
+ end
53
+
54
+ def x_friendship?(user1, user2)
55
+ user1_id = resolve_user_id(user1)
56
+ user2_id = resolve_user_id(user2)
57
+ fetch_relationship_ids(user1_id, "following").include?(user2_id.to_s)
58
+ end
59
+
60
+ def x_friend_ids(user = nil)
61
+ user_id = user.nil? ? current_user_id : resolve_user_id(user)
62
+ fetch_relationship_ids(user_id, "following")
63
+ end
64
+
65
+ def x_follower_ids(user = nil)
66
+ user_id = user.nil? ? current_user_id : resolve_user_id(user)
67
+ fetch_relationship_ids(user_id, "followers")
68
+ end
69
+
70
+ private
71
+
72
+ def fetch_single_user(user)
73
+ response = if numeric_identifier?(user)
74
+ t_get_v2("users/#{user}", user_lookup_params)
75
+ else
76
+ t_get_v2("users/by/username/#{strip_at(user)}", user_lookup_params)
77
+ end
78
+ extract_users(response).first || {}
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,68 @@
1
+ module T
2
+ module RequestableAPI
3
+ module UserNormalization
4
+ private
5
+
6
+ def extract_users(value)
7
+ return value if value.is_a?(Array)
8
+ return value.fetch("users", []) if value["users"].is_a?(Array)
9
+
10
+ users = value["data"]
11
+ includes_tweets = index_items_by_id(value.dig("includes", "tweets"))
12
+ return users.map { |user| normalize_user_with_pinned_status(user, includes_tweets) } if users.is_a?(Array)
13
+ return [normalize_user_with_pinned_status(users, includes_tweets)] if users.is_a?(Hash)
14
+
15
+ extract_bare_v1_user(value)
16
+ end
17
+
18
+ def extract_bare_v1_user(value)
19
+ return [value] if value.is_a?(Hash) && (value.key?("screen_name") || value.key?("id"))
20
+
21
+ []
22
+ end
23
+
24
+ def normalize_user_with_pinned_status(user, includes_tweets)
25
+ normalized = normalize_v2_user(user || {})
26
+ pinned_id = user["pinned_tweet_id"]
27
+ normalized["status"] = normalize_v2_tweet(includes_tweets[pinned_id], {}, {}) if pinned_id && includes_tweets[pinned_id]
28
+ normalized
29
+ end
30
+
31
+ def normalize_v2_user(user)
32
+ return user if user.is_a?(Hash) && user.key?("screen_name")
33
+
34
+ object = build_v2_user_core(user)
35
+ apply_v2_user_metrics(object, user)
36
+ object
37
+ end
38
+
39
+ def build_v2_user_core(user)
40
+ object = {}
41
+ apply_v2_id(object, user)
42
+ username = user["username"] || user["screen_name"]
43
+ if username
44
+ object["screen_name"] = username
45
+ object["username"] = username
46
+ end
47
+ %w[created_at name verified protected description location url].each do |field|
48
+ object[field] = user[field] if user.key?(field)
49
+ end
50
+ object
51
+ end
52
+
53
+ def apply_v2_user_metrics(object, user)
54
+ return unless user["public_metrics"].is_a?(Hash)
55
+
56
+ metrics = user["public_metrics"]
57
+ object["statuses_count"] = metrics["tweet_count"] if metrics.key?("tweet_count")
58
+ if metrics.key?("like_count")
59
+ object["favourites_count"] = metrics["like_count"]
60
+ object["favorites_count"] = metrics["like_count"]
61
+ end
62
+ object["listed_count"] = metrics["listed_count"] if metrics.key?("listed_count")
63
+ object["friends_count"] = metrics["following_count"] if metrics.key?("following_count")
64
+ object["followers_count"] = metrics["followers_count"] if metrics.key?("followers_count")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ require_relative "requestable_api/helpers"
9
+ require_relative "requestable_api/http"
10
+ require_relative "requestable_api/tweet_normalization"
11
+ require_relative "requestable_api/user_normalization"
12
+ require_relative "requestable_api/list_normalization"
13
+ require_relative "requestable_api/resolution"
14
+ require_relative "requestable_api/dm_parsing"
15
+ require_relative "requestable_api/dm_helpers"
16
+ require_relative "requestable_api/user_endpoints"
17
+ require_relative "requestable_api/tweet_endpoints"
18
+ require_relative "requestable_api/mutations"
19
+ require_relative "requestable_api/dm_endpoints"
20
+ require_relative "requestable_api/list_endpoints"
21
+ require_relative "requestable_api/account_endpoints"
22
+
23
+ module T
24
+ module RequestableAPI
25
+ include Helpers
26
+ include HTTP
27
+ include TweetNormalization
28
+ include UserNormalization
29
+ include ListNormalization
30
+ include Resolution
31
+ include DMParsing
32
+ include DMHelpers
33
+ include UserEndpoints
34
+ include TweetEndpoints
35
+ include Mutations
36
+ include DMEndpoints
37
+ include ListEndpoints
38
+ include AccountEndpoints
39
+
40
+ BASE_URL = "https://api.twitter.com"
41
+ BASE_URL_V1 = "#{BASE_URL}/1.1/".freeze
42
+ BASE_URL_UPLOAD = "https://upload.twitter.com/1.1/"
43
+
44
+ DEFAULT_NUM_RESULTS = 20
45
+ MAX_SEARCH_RESULTS = 100
46
+ MAX_PAGE = 51
47
+
48
+ V2_TWEET_FIELDS = "author_id,created_at,entities,geo,id,in_reply_to_user_id,public_metrics,source,text"
49
+ V2_USER_FIELDS = "created_at,description,id,location,name,protected,public_metrics,url,username,verified"
50
+ V2_LIST_FIELDS = "created_at,description,follower_count,id,member_count,name,owner_id,private"
51
+ V2_TWEET_EXPANSIONS = "author_id,geo.place_id"
52
+ V2_USER_EXPANSIONS = "pinned_tweet_id"
53
+ V2_PLACE_FIELDS = "contained_within,country,country_code,full_name,geo,id,name,place_type"
54
+
55
+ FORM_HEADERS = {"Content-Type" => "application/x-www-form-urlencoded; charset=utf-8"}.freeze
56
+ JSON_HEADERS = {"Content-Type" => "application/json; charset=utf-8"}.freeze
57
+
58
+ def setup_requestable_api!(credentials)
59
+ return if defined?(@requestable_api_setup) && @requestable_api_setup
60
+
61
+ @requestable_api_setup = true
62
+ @requestable_api_credentials = credentials
63
+ @v1_client = X::Client.new(**credentials, base_url: BASE_URL_V1)
64
+ @upload_client = X::Client.new(**credentials, base_url: BASE_URL_UPLOAD)
65
+ @requestable_api_user_search_tokens = {}
66
+ @requestable_api_before_request = nil
67
+ end
68
+ end
69
+ end
data/lib/t/search.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require "thor"
2
- require "twitter"
3
2
  require "t/collectable"
4
3
  require "t/printable"
5
4
  require "t/rcfile"
@@ -32,28 +31,10 @@ module T
32
31
  method_option "relative_dates", aliases: "-a", type: :boolean, desc: "Show relative dates."
33
32
  def all(query)
34
33
  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)
34
+ opts = {count: MAX_SEARCH_RESULTS, include_entities: !!options["decode_uris"]}
35
+ tweets = x_search(query, opts).take(count)
38
36
  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
37
+ print_search_results(tweets)
57
38
  end
58
39
 
59
40
  desc "favorites [USER] QUERY", "Returns Tweets you've favorited that match the specified query."
@@ -68,20 +49,19 @@ module T
68
49
  opts = {count: MAX_NUM_RESULTS}
69
50
  opts[:include_entities] = !!options["decode_uris"]
70
51
  if user
71
- require "t/core_ext/string"
72
- user = options["id"] ? user.to_i : user.strip_ats
52
+ user = options["id"] ? user.to_i : user.tr("@", "")
73
53
  tweets = collect_with_max_id do |max_id|
74
54
  opts[:max_id] = max_id unless max_id.nil?
75
- client.favorites(user, opts)
55
+ x_favorites(user, opts)
76
56
  end
77
57
  else
78
58
  tweets = collect_with_max_id do |max_id|
79
59
  opts[:max_id] = max_id unless max_id.nil?
80
- client.favorites(opts)
60
+ x_favorites(opts)
81
61
  end
82
62
  end
83
63
  tweets = tweets.select do |tweet|
84
- /#{query}/i.match(tweet.full_text)
64
+ /#{query}/i.match(tweet["full_text"])
85
65
  end
86
66
  print_tweets(tweets)
87
67
  end
@@ -99,10 +79,10 @@ module T
99
79
  opts[:include_entities] = !!options["decode_uris"]
100
80
  tweets = collect_with_max_id do |max_id|
101
81
  opts[:max_id] = max_id unless max_id.nil?
102
- client.list_timeline(owner, list_name, opts)
82
+ x_list_timeline(owner, list_name, opts)
103
83
  end
104
84
  tweets = tweets.select do |tweet|
105
- /#{query}/i.match(tweet.full_text)
85
+ /#{query}/i.match(tweet["full_text"])
106
86
  end
107
87
  print_tweets(tweets)
108
88
  end
@@ -117,10 +97,10 @@ module T
117
97
  opts[:include_entities] = !!options["decode_uris"]
118
98
  tweets = collect_with_max_id do |max_id|
119
99
  opts[:max_id] = max_id unless max_id.nil?
120
- client.mentions(opts)
100
+ x_mentions(opts)
121
101
  end
122
102
  tweets = tweets.select do |tweet|
123
- /#{query}/i.match(tweet.full_text)
103
+ /#{query}/i.match(tweet["full_text"])
124
104
  end
125
105
  print_tweets(tweets)
126
106
  end
@@ -138,20 +118,19 @@ module T
138
118
  opts = {count: MAX_NUM_RESULTS}
139
119
  opts[:include_entities] = !!options["decode_uris"]
140
120
  if user
141
- require "t/core_ext/string"
142
- user = options["id"] ? user.to_i : user.strip_ats
121
+ user = options["id"] ? user.to_i : user.tr("@", "")
143
122
  tweets = collect_with_max_id do |max_id|
144
123
  opts[:max_id] = max_id unless max_id.nil?
145
- client.retweeted_by_user(user, opts)
124
+ x_retweeted_by_user(user, opts)
146
125
  end
147
126
  else
148
127
  tweets = collect_with_max_id do |max_id|
149
128
  opts[:max_id] = max_id unless max_id.nil?
150
- client.retweeted_by_me(opts)
129
+ x_retweeted_by_me(opts)
151
130
  end
152
131
  end
153
132
  tweets = tweets.select do |tweet|
154
- /#{query}/i.match(tweet.full_text)
133
+ /#{query}/i.match(tweet["full_text"])
155
134
  end
156
135
  print_tweets(tweets)
157
136
  end
@@ -169,28 +148,13 @@ module T
169
148
  def timeline(*args)
170
149
  query = args.pop
171
150
  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)
151
+ opts = build_timeline_opts.merge(count: MAX_NUM_RESULTS)
152
+ user = resolve_user_input(user) if user
153
+ tweets = collect_with_max_id do |max_id|
154
+ opts[:max_id] = max_id unless max_id.nil?
155
+ user ? x_user_timeline(user, opts) : x_home_timeline(opts)
193
156
  end
157
+ tweets = tweets.select { |tweet| /#{query}/i.match(tweet["full_text"]) }
194
158
  print_tweets(tweets)
195
159
  end
196
160
  map %w[tl] => :timeline
@@ -204,9 +168,30 @@ module T
204
168
  method_option "unsorted", aliases: "-u", type: :boolean, desc: "Output is not sorted."
205
169
  def users(query)
206
170
  users = collect_with_page do |page|
207
- client.user_search(query, page:)
171
+ x_user_search(query, page:)
208
172
  end
209
173
  print_users(users)
210
174
  end
175
+
176
+ private
177
+
178
+ def print_search_results(tweets)
179
+ if options["csv"]
180
+ require "csv"
181
+ say TWEET_HEADINGS.to_csv unless tweets.empty?
182
+ tweets.each { |tweet| print_csv_tweet(tweet) }
183
+ elsif options["long"]
184
+ array = tweets.collect { |tweet| build_long_tweet(tweet) }
185
+ format = options["format"] || Array.new(TWEET_HEADINGS.size) { "%s" }
186
+ print_table_with_headings(array, TWEET_HEADINGS, format)
187
+ else
188
+ print_search_messages(tweets)
189
+ end
190
+ end
191
+
192
+ def print_search_messages(tweets)
193
+ say unless tweets.empty?
194
+ tweets.each { |tweet| print_message(tweet["user"]["screen_name"], decode_full_text(tweet, decode_full_uris: options["decode_uris"])) }
195
+ end
211
196
  end
212
197
  end
data/lib/t/set.rb CHANGED
@@ -15,8 +15,7 @@ module T
15
15
 
16
16
  desc "active SCREEN_NAME [CONSUMER_KEY]", "Set your active account."
17
17
  def active(screen_name, consumer_key = nil)
18
- require "t/core_ext/string"
19
- screen_name = screen_name.strip_ats
18
+ screen_name = screen_name.tr("@", "")
20
19
  @rcfile.path = options["profile"] if options["profile"]
21
20
  consumer_key = @rcfile[screen_name].keys.last if consumer_key.nil?
22
21
  @rcfile.active_profile = {"username" => @rcfile[screen_name][consumer_key]["username"], "consumer_key" => consumer_key}
@@ -26,46 +25,46 @@ module T
26
25
 
27
26
  desc "bio DESCRIPTION", "Edits your Bio information on your Twitter profile."
28
27
  def bio(description)
29
- client.update_profile(description:)
28
+ x_update_profile(description:)
30
29
  say "@#{@rcfile.active_profile[0]}'s bio has been updated."
31
30
  end
32
31
 
33
32
  desc "language LANGUAGE_NAME", "Selects the language you'd like to receive notifications in."
34
33
  def language(language_name)
35
- client.settings(lang: language_name)
34
+ x_settings(lang: language_name)
36
35
  say "@#{@rcfile.active_profile[0]}'s language has been updated."
37
36
  end
38
37
 
39
38
  desc "location PLACE_NAME", "Updates the location field in your profile."
40
39
  def location(place_name)
41
- client.update_profile(location: place_name)
40
+ x_update_profile(location: place_name)
42
41
  say "@#{@rcfile.active_profile[0]}'s location has been updated."
43
42
  end
44
43
 
45
44
  desc "name NAME", "Sets the name field on your Twitter profile."
46
45
  def name(name)
47
- client.update_profile(name:)
46
+ x_update_profile(name:)
48
47
  say "@#{@rcfile.active_profile[0]}'s name has been updated."
49
48
  end
50
49
 
51
50
  desc "profile_background_image FILE", "Sets the background image on your Twitter profile."
52
51
  method_option "tile", aliases: "-t", type: :boolean, desc: "Whether or not to tile the background image."
53
52
  def profile_background_image(file)
54
- client.update_profile_background_image(File.new(File.expand_path(file)), tile: options["tile"], skip_status: true)
53
+ x_update_profile_background_image(File.new(File.expand_path(file)), tile: options["tile"], skip_status: true)
55
54
  say "@#{@rcfile.active_profile[0]}'s background image has been updated."
56
55
  end
57
56
  map %w[background background_image] => :profile_background_image
58
57
 
59
58
  desc "profile_image FILE", "Sets the image on your Twitter profile."
60
59
  def profile_image(file)
61
- client.update_profile_image(File.new(File.expand_path(file)))
60
+ x_update_profile_image(File.new(File.expand_path(file)))
62
61
  say "@#{@rcfile.active_profile[0]}'s image has been updated."
63
62
  end
64
63
  map %w[avatar image] => :profile_image
65
64
 
66
65
  desc "website URI", "Sets the website field on your profile."
67
66
  def website(uri)
68
- client.update_profile(url: uri)
67
+ x_update_profile(url: uri)
69
68
  say "@#{@rcfile.active_profile[0]}'s website has been updated."
70
69
  end
71
70
  end