t 4.2.0 → 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,86 @@
1
+ module T
2
+ module RequestableAPI
3
+ module Helpers
4
+ private
5
+
6
+ def normalize_id_list(ids)
7
+ Array(ids).flatten.compact.map(&:to_s)
8
+ end
9
+
10
+ def single_or_array(input, values)
11
+ input.is_a?(Array) ? values : values.first
12
+ end
13
+
14
+ def timeline_v2_params(opts)
15
+ params = {
16
+ "tweet.fields": V2_TWEET_FIELDS,
17
+ expansions: V2_TWEET_EXPANSIONS,
18
+ "user.fields": V2_USER_FIELDS,
19
+ "place.fields": V2_PLACE_FIELDS,
20
+ }
21
+ count = opts[:count] || DEFAULT_NUM_RESULTS
22
+ params[:max_results] = count.to_i.clamp(1, MAX_SEARCH_RESULTS).to_s
23
+ excludes = build_v2_excludes(opts)
24
+ params[:exclude] = excludes unless excludes.empty?
25
+ params[:until_id] = opts[:max_id].to_s if opts[:max_id]
26
+ params[:since_id] = opts[:since_id].to_s if opts[:since_id]
27
+ params
28
+ end
29
+
30
+ def build_v2_excludes(opts)
31
+ excludes = []
32
+ excludes << "replies" if opts[:exclude_replies]
33
+ excludes << "retweets" if opts.key?(:include_rts) && opts[:include_rts] == false
34
+ excludes.join(",")
35
+ end
36
+
37
+ def user_lookup_params
38
+ {
39
+ "user.fields": V2_USER_FIELDS,
40
+ expansions: V2_USER_EXPANSIONS,
41
+ "tweet.fields": V2_TWEET_FIELDS,
42
+ }
43
+ end
44
+
45
+ def list_lookup_params
46
+ {
47
+ "list.fields": V2_LIST_FIELDS,
48
+ expansions: "owner_id",
49
+ "user.fields": V2_USER_FIELDS,
50
+ }
51
+ end
52
+
53
+ def v2_tweet_params
54
+ {
55
+ "tweet.fields": V2_TWEET_FIELDS,
56
+ expansions: V2_TWEET_EXPANSIONS,
57
+ "user.fields": V2_USER_FIELDS,
58
+ "place.fields": V2_PLACE_FIELDS,
59
+ }
60
+ end
61
+
62
+ def entity_like?(obj)
63
+ obj.is_a?(Hash)
64
+ end
65
+
66
+ def value_id(value)
67
+ return nil unless entity_like?(value)
68
+
69
+ value["id_str"] || value["id"]&.to_s
70
+ end
71
+
72
+ def strip_at(value)
73
+ value.to_s.delete_prefix("@")
74
+ end
75
+
76
+ def slugify_list_name(value)
77
+ slug = value.to_s.downcase.gsub(/[^a-z0-9_]+/, "-")
78
+ slug.gsub(/\A-+|-+\z/, "")
79
+ end
80
+
81
+ def numeric_identifier?(value)
82
+ value.to_s.match?(/\A\d+\z/)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,113 @@
1
+ require "net/http"
2
+
3
+ module T
4
+ module RequestableAPI
5
+ module HTTP
6
+ private
7
+
8
+ def v1_client
9
+ @v1_client ||= begin
10
+ client # ensure credentials are initialized
11
+ X::Client.new(**@requestable_api_credentials, base_url: BASE_URL_V1)
12
+ end
13
+ end
14
+
15
+ def upload_client
16
+ @upload_client ||= begin
17
+ client # ensure credentials are initialized
18
+ X::Client.new(**@requestable_api_credentials, base_url: BASE_URL_UPLOAD)
19
+ end
20
+ end
21
+
22
+ def bearer_client
23
+ @bearer_client ||= begin
24
+ client # ensure credentials are initialized
25
+ key = @requestable_api_credentials[:api_key]
26
+ secret = @requestable_api_credentials[:api_key_secret]
27
+ basic = Base64.strict_encode64("#{key}:#{secret}")
28
+ uri = URI("https://api.twitter.com/oauth2/token")
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = true
31
+ request = Net::HTTP::Post.new(uri)
32
+ request["Authorization"] = "Basic #{basic}"
33
+ request["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"
34
+ request.body = "grant_type=client_credentials"
35
+ response = http.request(request)
36
+ token = JSON.parse(response.body)["access_token"]
37
+ X::Client.new(bearer_token: token)
38
+ end
39
+ end
40
+
41
+ def upload_media(file)
42
+ binary = if file.respond_to?(:read)
43
+ file.rewind if file.respond_to?(:rewind)
44
+ file.read
45
+ else
46
+ File.binread(file.to_s)
47
+ end
48
+ response = t_post_v1_form("media/upload.json", {media_data: Base64.strict_encode64(binary)}, request_client: upload_client)
49
+ media_id = response["media_id_string"] || response["media_id"] || value_id(response)
50
+ raise X::Error.new("Media upload did not return a media_id") if media_id.to_s.empty?
51
+
52
+ media_id.to_s
53
+ end
54
+
55
+ def t_get_v2(path, params = {})
56
+ client.get(t_endpoint(path, params))
57
+ end
58
+
59
+ def t_get_v1(path, params = {})
60
+ v1_client.get(t_endpoint(path, params))
61
+ end
62
+
63
+ def t_post_v2_json(path, body = {})
64
+ client.post(t_normalize_path(path), JSON.generate(t_compact_hash(body)), headers: JSON_HEADERS)
65
+ end
66
+
67
+ def t_post_bearer_json(path, body = {})
68
+ bearer_client.post(t_normalize_path(path), JSON.generate(t_compact_hash(body)), headers: JSON_HEADERS)
69
+ end
70
+
71
+ def t_post_v1_form(path, params = {}, request_client: v1_client)
72
+ request_client.post(t_normalize_path(path), URI.encode_www_form(t_form_pairs(params)), headers: FORM_HEADERS)
73
+ end
74
+
75
+ def t_delete_v2(path, params = {})
76
+ client.delete(t_endpoint(path, params))
77
+ end
78
+
79
+ def t_endpoint(path, params)
80
+ query = URI.encode_www_form(t_form_pairs(params))
81
+ query.empty? ? t_normalize_path(path) : "#{t_normalize_path(path)}?#{query}"
82
+ end
83
+
84
+ def t_form_pairs(hash)
85
+ hash.each_with_object([]) do |(key, value), pairs|
86
+ next if value.nil?
87
+
88
+ pairs << [key.to_s, t_scalar_value(value)]
89
+ end
90
+ end
91
+
92
+ def t_scalar_value(value)
93
+ case value
94
+ when TrueClass then "true"
95
+ when FalseClass then "false"
96
+ else value.to_s
97
+ end
98
+ end
99
+
100
+ def t_compact_hash(hash)
101
+ hash.each_with_object({}) do |(key, value), memo|
102
+ next if value.nil?
103
+
104
+ memo[key] = value
105
+ end
106
+ end
107
+
108
+ def t_normalize_path(path)
109
+ path.to_s.sub(%r{\A/+}, "")
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,70 @@
1
+ module T
2
+ module RequestableAPI
3
+ module ListEndpoints
4
+ def x_lists(user = nil)
5
+ user_id = user.nil? ? current_user_id : resolve_user_id(user)
6
+ collect_owned_lists(user_id)
7
+ end
8
+
9
+ def x_list(owner_or_id, list_name = nil)
10
+ list_id = if list_name.nil?
11
+ numeric_identifier?(owner_or_id) ? owner_or_id.to_s : resolve_list_id(current_user_id, owner_or_id.to_s)
12
+ else
13
+ owner_id = numeric_identifier?(owner_or_id) ? owner_or_id.to_s : resolve_user_id(owner_or_id)
14
+ resolve_list_id(owner_id, list_name.to_s)
15
+ end
16
+ extract_lists(t_get_v2("lists/#{list_id}", list_lookup_params)).first || {}
17
+ end
18
+
19
+ def x_create_list(name, opts = {})
20
+ body = {
21
+ name: name.to_s,
22
+ description: opts[:description].to_s,
23
+ private: opts[:mode].to_s == "private",
24
+ }
25
+ extract_lists(t_post_v2_json("lists", body)).first || {}
26
+ end
27
+
28
+ def x_destroy_list(list)
29
+ list_id = entity_like?(list) ? value_id(list) : list.to_s
30
+ t_delete_v2("lists/#{list_id}")
31
+ true
32
+ end
33
+
34
+ def x_add_list_members(list_name, users)
35
+ list_id = resolve_list_id(current_user_id, list_name.to_s)
36
+ Array(users).flatten.each do |entry|
37
+ t_post_v2_json("lists/#{list_id}/members", user_id: resolve_user_id(entry))
38
+ end
39
+ true
40
+ end
41
+
42
+ def x_remove_list_members(list_name, users)
43
+ list_id = resolve_list_id(current_user_id, list_name.to_s)
44
+ Array(users).flatten.each do |entry|
45
+ t_delete_v2("lists/#{list_id}/members/#{resolve_user_id(entry)}")
46
+ end
47
+ true
48
+ end
49
+
50
+ def x_list_member?(owner, list_name, user)
51
+ owner_id = numeric_identifier?(owner) ? owner.to_s : resolve_user_id(owner)
52
+ list_id = resolve_list_id(owner_id, list_name.to_s)
53
+ user_id = resolve_user_id(user)
54
+ fetch_list_member_ids(list_id).include?(user_id.to_s)
55
+ end
56
+
57
+ def x_list_members(owner, list_name)
58
+ owner_id = numeric_identifier?(owner) ? owner.to_s : resolve_user_id(owner)
59
+ list_id = resolve_list_id(owner_id, list_name)
60
+ lookup_users_by_ids(fetch_list_member_ids(list_id))
61
+ end
62
+
63
+ def x_list_timeline(owner, list_name, opts = {})
64
+ owner_id = numeric_identifier?(owner) ? owner.to_s : resolve_user_id(owner)
65
+ list_id = resolve_list_id(owner_id, list_name)
66
+ extract_tweets(t_get_v2("lists/#{list_id}/tweets", timeline_v2_params(opts)))
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,74 @@
1
+ module T
2
+ module RequestableAPI
3
+ module ListNormalization
4
+ private
5
+
6
+ def extract_lists(value)
7
+ return value if value.is_a?(Array)
8
+ return value.fetch("lists", []) if value["lists"].is_a?(Array)
9
+
10
+ users_by_id = index_items_by_id(value.dig("includes", "users"))
11
+ data = value["data"]
12
+ return [] unless data.is_a?(Array)
13
+
14
+ data.map { |list| normalize_v2_list(list, users_by_id) }
15
+ end
16
+
17
+ def normalize_v2_list(list, users_by_id)
18
+ return list if list.key?("slug") || list.key?("full_name")
19
+
20
+ object = build_v2_list_core(list)
21
+ apply_v2_list_mode(object, list)
22
+ apply_v2_list_owner(object, list, users_by_id)
23
+ object
24
+ end
25
+
26
+ def build_v2_list_core(list)
27
+ object = {}
28
+ apply_v2_id(object, list)
29
+ apply_v2_list_slug(object, list)
30
+ %w[created_at description].each { |f| object[f] = list[f] if list[f] }
31
+ object["member_count"] = list["member_count"] if list.key?("member_count")
32
+ object["subscriber_count"] = list["subscriber_count"] || list["follower_count"]
33
+ object
34
+ end
35
+
36
+ def apply_v2_id(object, source)
37
+ id = value_id(source)
38
+ return unless id
39
+
40
+ object["id"] = id.to_i
41
+ object["id_str"] = id.to_s
42
+ end
43
+
44
+ def apply_v2_list_slug(object, list)
45
+ slug = list["slug"] || list["name"]
46
+ return unless slug
47
+
48
+ object["slug"] = slug
49
+ object["name"] = slug
50
+ end
51
+
52
+ def apply_v2_list_mode(object, list)
53
+ if list.key?("mode")
54
+ object["mode"] = list["mode"]
55
+ elsif list.key?("private")
56
+ object["mode"] = list["private"] ? "private" : "public"
57
+ end
58
+ end
59
+
60
+ def apply_v2_list_owner(object, list, users_by_id)
61
+ id = value_id(list)
62
+ slug = object["slug"]
63
+ owner_id = list["owner_id"]
64
+ if owner_id && users_by_id[owner_id]
65
+ owner = normalize_v2_user(users_by_id[owner_id])
66
+ object["user"] = owner
67
+ owner_name = owner["screen_name"]
68
+ object["full_name"] = "@#{owner_name}/#{slug}" if owner_name && slug
69
+ end
70
+ object["uri"] = "https://x.com/i/lists/#{id}" if id
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,88 @@
1
+ module T
2
+ module RequestableAPI
3
+ module Mutations
4
+ def x_block(users)
5
+ mutate_users(users) { |target_id, me_id| t_post_v2_json("users/#{me_id}/blocking", target_user_id: target_id) }
6
+ end
7
+
8
+ def x_unblock(users)
9
+ mutate_users(users) { |target_id, me_id| t_delete_v2("users/#{me_id}/blocking/#{target_id}") }
10
+ end
11
+
12
+ def x_mute(users)
13
+ mutate_users(users) { |target_id, me_id| t_post_v2_json("users/#{me_id}/muting", target_user_id: target_id) }
14
+ end
15
+
16
+ def x_unmute(users)
17
+ mutate_users(users) { |target_id, me_id| t_delete_v2("users/#{me_id}/muting/#{target_id}") }
18
+ end
19
+
20
+ def x_follow(users)
21
+ mutate_users(users) { |target_id, me_id| t_post_v2_json("users/#{me_id}/following", target_user_id: target_id) }
22
+ end
23
+
24
+ def x_unfollow(users)
25
+ mutate_users(users) { |target_id, me_id| t_delete_v2("users/#{me_id}/following/#{target_id}") }
26
+ end
27
+
28
+ def x_report_spam(users)
29
+ Array(users).flatten.map do |entry|
30
+ resolved_user = resolve_user(entry)
31
+ key = numeric_identifier?(entry) ? :user_id : :screen_name
32
+ t_post_v1_form("users/report_spam.json", {key => (key == :user_id ? resolved_user["id"] : resolved_user["screen_name"])})
33
+ resolved_user
34
+ end
35
+ end
36
+
37
+ def x_muted_ids
38
+ ids = []
39
+ params = {max_results: "1000", "user.fields": "id,username"}
40
+ me_id = current_user_id
41
+ MAX_PAGE.times do
42
+ response = t_get_v2("users/#{me_id}/muting", params)
43
+ ids.concat(extract_ids(response))
44
+ token = response.dig("meta", "next_token")
45
+ break if token.nil?
46
+
47
+ params = params.merge(pagination_token: token)
48
+ end
49
+ ids
50
+ end
51
+
52
+ def x_favorite(status_ids)
53
+ me_id = current_user_id
54
+ tweets = normalize_id_list(status_ids).map do |id|
55
+ t_post_v2_json("users/#{me_id}/likes", tweet_id: id)
56
+ {"id" => id.to_i, "id_str" => id.to_s}
57
+ end
58
+ single_or_array(status_ids, tweets)
59
+ end
60
+
61
+ def x_unfavorite(status_ids)
62
+ me_id = current_user_id
63
+ tweets = normalize_id_list(status_ids).map do |id|
64
+ t_delete_v2("users/#{me_id}/likes/#{id}")
65
+ {"id" => id.to_i, "id_str" => id.to_s}
66
+ end
67
+ single_or_array(status_ids, tweets)
68
+ end
69
+
70
+ def x_retweet(status_ids)
71
+ me_id = current_user_id
72
+ tweets = normalize_id_list(status_ids).map do |id|
73
+ t_post_v2_json("users/#{me_id}/retweets", tweet_id: id)
74
+ {"id" => id.to_i, "id_str" => id.to_s}
75
+ end
76
+ single_or_array(status_ids, tweets)
77
+ end
78
+
79
+ def x_destroy_status(status_ids)
80
+ statuses = normalize_id_list(status_ids).map do |id|
81
+ t_delete_v2("tweets/#{id}")
82
+ {"id" => id.to_i, "id_str" => id.to_s}
83
+ end
84
+ single_or_array(status_ids, statuses)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,108 @@
1
+ module T
2
+ module RequestableAPI
3
+ module Resolution
4
+ private
5
+
6
+ def mutate_users(users)
7
+ me_id = current_user_id
8
+ Array(users).flatten.map do |entry|
9
+ resolved_user = resolve_user(entry)
10
+ yield resolved_user["id"].to_s, me_id
11
+ resolved_user
12
+ end
13
+ end
14
+
15
+ def current_user
16
+ @current_user ||= x_verify_credentials
17
+ end
18
+
19
+ def current_user_id
20
+ value_id(current_user).to_s
21
+ end
22
+
23
+ def resolve_user(entry)
24
+ return current_user if entry.nil?
25
+ return entry if entity_like?(entry)
26
+ return x_user(entry.to_s) if numeric_identifier?(entry)
27
+
28
+ x_user(strip_at(entry.to_s))
29
+ end
30
+
31
+ def resolve_user_id(entry)
32
+ return current_user_id if entry.nil?
33
+ return value_id(entry).to_s if entity_like?(entry)
34
+
35
+ numeric_identifier?(entry) ? entry.to_s : value_id(x_user(strip_at(entry.to_s))).to_s
36
+ end
37
+
38
+ def resolve_list_id(owner_id, list_name)
39
+ desired = slugify_list_name(list_name)
40
+ lists = collect_owned_lists(owner_id)
41
+ matched = lists.find do |list|
42
+ slug = (list["slug"] || list["name"]).to_s
43
+ slug.casecmp?(list_name.to_s) || slugify_list_name(slug) == desired
44
+ end
45
+ (value_id(matched) || list_name).to_s
46
+ end
47
+
48
+ def fetch_relationship_ids(user_id, relationship)
49
+ endpoint = relationship == "followers" ? "users/#{user_id}/followers" : "users/#{user_id}/following"
50
+ params = {max_results: "1000", "user.fields": "id,username"}
51
+ ids = []
52
+ MAX_PAGE.times do
53
+ response = t_get_v2(endpoint, params)
54
+ ids.concat(extract_ids(response))
55
+ token = response.dig("meta", "next_token")
56
+ break if token.nil?
57
+
58
+ params = params.merge(pagination_token: token)
59
+ end
60
+ ids
61
+ end
62
+
63
+ def fetch_list_member_ids(list_id)
64
+ params = {max_results: "100", "user.fields": "id,username"}
65
+ ids = []
66
+ MAX_PAGE.times do
67
+ response = t_get_v2("lists/#{list_id}/members", params)
68
+ ids.concat(extract_ids(response))
69
+ token = response.dig("meta", "next_token")
70
+ break if token.nil?
71
+
72
+ params = params.merge(pagination_token: token)
73
+ end
74
+ ids
75
+ end
76
+
77
+ def collect_owned_lists(user_id)
78
+ params = {
79
+ max_results: "100",
80
+ "list.fields": V2_LIST_FIELDS,
81
+ expansions: "owner_id",
82
+ "user.fields": V2_USER_FIELDS,
83
+ }
84
+ lists = []
85
+ MAX_PAGE.times do
86
+ response = t_get_v2("users/#{user_id}/owned_lists", params)
87
+ lists.concat(extract_lists(response))
88
+ token = response.dig("meta", "next_token")
89
+ break if token.nil?
90
+
91
+ params = params.merge(pagination_token: token)
92
+ end
93
+ lists
94
+ end
95
+
96
+ def lookup_users_by_ids(ids)
97
+ ids = Array(ids).compact
98
+ return [] if ids.empty?
99
+
100
+ users = []
101
+ ids.each_slice(100) do |chunk|
102
+ users.concat(extract_users(t_get_v2("users", user_lookup_params.merge(ids: chunk.join(",")))))
103
+ end
104
+ users
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,85 @@
1
+ module T
2
+ module RequestableAPI
3
+ module TweetEndpoints
4
+ def x_retweets_of_me(opts = {})
5
+ extract_tweets(t_get_v2("users/reposts_of_me", timeline_v2_params(opts)))
6
+ end
7
+
8
+ def x_retweeted_by_me(opts = {})
9
+ x_retweets_of_me(opts)
10
+ end
11
+
12
+ def x_retweeted_by_user(user, opts = {})
13
+ x_user_timeline(user, opts).select { |tweet| tweet["full_text"].to_s.start_with?("RT @") }
14
+ end
15
+
16
+ def x_retweeters_ids(tweet_id)
17
+ ids = []
18
+ params = {"user.fields": "id,username", max_results: "100"}
19
+ MAX_PAGE.times do
20
+ response = t_get_v2("tweets/#{tweet_id}/retweeted_by", params)
21
+ ids.concat(extract_ids(response))
22
+ token = response.dig("meta", "next_token")
23
+ break if token.nil?
24
+
25
+ params = params.merge(pagination_token: token)
26
+ end
27
+ ids
28
+ end
29
+
30
+ def x_status(status_id, _opts = {})
31
+ extract_tweets(t_get_v2("tweets/#{status_id}", v2_tweet_params)).first || {}
32
+ end
33
+
34
+ def x_home_timeline(opts = {})
35
+ me_id = current_user_id
36
+ extract_tweets(t_get_v2("users/#{me_id}/timelines/reverse_chronological", timeline_v2_params(opts)))
37
+ end
38
+
39
+ def x_user_timeline(user, opts = {})
40
+ user_id = resolve_user_id(user)
41
+ extract_tweets(t_get_v2("users/#{user_id}/tweets", timeline_v2_params(opts)))
42
+ end
43
+
44
+ def x_mentions(opts = {})
45
+ me_id = current_user_id
46
+ extract_tweets(t_get_v2("users/#{me_id}/mentions", timeline_v2_params(opts)))
47
+ end
48
+
49
+ def x_favorites(user = nil, opts = {})
50
+ if user.is_a?(Hash) && opts.empty?
51
+ opts = user
52
+ user = nil
53
+ end
54
+ user_id = user.nil? ? current_user_id : resolve_user_id(user)
55
+ extract_tweets(t_get_v2("users/#{user_id}/liked_tweets", timeline_v2_params(opts)))
56
+ end
57
+
58
+ def x_search(query, opts = {})
59
+ count = [opts.fetch(:count, MAX_SEARCH_RESULTS).to_i, MAX_SEARCH_RESULTS].min
60
+ params = {
61
+ query: query.to_s,
62
+ max_results: count.to_s,
63
+ }.merge(v2_tweet_params)
64
+ params[:until_id] = opts[:max_id].to_s if opts[:max_id]
65
+ params[:since_id] = opts[:since_id].to_s if opts[:since_id]
66
+ extract_tweets(t_get_v2("tweets/search/recent", params))
67
+ end
68
+
69
+ def x_update(status, opts = {})
70
+ body = {text: status.to_s}
71
+ body[:reply] = {in_reply_to_tweet_id: opts[:in_reply_to_status_id].to_s} if opts[:in_reply_to_status_id]
72
+ body[:media] = {media_ids: Array(opts[:media_ids]).map(&:to_s)} if opts[:media_ids]
73
+ response = t_post_v2_json("tweets", body)
74
+ id = value_id(response) || value_id(response["data"])
75
+ {"id" => id.to_i, "id_str" => id.to_s, "text" => status.to_s,
76
+ "full_text" => status.to_s, "user" => current_user}
77
+ end
78
+
79
+ def x_update_with_media(status, file, opts = {})
80
+ media_id = upload_media(file)
81
+ x_update(status, opts.merge(media_ids: [media_id]))
82
+ end
83
+ end
84
+ end
85
+ end