x-resources 1.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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +254 -0
- data/LICENSE.txt +21 -0
- data/README.md +148 -0
- data/lib/x/resources/abstract_class.rb +29 -0
- data/lib/x/resources/actions/direct_messages.rb +99 -0
- data/lib/x/resources/actions/engagement.rb +95 -0
- data/lib/x/resources/actions/lists.rb +126 -0
- data/lib/x/resources/actions/posts.rb +77 -0
- data/lib/x/resources/actions/relationships.rb +91 -0
- data/lib/x/resources/actions.rb +22 -0
- data/lib/x/resources/api.rb +40 -0
- data/lib/x/resources/attributes.rb +203 -0
- data/lib/x/resources/batch.rb +43 -0
- data/lib/x/resources/batch_finders.rb +185 -0
- data/lib/x/resources/bookmark_folder.rb +23 -0
- data/lib/x/resources/community.rb +137 -0
- data/lib/x/resources/cursor.rb +493 -0
- data/lib/x/resources/direct_message.rb +325 -0
- data/lib/x/resources/direct_message_conversations.rb +147 -0
- data/lib/x/resources/errors.rb +104 -0
- data/lib/x/resources/finders.rb +255 -0
- data/lib/x/resources/identity.rb +65 -0
- data/lib/x/resources/includes.rb +216 -0
- data/lib/x/resources/list.rb +336 -0
- data/lib/x/resources/lookups/communities.rb +56 -0
- data/lib/x/resources/lookups/direct_messages.rb +86 -0
- data/lib/x/resources/lookups/lists.rb +44 -0
- data/lib/x/resources/lookups/media.rb +72 -0
- data/lib/x/resources/lookups/posts.rb +198 -0
- data/lib/x/resources/lookups/spaces.rb +87 -0
- data/lib/x/resources/lookups/trends.rb +38 -0
- data/lib/x/resources/lookups/users.rb +221 -0
- data/lib/x/resources/lookups.rb +25 -0
- data/lib/x/resources/marshalling.rb +93 -0
- data/lib/x/resources/matching_rule.rb +107 -0
- data/lib/x/resources/media.rb +278 -0
- data/lib/x/resources/media_ids.rb +74 -0
- data/lib/x/resources/memo.rb +54 -0
- data/lib/x/resources/page.rb +394 -0
- data/lib/x/resources/page_limit.rb +80 -0
- data/lib/x/resources/pages.rb +270 -0
- data/lib/x/resources/parallel.rb +82 -0
- data/lib/x/resources/personalized_trend.rb +124 -0
- data/lib/x/resources/place.rb +107 -0
- data/lib/x/resources/poll.rb +75 -0
- data/lib/x/resources/post.rb +615 -0
- data/lib/x/resources/post_collections.rb +67 -0
- data/lib/x/resources/post_counts.rb +215 -0
- data/lib/x/resources/post_search.rb +86 -0
- data/lib/x/resources/post_usage.rb +203 -0
- data/lib/x/resources/post_writes.rb +140 -0
- data/lib/x/resources/published_count.rb +31 -0
- data/lib/x/resources/references.rb +121 -0
- data/lib/x/resources/relation_writes.rb +54 -0
- data/lib/x/resources/relationships.rb +77 -0
- data/lib/x/resources/resource.rb +535 -0
- data/lib/x/resources/serialization.rb +58 -0
- data/lib/x/resources/shape.rb +167 -0
- data/lib/x/resources/space.rb +332 -0
- data/lib/x/resources/topic.rb +59 -0
- data/lib/x/resources/trend.rb +130 -0
- data/lib/x/resources/user.rb +502 -0
- data/lib/x/resources/user_collections.rb +213 -0
- data/lib/x/resources/user_finders.rb +282 -0
- data/lib/x/resources/utils.rb +358 -0
- data/lib/x/resources/value_equality.rb +38 -0
- data/lib/x/resources/value_marshalling.rb +89 -0
- data/lib/x/resources/version.rb +25 -0
- data/lib/x/resources.rb +22 -0
- data/sig/manifest.yaml +7 -0
- data/sig/x-resources.rbs +813 -0
- metadata +140 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "shape"
|
|
6
|
+
require_relative "page_limit"
|
|
7
|
+
require_relative "utils"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Resources
|
|
11
|
+
# Counts of the posts that match a query, which the API bills by the request rather than by the post
|
|
12
|
+
#
|
|
13
|
+
# The counts endpoints refuse OAuth 1.0a, so a client that signs with it counts with a copy that authenticates as
|
|
14
|
+
# the app. A client signed in with OAuth 2.0 as a user that holds no credentials of the app counts as the user,
|
|
15
|
+
# which the count of recent posts takes, and the count of the full archive, which takes app-only authentication
|
|
16
|
+
# alone, refuses with 403 Forbidden, raising X::Forbidden.
|
|
17
|
+
#
|
|
18
|
+
# Internal to x-resources: the methods it gives Post, such as X::Post.count, are public API, but the module is only
|
|
19
|
+
# how they are shared, and which classes extend or include it can change within 1.x.
|
|
20
|
+
#
|
|
21
|
+
# @api semipublic
|
|
22
|
+
module PostCounts
|
|
23
|
+
# The endpoint that counts the posts from the last seven days
|
|
24
|
+
# @api private
|
|
25
|
+
RECENT_ENDPOINT = "tweets/counts/recent"
|
|
26
|
+
# The endpoint that counts the posts from the full archive
|
|
27
|
+
# @api private
|
|
28
|
+
ALL_ENDPOINT = "tweets/counts/all"
|
|
29
|
+
# The granularity that makes the fewest periods, and so the least data
|
|
30
|
+
# @api private
|
|
31
|
+
DEFAULT_GRANULARITY = "day"
|
|
32
|
+
private_constant :RECENT_ENDPOINT, :ALL_ENDPOINT, :DEFAULT_GRANULARITY
|
|
33
|
+
|
|
34
|
+
# Count the recent posts that match a query, without reading them
|
|
35
|
+
#
|
|
36
|
+
# The recent posts are counted a page of periods at a time, as the full archive is, which max_pages limits.
|
|
37
|
+
#
|
|
38
|
+
# @api public
|
|
39
|
+
# @param query [String] the search query
|
|
40
|
+
# @param client [Object] the client used to make the requests
|
|
41
|
+
# @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
|
|
42
|
+
# @param params [Hash] query parameters, such as start_time and end_time
|
|
43
|
+
# @return [Integer] the number of matching posts
|
|
44
|
+
# @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
|
|
45
|
+
# @raise [InvalidAttribute] if the response holds a total that is not a number
|
|
46
|
+
# @raise [UnreadableResponse] if a page names the token of a page before it as the next
|
|
47
|
+
# @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
|
|
48
|
+
# @example Count the recent posts about Ruby
|
|
49
|
+
# X::Post.count("ruby", client: client)
|
|
50
|
+
def count(query, client:, max_pages: nil, **params) = total(pages(RECENT_ENDPOINT, query, client:, max_pages:, **params))
|
|
51
|
+
|
|
52
|
+
# Count the posts from the full archive that match a query, without reading them
|
|
53
|
+
#
|
|
54
|
+
# The archive is counted a page of periods at a time, and the API bills each page, so a count over many years
|
|
55
|
+
# can take many requests, which max_pages limits, raising PageLimitReached rather than read past them.
|
|
56
|
+
#
|
|
57
|
+
# @api public
|
|
58
|
+
# @param query [String] the search query
|
|
59
|
+
# @param client [Object] the client used to make the requests
|
|
60
|
+
# @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
|
|
61
|
+
# @param params [Hash] query parameters, such as start_time and end_time
|
|
62
|
+
# @return [Integer] the number of matching posts
|
|
63
|
+
# @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
|
|
64
|
+
# @raise [InvalidAttribute] if the response holds a total that is not a number
|
|
65
|
+
# @raise [UnreadableResponse] if a page names the token of a page before it as the next
|
|
66
|
+
# @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
|
|
67
|
+
# @example Count every post about Ruby
|
|
68
|
+
# X::Post.count_all("ruby", client: client)
|
|
69
|
+
# @example Count the posts about Ruby in no more than three requests
|
|
70
|
+
# X::Post.count_all("ruby", client: client, max_pages: 3)
|
|
71
|
+
def count_all(query, client:, max_pages: nil, **params) = total(pages(ALL_ENDPOINT, query, client:, max_pages:, **params))
|
|
72
|
+
|
|
73
|
+
# Count the posts from the last seven days that match a query, by period
|
|
74
|
+
#
|
|
75
|
+
# The recent posts are counted a page of periods at a time, as the full archive is, which max_pages limits.
|
|
76
|
+
#
|
|
77
|
+
# @api public
|
|
78
|
+
# @param query [String] the search query
|
|
79
|
+
# @param client [Object] the client used to make the requests
|
|
80
|
+
# @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
|
|
81
|
+
# @param params [Hash] query parameters, such as granularity, which is day by default
|
|
82
|
+
# @return [Hash{Range<Time> => Integer}] the number of matching posts, keyed by the time each period spans, from
|
|
83
|
+
# its start up to, but not including, its end, oldest first
|
|
84
|
+
# @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
|
|
85
|
+
# @raise [InvalidAttribute] if the response holds a period without a start and an end in ISO 8601, or without a
|
|
86
|
+
# count
|
|
87
|
+
# @raise [UnreadableResponse] if a page names the token of a page before it as the next
|
|
88
|
+
# @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
|
|
89
|
+
# @example Count the recent posts about Ruby by hour
|
|
90
|
+
# X::Post.count_by_period("ruby", client: client, granularity: "hour")
|
|
91
|
+
def count_by_period(query, client:, max_pages: nil, **params) = periods(pages(RECENT_ENDPOINT, query, client:, max_pages:, **params))
|
|
92
|
+
|
|
93
|
+
# Count the posts from the full archive that match a query, by period
|
|
94
|
+
#
|
|
95
|
+
# The archive is counted a page of periods at a time, and the API bills each page, so a count over many years
|
|
96
|
+
# can take many requests, which max_pages limits, raising PageLimitReached rather than read past them.
|
|
97
|
+
#
|
|
98
|
+
# @api public
|
|
99
|
+
# @param query [String] the search query
|
|
100
|
+
# @param client [Object] the client used to make the requests
|
|
101
|
+
# @param max_pages [Integer, nil] the most pages of counts to request, or nil for no limit
|
|
102
|
+
# @param params [Hash] query parameters, such as granularity, which is day by default
|
|
103
|
+
# @return [Hash{Range<Time> => Integer}] the number of matching posts, keyed by the time each period spans, from
|
|
104
|
+
# its start up to, but not including, its end, oldest first
|
|
105
|
+
# @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
|
|
106
|
+
# @raise [InvalidAttribute] if the response holds a period without a start and an end in ISO 8601, or without a
|
|
107
|
+
# count
|
|
108
|
+
# @raise [UnreadableResponse] if a page names the token of a page before it as the next
|
|
109
|
+
# @raise [PageLimitReached] if the count requests max_pages pages, and the API names another
|
|
110
|
+
# @example Count every post about Ruby by day
|
|
111
|
+
# X::Post.count_all_by_period("ruby", client: client)
|
|
112
|
+
def count_all_by_period(query, client:, max_pages: nil, **params) = periods(pages(ALL_ENDPOINT, query, client:, max_pages:, **params))
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# Request every page of counts, following the next page token, up to a limit
|
|
117
|
+
# @api private
|
|
118
|
+
# @param path [String] the counts endpoint path
|
|
119
|
+
# @param query [String] the search query
|
|
120
|
+
# @param client [Object] the client used to make the requests
|
|
121
|
+
# @param max_pages [Integer, nil] the most pages to request, or nil for no limit
|
|
122
|
+
# @param params [Hash] query parameters
|
|
123
|
+
# @return [Array<Hash>] the response bodies
|
|
124
|
+
# @raise [ArgumentError] if max_pages is neither an Integer of at least 1 nor nil, before a request
|
|
125
|
+
# @raise [InvalidAttribute] if a response holds a meta that is not an object
|
|
126
|
+
# @raise [UnreadableResponse] if a response names the token of a page before it as the next
|
|
127
|
+
# @raise [PageLimitReached] if the pages requested reach max_pages, and the last names another
|
|
128
|
+
def pages(path, query, client:, max_pages:, **params)
|
|
129
|
+
params, max_pages = {query:, granularity: DEFAULT_GRANULARITY}.merge(params), PageLimit.check!(max_pages)
|
|
130
|
+
client = Utils.app_client(client)
|
|
131
|
+
bodies = [] #: Array[Hash[String, untyped]]
|
|
132
|
+
given = params[:next_token]
|
|
133
|
+
loop do
|
|
134
|
+
bodies << client.get(Utils.path(path, params), **Utils::JSON_CLASSES).to_h
|
|
135
|
+
token = next_token(bodies, given, max_pages) or break
|
|
136
|
+
params = params.merge(next_token: token)
|
|
137
|
+
end
|
|
138
|
+
bodies
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The token of the page of counts after the last, which fetched no page before it
|
|
142
|
+
#
|
|
143
|
+
# A page that names the token of a page before it as the next would have the pages requested again for good,
|
|
144
|
+
# and the API bill each request, so it raises instead. The next_token the counts were given, which fetched the
|
|
145
|
+
# first page, is the token of a page before each of them too. An empty token names no page, so the page that
|
|
146
|
+
# names one is the last, as a page that names none is. A page after as many as max_pages allows raises too,
|
|
147
|
+
# rather than be requested.
|
|
148
|
+
#
|
|
149
|
+
# @api private
|
|
150
|
+
# @param bodies [Array<Hash>] the response bodies so far
|
|
151
|
+
# @param given [String, nil] the next_token the counts were given, or nil for none
|
|
152
|
+
# @param max_pages [Integer, nil] the most pages to request, or nil for no limit
|
|
153
|
+
# @return [String, nil] the token, or nil if the last page is the last of the counts
|
|
154
|
+
# @raise [InvalidAttribute] if the last response holds a meta that is not an object
|
|
155
|
+
# @raise [UnreadableResponse] if the last response names the token of a page before it as the next
|
|
156
|
+
# @raise [PageLimitReached] if the pages requested reach max_pages, and the last names another
|
|
157
|
+
def next_token(bodies, given, max_pages)
|
|
158
|
+
tokens = bodies.map { |body| Shape.dig("The next page of the counts of #{self}", body, %w[meta next_token]) }
|
|
159
|
+
token = tokens.pop
|
|
160
|
+
return if token.nil? || token.eql?("")
|
|
161
|
+
|
|
162
|
+
raise UnreadableResponse, "The counts of #{self} name the next_token #{token.inspect}, which fetched an earlier page" if [given, *tokens].include?(token)
|
|
163
|
+
|
|
164
|
+
PageLimit.reached!("The counts of #{self}", read: bodies.size, max_pages:, next_token: token)
|
|
165
|
+
token
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The total count of every page, which the API names for tweets or for posts
|
|
169
|
+
# @api private
|
|
170
|
+
# @param bodies [Array<Hash>] the response bodies
|
|
171
|
+
# @return [Integer] the total
|
|
172
|
+
# @raise [InvalidAttribute] if a page holds a total that is not a number
|
|
173
|
+
def total(bodies) = bodies.sum { |body| page_total(body.dig("meta", "total_tweet_count") || body.dig("meta", "total_post_count")) }
|
|
174
|
+
|
|
175
|
+
# The total count of one page, which is zero when the page holds none
|
|
176
|
+
# @api private
|
|
177
|
+
# @param value [Integer, String, nil] the total the page holds
|
|
178
|
+
# @return [Integer] the total
|
|
179
|
+
# @raise [InvalidAttribute] if the total is not a number
|
|
180
|
+
def page_total(value) = Utils.read("The total of the counts of #{self}", value) { Shape.integer(value) } || 0
|
|
181
|
+
|
|
182
|
+
# The count of each period of every page, oldest first
|
|
183
|
+
#
|
|
184
|
+
# The API serves the newest page first while each page runs oldest to newest, so the periods of a paginated
|
|
185
|
+
# count are sorted before they are frozen, and the result reads in time order however many pages it took.
|
|
186
|
+
#
|
|
187
|
+
# @api private
|
|
188
|
+
# @param bodies [Array<Hash>] the response bodies
|
|
189
|
+
# @return [Hash{Range<Time> => Integer}] the counts, keyed by the time each period spans, in time order
|
|
190
|
+
# @raise [InvalidAttribute] if a period has no start and end in ISO 8601, or no count that is a number, or a
|
|
191
|
+
# response holds the periods as something other than a list of objects
|
|
192
|
+
def periods(bodies)
|
|
193
|
+
entries = bodies.flat_map { |body| Shape.objects("A period of the counts of #{self}", body["data"]) } #: Array[Hash[String, untyped]]
|
|
194
|
+
entries.map { |entry| period(entry) }.sort_by { |span, _count| span.begin }.to_h.freeze
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The time a period spans and the number of posts in it
|
|
198
|
+
#
|
|
199
|
+
# The period spans its start up to, but not including, its end, which is the start of the period after it. The
|
|
200
|
+
# API names the number for tweets or for posts.
|
|
201
|
+
#
|
|
202
|
+
# @api private
|
|
203
|
+
# @param entry [Hash] the period, with its start, its end, and its count
|
|
204
|
+
# @return [Array(Range<Time>, Integer)] the time it spans and the count
|
|
205
|
+
# @raise [InvalidAttribute] if the period has no start and end in ISO 8601, or no count that is an Integer that is
|
|
206
|
+
# not negative or a String of digits
|
|
207
|
+
def period(entry)
|
|
208
|
+
Utils.read("A period of the counts of #{self}", entry) do
|
|
209
|
+
[Time.iso8601(entry["start"].to_s)...Time.iso8601(entry["end"].to_s), Shape.integer(entry["tweet_count"] || entry["post_count"]) || raise(ArgumentError, "a period needs a count")]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
private_constant :PostCounts
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cursor"
|
|
4
|
+
require_relative "utils"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Resources
|
|
8
|
+
# The searches of posts, and the posts of the authenticated user that others reposted
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-resources: the methods it gives Post, such as X::Post.search, are public API, but the module is only
|
|
11
|
+
# how they are shared, and which classes extend or include it can change within 1.x.
|
|
12
|
+
#
|
|
13
|
+
# @api semipublic
|
|
14
|
+
module PostSearch
|
|
15
|
+
# Maximum number of posts per page of a search, or of the reposts of the authenticated user
|
|
16
|
+
# @api private
|
|
17
|
+
MAX_RESULTS = 100
|
|
18
|
+
# Maximum number of posts per page of full-archive search, which allows only MAX_RESULTS with context annotations
|
|
19
|
+
# @api private
|
|
20
|
+
MAX_ARCHIVE_RESULTS = 500
|
|
21
|
+
private_constant :MAX_RESULTS, :MAX_ARCHIVE_RESULTS
|
|
22
|
+
|
|
23
|
+
# Search recent posts
|
|
24
|
+
#
|
|
25
|
+
# @api public
|
|
26
|
+
# @param query [String] the search query
|
|
27
|
+
# @param client [Object] the client used to make the requests
|
|
28
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
29
|
+
# @return [Cursor] a cursor over the matching posts
|
|
30
|
+
# @example Print posts about Ruby
|
|
31
|
+
# X::Post.search("ruby -is:retweet", client: client).each { |post| puts post.text }
|
|
32
|
+
def search(query, client:, **params)
|
|
33
|
+
# @type self: singleton(Post)
|
|
34
|
+
Cursor.__send__(:build, self, "tweets/search/recent", client:, params: {query:, max_results: MAX_RESULTS}.merge(params), min_results: 10)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Search the full archive of posts
|
|
38
|
+
#
|
|
39
|
+
# A page holds up to 500 posts, or 100 when the request asks for context annotations, as the default fields do.
|
|
40
|
+
#
|
|
41
|
+
# @api public
|
|
42
|
+
# @param query [String] the search query
|
|
43
|
+
# @param client [Object] the client used to make the requests
|
|
44
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
45
|
+
# @return [Cursor] a cursor over the matching posts
|
|
46
|
+
# @example Print every post about Ruby
|
|
47
|
+
# X::Post.search_all("ruby -is:retweet", client: client).each { |post| puts post.text }
|
|
48
|
+
# @example Page through every post about Ruby 500 at a time, without context annotations
|
|
49
|
+
# X::Post.search_all("ruby", client: client, "post.fields": %w[created_at text])
|
|
50
|
+
def search_all(query, client:, **params)
|
|
51
|
+
# @type self: singleton(Post)
|
|
52
|
+
max_results = context_annotations?(params) ? MAX_RESULTS : MAX_ARCHIVE_RESULTS
|
|
53
|
+
Cursor.__send__(:build, self, "tweets/search/all", client:, params: {query:, max_results:}.merge(params), min_results: 10)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The posts of the authenticated user that other users have reposted
|
|
57
|
+
#
|
|
58
|
+
# The endpoint names no user, so these are always the posts of the user the client authenticates as.
|
|
59
|
+
#
|
|
60
|
+
# @api public
|
|
61
|
+
# @param client [Object] the client used to make the requests
|
|
62
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
63
|
+
# @return [Cursor] a cursor over the reposted posts
|
|
64
|
+
# @example Print the reposted posts
|
|
65
|
+
# X::Post.reposts_of_me(client: client).each { |post| puts post.text }
|
|
66
|
+
def reposts_of_me(client:, **params)
|
|
67
|
+
# @type self: singleton(Post)
|
|
68
|
+
Cursor.__send__(:build, self, "users/reposts_of_me", client:, params: {max_results: MAX_RESULTS}.merge(params))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
alias_method :retweets_of_me, :reposts_of_me
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Check whether a request asks for the context annotations of its posts
|
|
76
|
+
# @api private
|
|
77
|
+
# @param params [Hash] query parameters merged over the default parameters
|
|
78
|
+
# @return [Boolean] true if the post fields include context_annotations
|
|
79
|
+
def context_annotations?(params)
|
|
80
|
+
# @type self: singleton(Post)
|
|
81
|
+
Utils.merge_params(default_params, params)["post.fields"].to_s.split(",").include?("context_annotations")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
private_constant :PostSearch
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "serialization"
|
|
5
|
+
require_relative "shape"
|
|
6
|
+
require_relative "utils"
|
|
7
|
+
require_relative "value_equality"
|
|
8
|
+
require_relative "value_marshalling"
|
|
9
|
+
|
|
10
|
+
module X
|
|
11
|
+
module Resources
|
|
12
|
+
# How many posts the app's project has read, as the usage endpoint reports it
|
|
13
|
+
#
|
|
14
|
+
# The API caps the posts a project reads each month, and bills each post read, so the usage shows both what a
|
|
15
|
+
# project has spent and how much of its cap remains.
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
class ::X::PostUsage
|
|
19
|
+
include Serialization
|
|
20
|
+
include ValueEquality
|
|
21
|
+
include ValueMarshalling
|
|
22
|
+
|
|
23
|
+
# The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
|
|
24
|
+
#
|
|
25
|
+
# @!parse
|
|
26
|
+
# include X::Resources::Serialization
|
|
27
|
+
# include X::Resources::ValueEquality
|
|
28
|
+
# include X::Resources::ValueMarshalling
|
|
29
|
+
|
|
30
|
+
# The endpoint that reports the post usage of the project
|
|
31
|
+
# @api private
|
|
32
|
+
ENDPOINT = "usage/tweets"
|
|
33
|
+
private_constant :ENDPOINT
|
|
34
|
+
# Every field of the usage
|
|
35
|
+
FIELDS = %w[cap_reset_day daily_client_app_usage daily_project_usage project_cap project_id project_usage].freeze
|
|
36
|
+
|
|
37
|
+
# The raw attributes of the usage
|
|
38
|
+
# @api public
|
|
39
|
+
# @return [Hash{String => Object}] the attributes
|
|
40
|
+
# @example Get the raw attributes
|
|
41
|
+
# usage.attrs # => {"project_usage" => "1234", "project_cap" => "3000000", ...}
|
|
42
|
+
attr_reader :attrs
|
|
43
|
+
|
|
44
|
+
# Look up the current post usage of the project the client's app belongs to
|
|
45
|
+
#
|
|
46
|
+
# A project has one usage, so it is looked up by no identifier, as X::User.current looks up the one user a
|
|
47
|
+
# client signs in as. The usage endpoint takes app-only authentication, so a client that signs its requests with OAuth 1.0a
|
|
48
|
+
# looks the usage up with a copy that authenticates as the app. A client signed in with OAuth 2.0 as a user that
|
|
49
|
+
# holds no credentials of the app requests as the user, which the endpoint refuses with X::Forbidden.
|
|
50
|
+
#
|
|
51
|
+
# A response that holds no usage returns nil, as X::User.current does for a users/me that holds no user, and
|
|
52
|
+
# passes the problems it reported to the block, if there is one.
|
|
53
|
+
#
|
|
54
|
+
# @api public
|
|
55
|
+
# @param client [Object] the client used to make the request
|
|
56
|
+
# @param params [Hash] query parameters, such as days, the number of days to report, which is 7 by default
|
|
57
|
+
# @return [PostUsage, nil] the usage, or nil if the response holds none
|
|
58
|
+
# @yieldparam problem [Problem] each problem the API reported
|
|
59
|
+
# @example Look up the usage of the last 30 days
|
|
60
|
+
# X::PostUsage.current(client: client, days: 30)&.project_usage
|
|
61
|
+
# @example Log why the API returned no usage
|
|
62
|
+
# X::PostUsage.current(client: client) { |problem| warn problem.detail }
|
|
63
|
+
def self.current(client:, **params)
|
|
64
|
+
body = Utils.app_client(client).get(Utils.path(ENDPOINT, {"usage.fields" => FIELDS}.merge(params)), **Utils::JSON_CLASSES)
|
|
65
|
+
Problem.all_from(body).each { |problem| yield problem } if block_given?
|
|
66
|
+
Hash.try_convert(body.to_h["data"])&.then { |data| new(data) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Look up the current post usage of the project, which must be returned
|
|
70
|
+
#
|
|
71
|
+
# @api public
|
|
72
|
+
# @param client [Object] the client used to make the request
|
|
73
|
+
# @param params [Hash] query parameters, such as days, the number of days to report, which is 7 by default
|
|
74
|
+
# @return [PostUsage] the usage
|
|
75
|
+
# @raise [MissingResource] if the API returns no usage
|
|
76
|
+
# @example Look up the usage of the last 30 days
|
|
77
|
+
# X::PostUsage.current!(client: client, days: 30).project_usage
|
|
78
|
+
def self.current!(client:, **params)
|
|
79
|
+
problems = [] #: Array[Problem]
|
|
80
|
+
current(client:, **params) { |problem| problems << problem } || raise(MissingResource.new("#{ENDPOINT} returned no usage", problems:))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Initialize the usage from the attributes the API reported
|
|
84
|
+
#
|
|
85
|
+
# @api public
|
|
86
|
+
# @param attrs [Hash{String, Symbol => Object}] the attributes
|
|
87
|
+
# @return [PostUsage] a new usage
|
|
88
|
+
# @raise [ArgumentError] if the attributes are not a Hash
|
|
89
|
+
# @example Build a usage
|
|
90
|
+
# X::PostUsage.new({"project_usage" => "1234"})
|
|
91
|
+
def initialize(attrs)
|
|
92
|
+
@attrs = Utils.deep_freeze(Utils.attributes!(attrs))
|
|
93
|
+
freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The identifier of the project
|
|
97
|
+
#
|
|
98
|
+
# @api public
|
|
99
|
+
# @return [Integer, nil] the identifier
|
|
100
|
+
# @example Get the project identifier
|
|
101
|
+
# usage.project_id # => 1234567890
|
|
102
|
+
def project_id = integer("project_id", attrs["project_id"])
|
|
103
|
+
|
|
104
|
+
# The number of posts the project has read in the current billing cycle
|
|
105
|
+
#
|
|
106
|
+
# @api public
|
|
107
|
+
# @return [Integer, nil] the number of posts
|
|
108
|
+
# @example Get the posts read this cycle
|
|
109
|
+
# usage.project_usage # => 1234
|
|
110
|
+
def project_usage = integer("project_usage", attrs["project_usage"])
|
|
111
|
+
|
|
112
|
+
# The number of posts the project may read in a billing cycle
|
|
113
|
+
#
|
|
114
|
+
# @api public
|
|
115
|
+
# @return [Integer, nil] the cap
|
|
116
|
+
# @example Get the cap
|
|
117
|
+
# usage.project_cap # => 3000000
|
|
118
|
+
def project_cap = integer("project_cap", attrs["project_cap"])
|
|
119
|
+
|
|
120
|
+
# The day of the month the billing cycle, and so the usage, starts over
|
|
121
|
+
#
|
|
122
|
+
# It is an Integer whether the response holds it as a number or as a String, as it holds the other counts.
|
|
123
|
+
#
|
|
124
|
+
# @api public
|
|
125
|
+
# @return [Integer, nil] the day of the month
|
|
126
|
+
# @raise [InvalidAttribute] if the response holds a value that is not a number
|
|
127
|
+
# @example Get the reset day
|
|
128
|
+
# usage.cap_reset_day # => 16
|
|
129
|
+
def cap_reset_day = integer("cap_reset_day", attrs["cap_reset_day"])
|
|
130
|
+
|
|
131
|
+
# The number of posts the project read each day
|
|
132
|
+
#
|
|
133
|
+
# @api public
|
|
134
|
+
# @return [Hash{Time => Integer}] the number of posts, keyed by the start of each day
|
|
135
|
+
# @raise [InvalidAttribute] if the response holds a day without a date in ISO 8601, or a number that is not one,
|
|
136
|
+
# or holds the days as something other than a list of objects
|
|
137
|
+
# @example Get the posts read yesterday
|
|
138
|
+
# usage.daily.values.last(2).first
|
|
139
|
+
def daily = days("daily", Shape.dig("#{self.class}#daily", attrs, %w[daily_project_usage usage]))
|
|
140
|
+
|
|
141
|
+
# The number of posts each of the project's apps read each day
|
|
142
|
+
#
|
|
143
|
+
# @api public
|
|
144
|
+
# @return [Hash{Integer, nil => Hash{Time => Integer}}] the daily usage, keyed by the identifier of each app
|
|
145
|
+
# @raise [InvalidAttribute] if the response holds an app identifier that is not a number, a day without a date in
|
|
146
|
+
# ISO 8601, or a number that is not one, or holds the apps or their days as something other than a list of objects
|
|
147
|
+
# @example Total the posts each app read
|
|
148
|
+
# usage.daily_by_app.transform_values { |days| days.values.sum }
|
|
149
|
+
def daily_by_app
|
|
150
|
+
by_app = {} #: Hash[Integer?, Hash[Time, Integer]]
|
|
151
|
+
Shape.objects("#{self.class}#daily_by_app", attrs["daily_client_app_usage"]).each do |app|
|
|
152
|
+
by_app[integer("daily_by_app", app["client_app_id"])] = days("daily_by_app", app["usage"])
|
|
153
|
+
end
|
|
154
|
+
by_app.freeze
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Deconstruct the usage into what its readers read, so it matches a hash pattern
|
|
158
|
+
#
|
|
159
|
+
# Only the readers a pattern names are read, so one that names the counts of the project matches a usage whose
|
|
160
|
+
# days cannot be read.
|
|
161
|
+
#
|
|
162
|
+
# @api public
|
|
163
|
+
# @param keys [Array<Symbol>, nil] the keys the pattern asks for, or nil for every reader
|
|
164
|
+
# @return [Hash{Symbol => Object}] what the readers read
|
|
165
|
+
# @raise [InvalidAttribute] if the pattern asks for what the response holds as something that cannot be read
|
|
166
|
+
# @example Warn when the project has read nine tenths of its cap
|
|
167
|
+
# warn "near the cap" if usage in {project_usage: Integer => used, project_cap: Integer => cap} and used * 10 >= cap * 9
|
|
168
|
+
def deconstruct_keys(keys) = Utils.deconstruct(self, keys, %i[project_id project_usage project_cap cap_reset_day daily daily_by_app])
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Read daily usage entries, counting a day without a number as zero
|
|
173
|
+
# @api private
|
|
174
|
+
# @param reader [String] the name of the reader that reads them
|
|
175
|
+
# @param entries [Array<Hash>, nil] the entries, each with a date and a usage
|
|
176
|
+
# @return [Hash{Time => Integer}] the number of posts, keyed by the start of each day
|
|
177
|
+
# @raise [InvalidAttribute] if an entry has no date in ISO 8601, or a number that is not one, or the entries are
|
|
178
|
+
# not a list of objects
|
|
179
|
+
def days(reader, entries)
|
|
180
|
+
counts = {} #: Hash[Time, Integer]
|
|
181
|
+
entries = Shape.objects("#{self.class}##{reader}", entries)
|
|
182
|
+
entries.each { |entry| counts[time(reader, entry["date"])] = integer(reader, entry["usage"]) || 0 }
|
|
183
|
+
counts.freeze
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Read an identifier or a count the response holds as an Integer
|
|
187
|
+
# @api private
|
|
188
|
+
# @param reader [String] the name of the reader that reads it
|
|
189
|
+
# @param value [String, Integer, nil] the value
|
|
190
|
+
# @return [Integer, nil] the Integer, or nil if the value is missing
|
|
191
|
+
# @raise [InvalidAttribute] if the value is not a number
|
|
192
|
+
def integer(reader, value) = Utils.read("#{self.class}##{reader}", value) { Shape.integer(value) }
|
|
193
|
+
|
|
194
|
+
# Read a date the response holds as a Time, which it must hold
|
|
195
|
+
# @api private
|
|
196
|
+
# @param reader [String] the name of the reader that reads it
|
|
197
|
+
# @param value [String, nil] the date, in ISO 8601
|
|
198
|
+
# @return [Time] the time
|
|
199
|
+
# @raise [InvalidAttribute] if the value is missing, or is not ISO 8601
|
|
200
|
+
def time(reader, value) = Utils.read("#{self.class}##{reader}", value) { Time.iso8601(value.to_s) }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "media_ids"
|
|
4
|
+
require_relative "utils"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Resources
|
|
8
|
+
# Creating and deleting posts, and hiding replies, as the authenticated user
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-resources: the methods it gives Post, such as X::Post.create, are public API, but the module is only
|
|
11
|
+
# how they are shared, and which classes extend or include it can change within 1.x.
|
|
12
|
+
#
|
|
13
|
+
# @api semipublic
|
|
14
|
+
module PostWrites
|
|
15
|
+
# Create a post as the authenticated user
|
|
16
|
+
#
|
|
17
|
+
# The API bills each post created, and bills a post whose text holds a URL more than ten times as much.
|
|
18
|
+
# A post needs no text when it has something else to show, such as media.
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
# @param text [String, nil] the text of the post, or nil for a post without text, such as one of media alone
|
|
22
|
+
# @param client [Object] the client used to make the request
|
|
23
|
+
# @param reply_to [Post, String, Integer, nil] the post to reply to or its identifier
|
|
24
|
+
# @param quote [Post, String, Integer, nil] the post to quote or its identifier
|
|
25
|
+
# @param media_ids [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media, nil] the identifiers or
|
|
26
|
+
# media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or
|
|
27
|
+
# many; an empty list attaches nothing, as nil does
|
|
28
|
+
# @param community [Community, String, Integer, nil] the community to post in or its identifier
|
|
29
|
+
# @param params [Hash] additional request body fields, such as poll or reply_settings, among them reply and
|
|
30
|
+
# media, whose other fields reply_to and media_ids are merged into, each named by a String or a Symbol
|
|
31
|
+
# @return [Post] the created post, holding only its identifier and text
|
|
32
|
+
# @raise [ArgumentError] if the post has neither text nor any other field, which an empty media_ids is not
|
|
33
|
+
# @raise [MissingResource] if the API answers without the post
|
|
34
|
+
# @example Create a post
|
|
35
|
+
# X::Post.create("Hello, World!", client: client)
|
|
36
|
+
# @example Post an image without text
|
|
37
|
+
# X::Post.create(client: client, media_ids: media)
|
|
38
|
+
# @example Reply to a post with an image
|
|
39
|
+
# X::Post.create("Hello!", client: client, reply_to: post, media_ids: [media["id"]])
|
|
40
|
+
# @example Reply without the users a thread would otherwise mention
|
|
41
|
+
# X::Post.create("Hello!", client: client, reply_to: post, reply: {exclude_reply_user_ids: ["7505382"]})
|
|
42
|
+
# @example Post in a community
|
|
43
|
+
# X::Post.create("Hello, Rubyists!", client: client, community: community)
|
|
44
|
+
# @example Quote a post
|
|
45
|
+
# X::Post.create("Worth reading", client: client, quote: post)
|
|
46
|
+
def create(text = nil, client:, reply_to: nil, quote: nil, media_ids: nil, community: nil, **params)
|
|
47
|
+
params = Utils.fields(params)
|
|
48
|
+
fields = {text:, **params, **referenced(params, reply_to:, quote:, media_ids:, community:)}.compact
|
|
49
|
+
raise ArgumentError, "a post needs text, or something else to show, such as media_ids" if fields.empty?
|
|
50
|
+
|
|
51
|
+
created_from_response(client.post("tweets", fields, **Utils::JSON_CLASSES), "POST tweets", client:)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Delete a post as the authenticated user
|
|
55
|
+
#
|
|
56
|
+
# @api public
|
|
57
|
+
# @param post [Post, String, Integer] the post or its identifier
|
|
58
|
+
# @param client [Object] the client used to make the request
|
|
59
|
+
# @return [Boolean] true if the post was deleted
|
|
60
|
+
# @example Delete a post
|
|
61
|
+
# X::Post.delete("1234567890", client: client)
|
|
62
|
+
def delete(post, client:)
|
|
63
|
+
body = client.delete("tweets/#{Utils.id_of(post, Post)}", **Utils::JSON_CLASSES)
|
|
64
|
+
Utils.written(body, "deleted").eql?(true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Hide a reply to a post of the authenticated user
|
|
68
|
+
#
|
|
69
|
+
# X still shows a hidden reply, behind a notice that its author was hidden.
|
|
70
|
+
#
|
|
71
|
+
# @api public
|
|
72
|
+
# @param post [Post, String, Integer] the reply or its identifier
|
|
73
|
+
# @param client [Object] the client used to make the request
|
|
74
|
+
# @return [Boolean] true if the reply is now hidden
|
|
75
|
+
# @example Hide a reply
|
|
76
|
+
# X::Post.hide_reply("1234567890", client: client)
|
|
77
|
+
def hide_reply(post, client:)
|
|
78
|
+
change_visibility(post, true, client:).eql?(true)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Show a reply that was hidden, as the author of the post it replies to
|
|
82
|
+
#
|
|
83
|
+
# @api public
|
|
84
|
+
# @param post [Post, String, Integer] the reply or its identifier
|
|
85
|
+
# @param client [Object] the client used to make the request
|
|
86
|
+
# @return [Boolean] true if the reply is no longer hidden
|
|
87
|
+
# @example Show a hidden reply
|
|
88
|
+
# X::Post.unhide_reply("1234567890", client: client)
|
|
89
|
+
def unhide_reply(post, client:)
|
|
90
|
+
change_visibility(post, false, client:).eql?(false)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Hide or show a reply, and read whether it is hidden
|
|
96
|
+
# @api private
|
|
97
|
+
# @param post [Post, String, Integer] the reply or its identifier
|
|
98
|
+
# @param hidden [Boolean] whether to hide the reply
|
|
99
|
+
# @param client [Object] the client used to make the request
|
|
100
|
+
# @return [Boolean, nil] whether the reply is hidden, as the response reports, or nil if it does not
|
|
101
|
+
def change_visibility(post, hidden, client:)
|
|
102
|
+
Utils.written(client.put("tweets/#{Utils.id_of(post, Post)}/hidden", {hidden:}, **Utils::JSON_CLASSES), "hidden")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The fields of a new post that refer to other posts, media, or a community
|
|
106
|
+
#
|
|
107
|
+
# The API nests the post replied to within reply, and the media within media, beside other fields a caller may
|
|
108
|
+
# set, so reply_to and media_ids are merged into what the caller gave rather than replace it, and they win the
|
|
109
|
+
# one field each of them sets. An empty media_ids sets no media field, which the API would refuse.
|
|
110
|
+
#
|
|
111
|
+
# @api private
|
|
112
|
+
# @param params [Hash] the request body fields the caller gave, such as reply, media, or poll
|
|
113
|
+
# @param reply_to [Post, String, Integer, nil] the post to reply to or its identifier
|
|
114
|
+
# @param quote [Post, String, Integer, nil] the post to quote or its identifier
|
|
115
|
+
# @param media_ids [Array, #fetch, Media, String, Integer, nil] the identifiers of uploaded media, what the uploads
|
|
116
|
+
# returned, or media, one or many
|
|
117
|
+
# @param community [Community, String, Integer, nil] the community to post in or its identifier
|
|
118
|
+
# @return [Hash{Symbol => Object}] the fields, without those given nil
|
|
119
|
+
def referenced(params, reply_to:, quote:, media_ids:, community:)
|
|
120
|
+
fields = {} #: Hash[Symbol, untyped]
|
|
121
|
+
fields[:reply] = merged(params, :reply, in_reply_to_tweet_id: Utils.id_of(reply_to, Post)) unless reply_to.nil?
|
|
122
|
+
fields[:quote_tweet_id] = Utils.id_of(quote, Post) unless quote.nil?
|
|
123
|
+
ids = MediaIds.media_ids_of(media_ids)
|
|
124
|
+
fields[:media] = merged(params, :media, media_ids: ids) unless ids.empty?
|
|
125
|
+
fields[:community_id] = Utils.id_of(community, Community) unless community.nil?
|
|
126
|
+
fields
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# One nested field, with the convenience key merged over what the caller gave
|
|
130
|
+
#
|
|
131
|
+
# @api private
|
|
132
|
+
# @param params [Hash{Symbol => Object}] the request body fields the caller gave
|
|
133
|
+
# @param key [Symbol] the nested field, reply or media
|
|
134
|
+
# @param field [Hash] the one field the convenience key sets, which wins over the caller's, by a String or a Symbol
|
|
135
|
+
# @return [Hash{Symbol => Object}] the nested field
|
|
136
|
+
def merged(params, key, **field) = Utils.fields(params[key]).merge(field)
|
|
137
|
+
end
|
|
138
|
+
private_constant :PostWrites
|
|
139
|
+
end
|
|
140
|
+
end
|