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.
Files changed (74) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +254 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +148 -0
  6. data/lib/x/resources/abstract_class.rb +29 -0
  7. data/lib/x/resources/actions/direct_messages.rb +99 -0
  8. data/lib/x/resources/actions/engagement.rb +95 -0
  9. data/lib/x/resources/actions/lists.rb +126 -0
  10. data/lib/x/resources/actions/posts.rb +77 -0
  11. data/lib/x/resources/actions/relationships.rb +91 -0
  12. data/lib/x/resources/actions.rb +22 -0
  13. data/lib/x/resources/api.rb +40 -0
  14. data/lib/x/resources/attributes.rb +203 -0
  15. data/lib/x/resources/batch.rb +43 -0
  16. data/lib/x/resources/batch_finders.rb +185 -0
  17. data/lib/x/resources/bookmark_folder.rb +23 -0
  18. data/lib/x/resources/community.rb +137 -0
  19. data/lib/x/resources/cursor.rb +493 -0
  20. data/lib/x/resources/direct_message.rb +325 -0
  21. data/lib/x/resources/direct_message_conversations.rb +147 -0
  22. data/lib/x/resources/errors.rb +104 -0
  23. data/lib/x/resources/finders.rb +255 -0
  24. data/lib/x/resources/identity.rb +65 -0
  25. data/lib/x/resources/includes.rb +216 -0
  26. data/lib/x/resources/list.rb +336 -0
  27. data/lib/x/resources/lookups/communities.rb +56 -0
  28. data/lib/x/resources/lookups/direct_messages.rb +86 -0
  29. data/lib/x/resources/lookups/lists.rb +44 -0
  30. data/lib/x/resources/lookups/media.rb +72 -0
  31. data/lib/x/resources/lookups/posts.rb +198 -0
  32. data/lib/x/resources/lookups/spaces.rb +87 -0
  33. data/lib/x/resources/lookups/trends.rb +38 -0
  34. data/lib/x/resources/lookups/users.rb +221 -0
  35. data/lib/x/resources/lookups.rb +25 -0
  36. data/lib/x/resources/marshalling.rb +93 -0
  37. data/lib/x/resources/matching_rule.rb +107 -0
  38. data/lib/x/resources/media.rb +278 -0
  39. data/lib/x/resources/media_ids.rb +74 -0
  40. data/lib/x/resources/memo.rb +54 -0
  41. data/lib/x/resources/page.rb +394 -0
  42. data/lib/x/resources/page_limit.rb +80 -0
  43. data/lib/x/resources/pages.rb +270 -0
  44. data/lib/x/resources/parallel.rb +82 -0
  45. data/lib/x/resources/personalized_trend.rb +124 -0
  46. data/lib/x/resources/place.rb +107 -0
  47. data/lib/x/resources/poll.rb +75 -0
  48. data/lib/x/resources/post.rb +615 -0
  49. data/lib/x/resources/post_collections.rb +67 -0
  50. data/lib/x/resources/post_counts.rb +215 -0
  51. data/lib/x/resources/post_search.rb +86 -0
  52. data/lib/x/resources/post_usage.rb +203 -0
  53. data/lib/x/resources/post_writes.rb +140 -0
  54. data/lib/x/resources/published_count.rb +31 -0
  55. data/lib/x/resources/references.rb +121 -0
  56. data/lib/x/resources/relation_writes.rb +54 -0
  57. data/lib/x/resources/relationships.rb +77 -0
  58. data/lib/x/resources/resource.rb +535 -0
  59. data/lib/x/resources/serialization.rb +58 -0
  60. data/lib/x/resources/shape.rb +167 -0
  61. data/lib/x/resources/space.rb +332 -0
  62. data/lib/x/resources/topic.rb +59 -0
  63. data/lib/x/resources/trend.rb +130 -0
  64. data/lib/x/resources/user.rb +502 -0
  65. data/lib/x/resources/user_collections.rb +213 -0
  66. data/lib/x/resources/user_finders.rb +282 -0
  67. data/lib/x/resources/utils.rb +358 -0
  68. data/lib/x/resources/value_equality.rb +38 -0
  69. data/lib/x/resources/value_marshalling.rb +89 -0
  70. data/lib/x/resources/version.rb +25 -0
  71. data/lib/x/resources.rb +22 -0
  72. data/sig/manifest.yaml +7 -0
  73. data/sig/x-resources.rbs +813 -0
  74. metadata +140 -0
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cursor"
4
+ require_relative "direct_message_conversations"
5
+ require_relative "finders"
6
+ require_relative "resource"
7
+
8
+ module X
9
+ module Resources
10
+ # A direct message event
11
+ # @api public
12
+ class ::X::DirectMessage < Resource
13
+ extend Finders
14
+ extend DirectMessageConversations
15
+
16
+ # The mixins of the class by their full names, which YARD needs to resolve them in a class opened under X
17
+ #
18
+ # @!parse
19
+ # extend X::Resources::Finders
20
+ # extend X::Resources::DirectMessageConversations
21
+
22
+ # The direct message event fields the object layer requests; the sender, the participants, and the posts a
23
+ # message refers to come with their expansions
24
+ #
25
+ # A minor release may add to it the fields the API adds, so that a lookup asks for them too; see
26
+ # {Resource#hydrated?} for what that means for a resource looked up with a list of fields of its own.
27
+ FIELDS = %w[attachments created_at dm_conversation_id entities event_type id text].freeze
28
+ # Every expansion available on direct message endpoints
29
+ #
30
+ # A minor release may add to it the expansions the API adds, so that a lookup asks for them too; see
31
+ # {Resource#hydrated?} for what that means for a resource looked up with a list of expansions of its own.
32
+ EXPANSIONS = %w[attachments.media_keys participant_ids referenced_posts sender_id].freeze
33
+ # Maximum number of events per page
34
+ # @api private
35
+ MAX_RESULTS = 100
36
+ private_constant :MAX_RESULTS
37
+
38
+ class << self
39
+ # The API endpoint used to look up direct message events by identifier
40
+ #
41
+ # @api private
42
+ # @return [String] the endpoint
43
+ # @example Get the endpoint
44
+ # X::DirectMessage.__send__(:endpoint) # => "dm_events"
45
+ def endpoint
46
+ "dm_events"
47
+ end
48
+
49
+ # The query parameter that selects direct message event fields
50
+ #
51
+ # @api private
52
+ # @return [String] the fields parameter
53
+ # @example Get the fields parameter
54
+ # X::DirectMessage.__send__(:fields_key) # => "dm_event.fields"
55
+ def fields_key = "dm_event.fields"
56
+
57
+ private :endpoint, :fields_key
58
+
59
+ # The default query parameters requesting every direct message field and expansion
60
+ #
61
+ # @api public
62
+ # @return [Hash{String => Array<String>}] the default query parameters
63
+ # @example Get the default parameters
64
+ # X::DirectMessage.default_params["dm_event.fields"]
65
+ def default_params
66
+ {"dm_event.fields" => FIELDS, "user.fields" => User::FIELDS, "post.fields" => Post::FIELDS,
67
+ "media.fields" => Media::FIELDS, "expansions" => EXPANSIONS}
68
+ end
69
+
70
+ # The most recent direct message events across every conversation
71
+ #
72
+ # @api public
73
+ # @param client [Object] the client used to make the requests
74
+ # @param params [Hash] query parameters merged over the default parameters
75
+ # @return [Cursor] a cursor over the events
76
+ # @example Print the most recent direct messages
77
+ # X::DirectMessage.all(client: client).first(10).each { |message| puts message.text }
78
+ def all(client:, **params)
79
+ Cursor.__send__(:build, self, "dm_events", client:, params: {max_results: MAX_RESULTS}.merge(params))
80
+ end
81
+
82
+ # The direct message events in the one-to-one conversation with a user
83
+ #
84
+ # @api public
85
+ # @param user [User, String, Integer] the other participant or their identifier
86
+ # @param client [Object] the client used to make the requests
87
+ # @param params [Hash] query parameters merged over the default parameters
88
+ # @return [Cursor] a cursor over the events
89
+ # @example Print the conversation with a user
90
+ # X::DirectMessage.with(user, client: client).each { |message| puts message.text }
91
+ def with(user, client:, **params)
92
+ path = "dm_conversations/with/#{Utils.id_of(user, User)}/dm_events"
93
+ Cursor.__send__(:build, self, path, client:, params: {max_results: MAX_RESULTS}.merge(params))
94
+ end
95
+
96
+ # Send a direct message to a user as the authenticated user
97
+ #
98
+ # @api public
99
+ # @param user [User, String, Integer] the recipient or their identifier
100
+ # @param text [String, nil] the text of the message, or nil for a message of attachments alone
101
+ # @param client [Object] the client used to make the request
102
+ # @param media_ids [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media, nil] the identifiers or
103
+ # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or
104
+ # many
105
+ # @param params [Hash] additional request body fields, such as attachments
106
+ # @return [DirectMessage] the sent message, holding only its identifiers
107
+ # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and
108
+ # attachments
109
+ # @raise [MissingResource] if the API answers without the message
110
+ # @example Send a direct message
111
+ # X::DirectMessage.create(user, "Hello!", client: client)
112
+ # @example Send an image without text
113
+ # X::DirectMessage.create(user, client: client, media_ids: media)
114
+ def create(user, text = nil, client:, media_ids: nil, **params)
115
+ path = "dm_conversations/with/#{Utils.id_of(user, User)}/messages"
116
+ sent(client.post(path, message(text, params, media_ids), **Utils::JSON_CLASSES), path, client:)
117
+ end
118
+
119
+ # Delete a direct message event as the authenticated user
120
+ #
121
+ # @api public
122
+ # @param message [DirectMessage, String, Integer] the event or its identifier
123
+ # @param client [Object] the client used to make the request
124
+ # @return [Boolean] true if the event was deleted
125
+ # @example Delete a direct message
126
+ # X::DirectMessage.delete("1234567890", client: client)
127
+ def delete(message, client:)
128
+ body = client.delete("dm_events/#{Utils.id_of(message, self)}", **Utils::JSON_CLASSES)
129
+ Utils.written(body, "deleted").eql?(true)
130
+ end
131
+ end
132
+
133
+ # @!attribute [r] text
134
+ # The text
135
+ #
136
+ # It is the text as the API sends it, which escapes &, <, and > as &amp;, &lt;, and &gt;, and it stays so
137
+ # throughout 1.x, so unescape it to display it.
138
+ #
139
+ # @api public
140
+ # @return [String, nil] the text, HTML-escaped as the API sends it
141
+ # @example Get the text
142
+ # message.text # => "Ruby &amp; Rails"
143
+ # @example Display the text
144
+ # CGI.unescapeHTML(message.text) # => "Ruby & Rails"
145
+ attribute :text
146
+
147
+ # @!attribute [r] event_type
148
+ # The event type: MessageCreate, ParticipantsJoin, or ParticipantsLeave
149
+ # @api public
150
+ # @return [String, nil] the event type
151
+ # @example Get the event type
152
+ # message.event_type
153
+ attribute :event_type
154
+
155
+ # @!attribute [r] created_at
156
+ # The time when the event occurred
157
+ # @api public
158
+ # @return [Time, nil] the event time
159
+ # @example Get the event time
160
+ # message.created_at
161
+ attribute :created_at, :time
162
+
163
+ # @!attribute [r] sender_id
164
+ # The identifier of the sender
165
+ # @api public
166
+ # @return [Integer, nil] the sender identifier
167
+ # @example Get the sender identifier
168
+ # message.sender_id
169
+ attribute :sender_id, :integer
170
+
171
+ # @!attribute [r] dm_conversation_id
172
+ # The identifier of the conversation
173
+ # @api public
174
+ # @return [String, nil] the conversation identifier: the identifiers of the two users of a one-to-one
175
+ # conversation joined with a hyphen, or the number of a group conversation
176
+ # @raise [InvalidAttribute] if the response holds an identifier that is neither
177
+ # @example Get the conversation identifier
178
+ # message.dm_conversation_id
179
+ attribute :dm_conversation_id, :conversation_id
180
+
181
+ # @!attribute [r] participant_ids
182
+ # The identifiers of the participants who joined or left
183
+ # @api public
184
+ # @return [Array<Integer>] the participant identifiers, empty if there are none
185
+ # @example Get the participant identifiers
186
+ # message.participant_ids
187
+ attribute :participant_ids, :integers
188
+
189
+ # @!attribute [r] referenced_posts
190
+ # The referenced posts with their identifiers
191
+ # @api public
192
+ # @return [Array<Hash>] the referenced posts, empty if there are none
193
+ # @example Get the referenced posts
194
+ # message.referenced_posts
195
+ attribute :referenced_posts, :objects, tweet_key: %w[referenced_tweets]
196
+ reference_keys.push(%w[referenced_posts], %w[referenced_tweets])
197
+
198
+ # @!attribute [r] attachments
199
+ # The attachment keys
200
+ # @api public
201
+ # @return [Hash, nil] the attachments
202
+ # @example Get the attachments
203
+ # message.attachments
204
+ attribute :attachments, :object
205
+
206
+ # @!attribute [r] entities
207
+ # The entities found in the text: its URLs, hashtags, mentions, and cashtags
208
+ # @api public
209
+ # @return [Hash, nil] the entities
210
+ # @example Get the URLs of a message
211
+ # message.entities&.dig("urls")
212
+ attribute :entities, :object
213
+
214
+ # @!method sender
215
+ # The sender, resolved from the includes or as a stub holding only its identifier
216
+ # @api public
217
+ # @return [User, nil] the sender
218
+ # @example Get the sender's username
219
+ # message.sender.username
220
+ reference :sender, :User, key: %w[sender_id]
221
+
222
+ # @!method participants
223
+ # The participants who joined or left, from the includes or as stubs
224
+ # @api public
225
+ # @return [Array<User>] the participants
226
+ # @example Get the participants
227
+ # message.participants
228
+ references :participants, :User, key: %w[participant_ids]
229
+
230
+ # @!method media
231
+ # The attached media, from the includes or as stubs holding only their keys
232
+ # @api public
233
+ # @return [Array<Media>] the media
234
+ # @example Get the media URLs
235
+ # message.media.map(&:url)
236
+ references :media, :Media, key: %w[attachments media_keys]
237
+
238
+ # The referenced posts, resolved from the includes or built as stubs
239
+ #
240
+ # @api public
241
+ # @return [Array<Post>] the referenced posts
242
+ # @raise [InvalidAttribute] if the response holds a referenced post that is not an object
243
+ # @example Get the referenced posts
244
+ # message.references
245
+ def references
246
+ referenced_posts.filter_map do |reference|
247
+ resolve(Post, reference["id"]) #: Post?
248
+ end.freeze
249
+ end
250
+
251
+ # Check whether a user sent this message
252
+ #
253
+ # A message that does not name its sender, as the message a new direct message returns does not, or one fetched
254
+ # with dm_event.fields that leave sender_id out, cannot say the user sent it, so it answers false; its sender_id
255
+ # is nil.
256
+ #
257
+ # @api public
258
+ # @param user [User, String, Integer] the user or their identifier
259
+ # @return [Boolean] true if the user sent the message, false if another user did, or the message does not name
260
+ # its sender
261
+ # @example Split messages into sent and received
262
+ # me = client.current_user_id
263
+ # messages.partition { |message| message.from?(me) }
264
+ def from?(user) = sender_id.to_s.eql?(Utils.id_of(user, User))
265
+
266
+ # Check whether the message belongs to a group conversation
267
+ #
268
+ # The identifier of a one-to-one conversation joins the identifiers of its two participants with a hyphen, and
269
+ # the identifier of a group conversation is a number of its own.
270
+ #
271
+ # @api public
272
+ # @return [Boolean] true if the message belongs to a group conversation, false if to a one-to-one conversation or
273
+ # the message does not say
274
+ # @raise [InvalidAttribute] if the response holds a conversation identifier that is not one
275
+ # @example Leave out the messages of group conversations
276
+ # client.direct_messages.reject(&:group?)
277
+ def group?
278
+ conversation_id = dm_conversation_id
279
+ !conversation_id.nil? && !conversation_id.include?("-")
280
+ end
281
+
282
+ # The other participant of a one-to-one conversation, as seen by a user
283
+ #
284
+ # The sender, when the user did not send the message, and otherwise the other member of the
285
+ # conversation, from the includes or as a stub holding only its identifier. A message that does not name its
286
+ # sender, as the message a new direct message returns does not, is read by its conversation alone, whose other
287
+ # member is the peer of a user who is one of its two. A message whose conversation the user is not one of the
288
+ # two members of has no peer for that user, even one another user sent, so neither has a message of a group
289
+ # conversation, whose identifier names none of its members.
290
+ #
291
+ # @api public
292
+ # @param user [User, String, Integer] the user, usually the authenticated user, or their identifier
293
+ # @return [User, nil] the other participant, or nil for a group conversation, one without another participant,
294
+ # or one the user is not a member of
295
+ # @raise [InvalidAttribute] if the response holds a conversation identifier that is not one
296
+ # @example Print the identifier of the user each message was exchanged with
297
+ # me = client.current_user_id
298
+ # client.direct_messages.reject(&:group?).each { |message| puts message.peer(me)&.id }
299
+ def peer(user)
300
+ user_id = Utils.id_of(user, User)
301
+ members = dm_conversation_id.to_s.split("-")
302
+ return unless members.empty? || members.include?(user_id)
303
+ return sender unless sender_id.nil? || from?(user_id)
304
+
305
+ resolve(User, members.find { |id| !id.eql?(user_id) }) #: User?
306
+ end
307
+
308
+ # Delete this direct message event as the authenticated user
309
+ #
310
+ # @api public
311
+ # @return [Boolean] true if the event was deleted
312
+ # @example Delete a direct message
313
+ # message.delete
314
+ def delete
315
+ self.class.delete(self, client: client!)
316
+ end
317
+
318
+ # The other names of attributes, as the aliases YARD reads them as
319
+ #
320
+ # @!parse
321
+ # alias_method :referenced_tweets, :referenced_posts
322
+ attribute_alias :referenced_tweets, :referenced_posts
323
+ end
324
+ end
325
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cursor"
4
+ require_relative "media_ids"
5
+ require_relative "shape"
6
+ require_relative "utils"
7
+
8
+ module X
9
+ module Resources
10
+ # Group conversations of direct messages: starting one, sending to one, and reading one, extended by DirectMessage
11
+ #
12
+ # Internal to x-resources: the methods it gives DirectMessage, such as X::DirectMessage.create_group, are public API,
13
+ # but the module is only how they are shared, and which classes extend or include it can change within 1.x.
14
+ #
15
+ # @api semipublic
16
+ module DirectMessageConversations
17
+ # Maximum number of events per page of a conversation
18
+ # @api private
19
+ MAX_RESULTS = 100
20
+ private_constant :MAX_RESULTS
21
+
22
+ # Start a group conversation, sending its first message as the authenticated user
23
+ #
24
+ # @api public
25
+ # @param users [Array<User, String, Integer>] the other participants or their identifiers
26
+ # @param text [String, nil] the text of the first message, or nil for a message of attachments alone
27
+ # @param client [Object] the client used to make the request
28
+ # @param media_ids [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media, nil] the identifiers or
29
+ # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or
30
+ # many
31
+ # @param params [Hash] additional fields of the message, such as attachments
32
+ # @return [DirectMessage] the sent message, holding only its identifiers, among them the new conversation's
33
+ # @raise [ArgumentError] if the message has neither text nor any other field, or has both media_ids and
34
+ # attachments
35
+ # @raise [MissingResource] if the API answers without the message
36
+ # @example Start a group conversation
37
+ # X::DirectMessage.create_group([alice, bob], "Hello, both of you!", client: client)
38
+ # @example Start a group conversation with an image
39
+ # X::DirectMessage.create_group([alice, bob], client: client, media_ids: media)
40
+ def create_group(users, text = nil, client:, media_ids: nil, **params)
41
+ body = {conversation_type: "Group", participant_ids: users.map { |user| Utils.id_of(user, User) }, message: message(text, params, media_ids)}
42
+ sent(client.post("dm_conversations", body, **Utils::JSON_CLASSES), "dm_conversations", client:)
43
+ end
44
+
45
+ # Send a direct message to a conversation as the authenticated user
46
+ #
47
+ # @api public
48
+ # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier
49
+ # @param text [String, nil] the text of the message, or nil for a message of attachments alone
50
+ # @param client [Object] the client used to make the request
51
+ # @param media_ids [Array<String, Integer, #fetch, Media>, String, Integer, #fetch, Media, nil] the identifiers or
52
+ # media keys of uploaded media to attach, what the uploads returned, or media, such as that of a post, one or
53
+ # many
54
+ # @param params [Hash] additional request body fields, such as attachments
55
+ # @return [DirectMessage] the sent message, holding only its identifiers
56
+ # @raise [ArgumentError] if the conversation identifier is not one, the message has neither text nor any other
57
+ # field, or it has both media_ids and attachments
58
+ # @raise [MissingResource] if the API answers without the message
59
+ # @example Reply to the conversation of a message
60
+ # X::DirectMessage.create_in(message, "Sounds good", client: client)
61
+ # @example Reply with an image
62
+ # X::DirectMessage.create_in(message, client: client, media_ids: media)
63
+ def create_in(conversation, text = nil, client:, media_ids: nil, **params)
64
+ path = "dm_conversations/#{conversation_id_of(conversation)}/messages"
65
+ sent(client.post(path, message(text, params, media_ids), **Utils::JSON_CLASSES), path, client:)
66
+ end
67
+
68
+ # The direct message events of a conversation, one-to-one or group
69
+ #
70
+ # @api public
71
+ # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier
72
+ # @param client [Object] the client used to make the requests
73
+ # @param params [Hash] query parameters merged over the default parameters, such as event_types
74
+ # @return [Cursor] a cursor over the events
75
+ # @raise [ArgumentError] if the conversation identifier is not one
76
+ # @example Print the conversation a message belongs to
77
+ # X::DirectMessage.in(message, client: client).each { |event| puts event.text }
78
+ def in(conversation, client:, **params)
79
+ path = "dm_conversations/#{conversation_id_of(conversation)}/dm_events"
80
+ Cursor.__send__(:build, DirectMessage, path, client:, params: {max_results: MAX_RESULTS}.merge(params))
81
+ end
82
+
83
+ private
84
+
85
+ # The identifier of a conversation, from a message of it or as given
86
+ # @api private
87
+ # @param conversation [DirectMessage, String, Integer] a message of the conversation, or the conversation's identifier
88
+ # @return [String] the conversation identifier
89
+ # @raise [ArgumentError] if the identifier is not one
90
+ def conversation_id_of(conversation)
91
+ id = case conversation
92
+ when DirectMessage then conversation.dm_conversation_id.to_s
93
+ else conversation.to_s
94
+ end
95
+ return id if id.match?(Shape::CONVERSATION_ID)
96
+
97
+ raise ArgumentError, "#{conversation.inspect} is not a conversation: pass a direct message or a conversation identifier"
98
+ end
99
+
100
+ # The fields of a message to send, which needs text or attachments
101
+ #
102
+ # The API takes media as attachments, each an object holding the identifier of one upload as a String, so
103
+ # media_ids builds them, and a caller who builds them itself passes attachments instead, by a String or a Symbol.
104
+ #
105
+ # @api private
106
+ # @param text [String, nil] the text of the message
107
+ # @param params [Hash] additional fields of the message, such as attachments
108
+ # @param media_ids [Array, #fetch, Media, String, Integer, nil] the identifiers of uploaded media to attach, what
109
+ # the uploads returned, or media, one or many; an empty list attaches nothing, as nil does
110
+ # @return [Hash{Symbol => Object}] the fields, without the text when there is none
111
+ # @raise [ArgumentError] if the message has neither text nor any other field, or attaches media by both
112
+ # media_ids and attachments
113
+ def message(text, params, media_ids)
114
+ fields = {text:, **Utils.fields(params)}.compact
115
+ attachments = MediaIds.media_ids_of(media_ids).map { |media_id| {media_id:} }
116
+ unless attachments.empty?
117
+ raise ArgumentError, "pass media_ids or attachments, not both" if fields.key?(:attachments)
118
+
119
+ fields[:attachments] = attachments
120
+ end
121
+ raise ArgumentError, "a direct message needs text, or something else to show, such as media_ids" if fields.empty?
122
+
123
+ fields
124
+ end
125
+
126
+ # The message a send created, from the identifiers the API returned
127
+ #
128
+ # It is built as the resources of any response are, so a response without the message, or without its event
129
+ # identifier, raises, as any request that creates a resource does.
130
+ #
131
+ # @api private
132
+ # @param body [Hash, nil] the response body
133
+ # @param path [String] the path the message was sent to
134
+ # @param client [Object] the client used to make the request
135
+ # @return [DirectMessage] the message
136
+ # @raise [MissingResource] if the response holds no data, or no event identifier
137
+ # @raise [InvalidAttribute] if the response holds an event identifier that is not one
138
+ def sent(body, path, client:)
139
+ body = body.to_h
140
+ data = body["data"]
141
+ data = {"id" => data["dm_event_id"], "dm_conversation_id" => data["dm_conversation_id"]} if data.is_a?(Hash)
142
+ created_from_response(body.merge("data" => data), "POST #{path}", client:)
143
+ end
144
+ end
145
+ private_constant :DirectMessageConversations
146
+ end
147
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "x/core"
4
+
5
+ module X
6
+ module Resources
7
+ # Base error class for the failures of the object layer, which every error x-resources raises of its own descends from
8
+ #
9
+ # It descends from X::Error, so that rescuing the errors of the X API catches one of them as well. The errors
10
+ # that descend from it are named directly under X, as the errors of x-core are, so that this is the one name
11
+ # under X::Resources a rescue reaches for: it catches the failure of the object layer alone.
12
+ #
13
+ # @api public
14
+ class Error < X::Error; end
15
+ end
16
+
17
+ # Raised when a resource that was asked for by identifier or name does not exist
18
+ #
19
+ # A lookup of one resource that is not there raises it whether the API answers 200 OK with no data or a 404 that
20
+ # reports the resource as not found, so code that rescues it need not know which. It is not
21
+ # the NotFound of x-core, which holds the response, and which every other 404 raises, such as one from a client
22
+ # pointed at the wrong host or API version; the NotFound of a 404 that reports the resource is the cause of this.
23
+ #
24
+ # It is raised as well when a request that creates a resource, such as X::Post.create, succeeds without returning
25
+ # it, as X::User.current! raises it when users/me returns no user, holding the problems the response reported.
26
+ #
27
+ # @api public
28
+ class MissingResource < Resources::Error
29
+ # The problems the API reported about the resource
30
+ # @api public
31
+ # @return [Array<Problem>] the problems, empty if the API reported none
32
+ # @example Read why a user was not found
33
+ # error.problems.first&.detail # => "Could not find user with username: [nobody]."
34
+ attr_reader :problems
35
+
36
+ # Initialize the error, adding the detail of the first problem to the message
37
+ #
38
+ # @api public
39
+ # @param message [String, nil] the message
40
+ # @param problems [Array<Problem>] the problems the API reported
41
+ # @return [MissingResource] a new error
42
+ # @example Raise the error
43
+ # raise X::MissingResource.new("Could not find X::User nobody", problems: problems)
44
+ def initialize(message = nil, problems: [])
45
+ explanation = problems.first&.then { |problem| problem.detail || problem.title }
46
+ super(([message, explanation].compact.join(": ") if message || explanation))
47
+ @problems = problems.dup.freeze
48
+ end
49
+ end
50
+
51
+ # Raised when a successful response holds what the object layer cannot read as what the API documents
52
+ #
53
+ # It is the base of InvalidAttribute, for one value of a response, and is raised itself for what a response says
54
+ # beside its values, such as a page that names the token of a page before it as the next, which would have the
55
+ # pages requested again for good. It is not the InvalidResponse of x-core, which a body that is not JSON raises,
56
+ # and which holds the response.
57
+ #
58
+ # @api public
59
+ # @example Rescue what the object layer cannot read of a response
60
+ # begin
61
+ # user.followers.to_a
62
+ # rescue X::UnreadableResponse => e
63
+ # logger.warn(e.message)
64
+ # end
65
+ class UnreadableResponse < Resources::Error; end
66
+
67
+ # Raised when a response holds a value that cannot be read as what the API documents it to be
68
+ #
69
+ # A timestamp that is not ISO 8601, or an identifier that is not one, is read when the attribute or the reference
70
+ # that holds it is read, and the identifier of a resource when the resource is built from the response, so that one
71
+ # value the object layer cannot read raises where it is read, as this error, which descends from X::Error, rather
72
+ # than as the ArgumentError the same value raises when a caller passes it. The cause is the error that refused it.
73
+ #
74
+ # @api public
75
+ class InvalidAttribute < UnreadableResponse; end
76
+
77
+ # Raised when a resource that holds no client is asked for what only a request can answer
78
+ #
79
+ # A resource built without a client, such as one Marshal read back, or one built with from_id and no client, holds
80
+ # its attributes, which it reads as any resource does, but cannot hydrate, refresh, page a collection, or act, since
81
+ # each of them is a request, and the client is what makes it. Build the resource with the client: of from_id, or
82
+ # look it up again with a client.
83
+ #
84
+ # @api public
85
+ # @example Hydrate a resource that has a client
86
+ # X::User.from_id(7_505_382, client: client).hydrate
87
+ class MissingClient < Resources::Error; end
88
+
89
+ # Raised when a scan or a count reads the pages its max_pages allows, and the API names a page after them
90
+ #
91
+ # A check that pages through a collection, such as List#member? or User#follows?, and a count of posts, such as
92
+ # X::Post.count_all, read as many pages as the answer takes, and the API bills each one. Given max_pages,
93
+ # each reads no more pages than that, and raises this rather than answer from the pages it read, which would be
94
+ # wrong: a member on a page it did not read, or posts counted on one, would go unseen.
95
+ #
96
+ # @api public
97
+ # @example Give up on a check that would read more than ten pages
98
+ # begin
99
+ # list.member?(user, max_pages: 10)
100
+ # rescue X::PageLimitReached
101
+ # nil
102
+ # end
103
+ class PageLimitReached < Resources::Error; end
104
+ end