shep 0.1.0.pre.alpha0
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/Copyright.txt +8 -0
- data/LICENSE.txt +697 -0
- data/README.md +101 -0
- data/Rakefile +52 -0
- data/doc/Shep/Entity/Account.html +193 -0
- data/doc/Shep/Entity/Context.html +165 -0
- data/doc/Shep/Entity/CustomEmoji.html +171 -0
- data/doc/Shep/Entity/MediaAttachment.html +175 -0
- data/doc/Shep/Entity/Notification.html +171 -0
- data/doc/Shep/Entity/Status.html +217 -0
- data/doc/Shep/Entity/StatusSource.html +167 -0
- data/doc/Shep/Entity/Status_Application.html +167 -0
- data/doc/Shep/Entity/Status_Mention.html +169 -0
- data/doc/Shep/Entity/Status_Tag.html +165 -0
- data/doc/Shep/Entity.html +1457 -0
- data/doc/Shep/Error/Caller.html +147 -0
- data/doc/Shep/Error/Http.html +329 -0
- data/doc/Shep/Error/Remote.html +143 -0
- data/doc/Shep/Error/Server.html +147 -0
- data/doc/Shep/Error/Type.html +233 -0
- data/doc/Shep/Error.html +149 -0
- data/doc/Shep/Session.html +4094 -0
- data/doc/Shep.html +128 -0
- data/doc/_index.html +300 -0
- data/doc/class_list.html +51 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +58 -0
- data/doc/css/style.css +497 -0
- data/doc/file.README.html +159 -0
- data/doc/file_list.html +56 -0
- data/doc/frames.html +17 -0
- data/doc/index.html +300 -0
- data/doc/js/app.js +314 -0
- data/doc/js/full_list.js +216 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +387 -0
- data/doc/top-level-namespace.html +110 -0
- data/lib/shep/entities.rb +164 -0
- data/lib/shep/entity_base.rb +378 -0
- data/lib/shep/exceptions.rb +78 -0
- data/lib/shep/session.rb +970 -0
- data/lib/shep/typeboxes.rb +180 -0
- data/lib/shep.rb +22 -0
- data/run_rake_test.example.sh +46 -0
- data/shep.gemspec +28 -0
- data/spec/data/smallimg.jpg +0 -0
- data/spec/data/smallish.jpg +0 -0
- data/spec/entity_common.rb +120 -0
- data/spec/entity_t1_spec.rb +168 -0
- data/spec/entity_t2_spec.rb +123 -0
- data/spec/entity_t3_spec.rb +30 -0
- data/spec/json_objects/account.1.json +25 -0
- data/spec/json_objects/account.2.json +36 -0
- data/spec/json_objects/status.1.json +85 -0
- data/spec/json_objects/status.2.json +59 -0
- data/spec/json_objects/status.3.json +95 -0
- data/spec/json_objects/status.4.json +95 -0
- data/spec/json_objects/status.5.json +74 -0
- data/spec/json_objects/status.6.json +140 -0
- data/spec/json_objects/status.7.json +84 -0
- data/spec/session_reader_1_unauth_spec.rb +366 -0
- data/spec/session_reader_2_auth_spec.rb +96 -0
- data/spec/session_writer_spec.rb +183 -0
- data/spec/spec_helper.rb +73 -0
- data/yard_helper.rb +30 -0
- metadata +154 -0
data/lib/shep/session.rb
ADDED
@@ -0,0 +1,970 @@
|
|
1
|
+
|
2
|
+
#
|
3
|
+
# The session class. This implements all of the API calls.
|
4
|
+
#
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
|
9
|
+
module Shep
|
10
|
+
using Assert
|
11
|
+
|
12
|
+
# Represents a connection to a Mastodon (or equivalent) server.
|
13
|
+
#
|
14
|
+
# @attr_reader [Logger] logger The logger object
|
15
|
+
# @attr_reader [String] host The Server's hostname
|
16
|
+
#
|
17
|
+
# ## Conventions
|
18
|
+
#
|
19
|
+
# `fetch_*` methods retrieve a single Mastodon object, an `Entity`
|
20
|
+
# subinstance.
|
21
|
+
#
|
22
|
+
# `each_*` methods retrieve multiple objects, also `Entity`
|
23
|
+
# subinstances. If called with a block, the block is evaluated on
|
24
|
+
# each item in turn and the block's result is ignored. Otherwise,
|
25
|
+
# it returns an `Enumerator` which can be used in the usual ways.
|
26
|
+
#
|
27
|
+
# `each_*` will automatically paginate through the available items;
|
28
|
+
# for example,
|
29
|
+
#
|
30
|
+
# statuses = session.each_public_status.to_a
|
31
|
+
#
|
32
|
+
# will retrieve the server's entire public timeline and put it in an
|
33
|
+
# array. (Note: don't do this.) The `limit:` keyword option will
|
34
|
+
# set an upper limit on the number of items retrieved (but note that
|
35
|
+
# the method may retrieve more from the API endpoint).
|
36
|
+
#
|
37
|
+
# The remaining Mastodon API methods will in some way modify the
|
38
|
+
# state of the server and return an Entity subinstance on success.
|
39
|
+
#
|
40
|
+
# All API calls throw an exception on failure.
|
41
|
+
class Session
|
42
|
+
attr_reader :logger, :host
|
43
|
+
|
44
|
+
# Initialize a new {Session}.
|
45
|
+
#
|
46
|
+
# @param host [String] Hostname of the server
|
47
|
+
# @param token [String] Bearer token; optional
|
48
|
+
# @param logger [Logger] The logger or mode; optional
|
49
|
+
# @param debug_http [Boolean] Enable `Net::HTTP` debugging; insecure!
|
50
|
+
#
|
51
|
+
# Parameter `logger` may be a `Logger` object, `nil`, or a
|
52
|
+
# `Symbol` whose value is the name of one of the supported log
|
53
|
+
# levels. In the latter case, a new Logger is created and set to
|
54
|
+
# that level. If `nil` is given, a dummy `Logger` is created and
|
55
|
+
# used.
|
56
|
+
#
|
57
|
+
# If `debug_http` is true, compression is disabled and the
|
58
|
+
# transactions are sent to `STDERR` via
|
59
|
+
# `Net::HTTP.set_debug_output`. **WARNING:** this opens a serious
|
60
|
+
# security hole and should not be used in production.
|
61
|
+
def initialize(host:,
|
62
|
+
token: nil,
|
63
|
+
logger: nil,
|
64
|
+
debug_http: false)
|
65
|
+
@host = host
|
66
|
+
@token = token
|
67
|
+
@logger = init_logger(logger)
|
68
|
+
@rate_limit = Struct.new(:limit, :remaining, :reset).new
|
69
|
+
@debug_http = debug_http
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def init_logger(logger_arg)
|
75
|
+
result = nil
|
76
|
+
if %i{debug error fatal info warn}.include? logger_arg
|
77
|
+
result = Logger.new(STDOUT)
|
78
|
+
result.send((logger_arg.to_s + '!').intern)
|
79
|
+
elsif logger_arg.is_a? Logger
|
80
|
+
result = logger_arg
|
81
|
+
else
|
82
|
+
result = Logger.new(nil)
|
83
|
+
end
|
84
|
+
|
85
|
+
return result
|
86
|
+
end
|
87
|
+
|
88
|
+
|
89
|
+
public
|
90
|
+
|
91
|
+
# Return the rate limit information as of the last operation.
|
92
|
+
#
|
93
|
+
# The result is a Struct with the following fields:
|
94
|
+
#
|
95
|
+
# * limit Integer - Number of allowed requests per time period
|
96
|
+
# * remaining Integer - Number of requests you have left
|
97
|
+
# * reset Time - Future time when the limit resets
|
98
|
+
#
|
99
|
+
# Note that different types of operations have different rate
|
100
|
+
# limits. For example, most endpoints can be called up to 300
|
101
|
+
# times within 5 minutes but no more than 30 media uploads are
|
102
|
+
# allowed within a 30 minute time period.
|
103
|
+
#
|
104
|
+
# @see https://docs.joinmastodon.org/api/rate-limits/
|
105
|
+
#
|
106
|
+
# @return [Struct.new(:limit, :remaining, :reset)]
|
107
|
+
def rate_limit = @rate_limit.dup.freeze
|
108
|
+
|
109
|
+
|
110
|
+
# Return a human-readable summary of the rate limit.
|
111
|
+
#
|
112
|
+
# @return [String] `rate_limit()`'s result as nicely-formatted
|
113
|
+
# text
|
114
|
+
def rate_limit_desc
|
115
|
+
rem = (@rate_limit.remaining || '?').to_s
|
116
|
+
lim = (@rate_limit.limit || '?').to_s
|
117
|
+
reset = @rate_limit.reset ? (@rate_limit.reset - Time.now).round : '?'
|
118
|
+
|
119
|
+
return "#{rem}/#{lim}, #{reset}s"
|
120
|
+
end
|
121
|
+
|
122
|
+
|
123
|
+
#
|
124
|
+
# Account
|
125
|
+
#
|
126
|
+
|
127
|
+
# Return the Entity::Account object for the token we're using.
|
128
|
+
#
|
129
|
+
# Requires a token (obviously).
|
130
|
+
#
|
131
|
+
# @return [Entity::Account]
|
132
|
+
#
|
133
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#verify_credentials
|
134
|
+
def verify_credentials
|
135
|
+
return rest_get('accounts/verify_credentials', Entity::Account, {})
|
136
|
+
end
|
137
|
+
|
138
|
+
# Fetch user details by ID
|
139
|
+
#
|
140
|
+
# @param id [String] the ID code of the account
|
141
|
+
#
|
142
|
+
# @return [Entity::Account]
|
143
|
+
#
|
144
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#get
|
145
|
+
def fetch_account(id)
|
146
|
+
return rest_get("accounts/#{id}", Entity::Account, {})
|
147
|
+
end
|
148
|
+
|
149
|
+
# Fetch user details by username.
|
150
|
+
#
|
151
|
+
# The username must belong to a user on the current server.
|
152
|
+
#
|
153
|
+
# @param handle [String] the account's username **with** the
|
154
|
+
# leading '@' character (e.g. @benoitmandelbot)
|
155
|
+
#
|
156
|
+
# @return [Entity::Account]
|
157
|
+
#
|
158
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#get
|
159
|
+
def fetch_account_by_username(handle)
|
160
|
+
return rest_get("accounts/lookup", Entity::Account, {acct: handle})
|
161
|
+
end
|
162
|
+
|
163
|
+
# Fetch an individual notification by ID.
|
164
|
+
#
|
165
|
+
# Requires a token with sufficient permissions.
|
166
|
+
#
|
167
|
+
# @param ntfn_id [String] the notification ID
|
168
|
+
#
|
169
|
+
# @return [Entity::Notification]
|
170
|
+
#
|
171
|
+
# @see https://docs.joinmastodon.org/methods/notifications/#get-one
|
172
|
+
def fetch_notification(ntfn_id) =
|
173
|
+
rest_get("notifications/#{ntfn_id}", Entity::Notification, {})
|
174
|
+
|
175
|
+
# Fetch a single status
|
176
|
+
#
|
177
|
+
# @return [Entity::Status]
|
178
|
+
#
|
179
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#get
|
180
|
+
def fetch_status(id) = rest_get("statuses/#{id}", Entity::Status, {})
|
181
|
+
|
182
|
+
# Fetch the context (parent and child status) of status at 'id'
|
183
|
+
#
|
184
|
+
# @return [Entity::Context]
|
185
|
+
#
|
186
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#context
|
187
|
+
def fetch_context(id) = rest_get("statuses/#{id}/context", Entity::Context, {})
|
188
|
+
|
189
|
+
# Fetch the editable source of status at id.
|
190
|
+
#
|
191
|
+
# Requires token.
|
192
|
+
#
|
193
|
+
# @return [Entity::StatusSource]
|
194
|
+
#
|
195
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#source
|
196
|
+
def fetch_status_src(id) = rest_get("statuses/#{id}/source",
|
197
|
+
Entity::StatusSource, {})
|
198
|
+
|
199
|
+
# Fetch the given status and also any attached media.
|
200
|
+
#
|
201
|
+
# @param id [String] ID of the status to retrieve
|
202
|
+
#
|
203
|
+
# @param media_dir [String] Path to the download directory
|
204
|
+
#
|
205
|
+
# @param refetch [Boolean] Fetch the media even if it is already
|
206
|
+
# present
|
207
|
+
#
|
208
|
+
# Media is downloaded into the given directory unless a file with
|
209
|
+
# the expected name is already there (and `refetch` is not
|
210
|
+
# `true`).
|
211
|
+
#
|
212
|
+
# Filenames are chosen by the function; the second return value (a
|
213
|
+
# `Hash`) can be used to find them. Value order also corresponds
|
214
|
+
# to the order of the returned `Status`'s `media_attachments`
|
215
|
+
# field.
|
216
|
+
#
|
217
|
+
# Note that intermediate files unique temporary names while
|
218
|
+
# downloading is in progress. This means it is safe to set
|
219
|
+
# `refetch` to false even if a previous download attempt failed.
|
220
|
+
# However, it is would be necessary to delete the intermediate
|
221
|
+
# file, which has the suffic ".tmp".
|
222
|
+
#
|
223
|
+
# @return [Entity::Status, Hash] the Status and a Hash mapping the media
|
224
|
+
# URL to the corresponding local file.
|
225
|
+
#
|
226
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#get
|
227
|
+
def fetch_status_with_media(id, media_dir = '.', refetch: true)
|
228
|
+
status = fetch_status(id)
|
229
|
+
media = {}
|
230
|
+
|
231
|
+
status.media_attachments.each { |ma|
|
232
|
+
outfile = File.join(media_dir, File.basename(ma.url.path))
|
233
|
+
|
234
|
+
if !refetch && File.exist?(outfile)
|
235
|
+
@logger.info "Found '#{outfile}'; skipping."
|
236
|
+
success = true
|
237
|
+
else
|
238
|
+
tmp = File.join(media_dir, SecureRandom.uuid + '.tmp')
|
239
|
+
success = basic_get_binary(ma.url, tmp)
|
240
|
+
if success
|
241
|
+
FileUtils.mv(tmp, outfile)
|
242
|
+
else
|
243
|
+
FileUtils.rm(tmp, force: true)
|
244
|
+
end
|
245
|
+
end
|
246
|
+
|
247
|
+
media[ma.url.to_s] = success ? outfile : nil
|
248
|
+
}
|
249
|
+
|
250
|
+
return [status, media]
|
251
|
+
end
|
252
|
+
|
253
|
+
|
254
|
+
# Retrieve the follower list of an account.
|
255
|
+
#
|
256
|
+
# As of Mastodon 4.0, no longer requires a token.
|
257
|
+
#
|
258
|
+
# @param account_id [String] The account
|
259
|
+
# @param limit [Integer] Maximum number of items to retrieve
|
260
|
+
#
|
261
|
+
# @yield [item] Optional; applied to each item
|
262
|
+
# @yieldparam [Entity::Account]
|
263
|
+
#
|
264
|
+
# @return [Enumerator] if block is not given, otherwise self
|
265
|
+
#
|
266
|
+
#
|
267
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#followers
|
268
|
+
def each_follower(account_id,
|
269
|
+
limit: nil,
|
270
|
+
&block)
|
271
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
272
|
+
return rest_get_seq("accounts/#{account_id}/followers", Entity::Account,
|
273
|
+
query, block)
|
274
|
+
end
|
275
|
+
|
276
|
+
# Retrieve the list of accounts this account follows
|
277
|
+
#
|
278
|
+
# @param account_id [String] The account
|
279
|
+
# @param limit [Integer] Maximum number of items to retrieve
|
280
|
+
#
|
281
|
+
# @yield [item] Optional; applied to each item
|
282
|
+
# @yieldparam [Entity::Account]
|
283
|
+
#
|
284
|
+
# @return [Enumerator] if block is not given, otherwise self
|
285
|
+
#
|
286
|
+
#
|
287
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#following
|
288
|
+
def each_following(account_id,
|
289
|
+
limit: nil,
|
290
|
+
&block)
|
291
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
292
|
+
return rest_get_seq("accounts/#{account_id}/following", Entity::Account,
|
293
|
+
query, block)
|
294
|
+
end
|
295
|
+
|
296
|
+
# Retrieve the account's statuses
|
297
|
+
#
|
298
|
+
# @param account_id [String] The ID of the account
|
299
|
+
#
|
300
|
+
# @param limit [Integer] Maximum number of accounts to
|
301
|
+
# retrieve
|
302
|
+
#
|
303
|
+
# @param only_media [Boolean] If true, filter for statuses
|
304
|
+
# with media
|
305
|
+
#
|
306
|
+
# @param exclude_replies [Boolean] If true, exclude replies
|
307
|
+
#
|
308
|
+
# @param exclude_reblogs [Boolean] If true, exclude boosts
|
309
|
+
#
|
310
|
+
# @param pinned [Boolean] If true, filter for pinned
|
311
|
+
# statuses
|
312
|
+
#
|
313
|
+
# @param tagged [String] Filter for statuses containing the
|
314
|
+
# given tag
|
315
|
+
#
|
316
|
+
# @yield [item] Optional; applied to each item
|
317
|
+
# @yieldparam [Entity::Status]
|
318
|
+
#
|
319
|
+
# @return [Enumerator] if block is not given, otherwise self
|
320
|
+
#
|
321
|
+
# @see https://docs.joinmastodon.org/methods/accounts/#statuses
|
322
|
+
def each_status(account_id,
|
323
|
+
limit: nil,
|
324
|
+
only_media: false,
|
325
|
+
exclude_replies: false,
|
326
|
+
exclude_reblogs: false,
|
327
|
+
pinned: false,
|
328
|
+
tagged: nil,
|
329
|
+
&block)
|
330
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
331
|
+
rest_get_seq("accounts/#{account_id}/statuses", Entity::Status, query, block)
|
332
|
+
end
|
333
|
+
|
334
|
+
# Retrieve the instance's public timeline(s)
|
335
|
+
#
|
336
|
+
# May require a token depending on the instance's settings.
|
337
|
+
#
|
338
|
+
# @param limit [Integer] Max. items to retrieve.
|
339
|
+
#
|
340
|
+
# @param local [Boolean] Retrieve only local statuses
|
341
|
+
#
|
342
|
+
# @param remote [Boolean] Retrieve only remote statuses
|
343
|
+
#
|
344
|
+
# @param only_media [Boolean] Retrieve only statuses with media
|
345
|
+
#
|
346
|
+
#
|
347
|
+
# @yield [item] Optional; applied to each item
|
348
|
+
# @yieldparam [Entity::Status]
|
349
|
+
#
|
350
|
+
# @return [Enumerator] if block is not given, otherwise self
|
351
|
+
#
|
352
|
+
# @see https://docs.joinmastodon.org/methods/timelines/#public
|
353
|
+
def each_public_status(limit: nil,
|
354
|
+
local: false,
|
355
|
+
remote: false,
|
356
|
+
only_media: false,
|
357
|
+
&block)
|
358
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
359
|
+
rest_get_seq("timelines/public", Entity::Status, query, block)
|
360
|
+
end
|
361
|
+
|
362
|
+
# Retrieve a tag's timeline.
|
363
|
+
#
|
364
|
+
# The tag may either be a String (containing one hashtag) or an
|
365
|
+
# Array containing one or more. If more than one hashtag is
|
366
|
+
# given, all statuses containing **any** of the given hashtags are
|
367
|
+
# retrieved. (This uses the `any[]` parameter in the API.)
|
368
|
+
#
|
369
|
+
# There is currently no check for contradictory tag lists.
|
370
|
+
#
|
371
|
+
# @param hashtag_s [String, Array<String>]
|
372
|
+
# Hashtag(s) to retrieve.
|
373
|
+
#
|
374
|
+
# @param limit [Integer] maximum number of items to retrieve
|
375
|
+
#
|
376
|
+
# @param local [Boolean] retrieve only local statuses
|
377
|
+
#
|
378
|
+
# @param remote [Boolean] retrieve only remote statuses
|
379
|
+
#
|
380
|
+
# @param only_media [Boolean] retrieve only media status
|
381
|
+
#
|
382
|
+
# @param all [Array<String>] list of other tags that
|
383
|
+
# must also be present.
|
384
|
+
#
|
385
|
+
# @param none [Array<String>] list of tags that are excluded.
|
386
|
+
#
|
387
|
+
# @yield [item] block to apply to each Status; optional
|
388
|
+
# @yieldparam [Entity::Status]
|
389
|
+
#
|
390
|
+
# @return [Enumerator] if block is not given, otherwise self
|
391
|
+
#
|
392
|
+
# @see https://docs.joinmastodon.org/methods/timelines/#tag
|
393
|
+
def each_tag_status(hashtag_s,
|
394
|
+
limit: nil,
|
395
|
+
local: false,
|
396
|
+
remote: false,
|
397
|
+
only_media: false,
|
398
|
+
all: [],
|
399
|
+
none: [],
|
400
|
+
&block)
|
401
|
+
|
402
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
403
|
+
|
404
|
+
any = []
|
405
|
+
if hashtag_s.is_a?(Array)
|
406
|
+
hashtag_s = hashtag_s.dup
|
407
|
+
hashtag = hashtag_s.shift
|
408
|
+
any = hashtag_s
|
409
|
+
else
|
410
|
+
hashtag = hashtag_s
|
411
|
+
end
|
412
|
+
|
413
|
+
assert("Empty hashtag!") { hashtag && !hashtag.empty? }
|
414
|
+
|
415
|
+
query[:any] = any unless any.empty?
|
416
|
+
|
417
|
+
rest_get_seq("timelines/tag/#{hashtag}", Entity::Status, query, block)
|
418
|
+
end
|
419
|
+
|
420
|
+
|
421
|
+
# Retrieve each Entity::Status in the home timeline.
|
422
|
+
#
|
423
|
+
# Requires token.
|
424
|
+
#
|
425
|
+
# @param limit [Integer] ,
|
426
|
+
# @param local [Boolean] ,
|
427
|
+
# @param remote [Boolean] ,
|
428
|
+
# @param only_media [Boolean] ,
|
429
|
+
#
|
430
|
+
# @yield [item]
|
431
|
+
#
|
432
|
+
# @yieldparam [Entity::Status]
|
433
|
+
#
|
434
|
+
# @return [Enumerator] if block is not given, otherwise self
|
435
|
+
#
|
436
|
+
#
|
437
|
+
# @see https://docs.joinmastodon.org/methods/timelines/#home
|
438
|
+
def each_home_status(limit: nil,
|
439
|
+
local: false,
|
440
|
+
remote: false,
|
441
|
+
only_media: false,
|
442
|
+
&block)
|
443
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
444
|
+
rest_get_seq("timelines/home", Entity::Status, query, block)
|
445
|
+
end
|
446
|
+
|
447
|
+
|
448
|
+
# Retrieve each Entity::Account that boosted the given status.
|
449
|
+
#
|
450
|
+
# @param limit [Integer] Maximum number of items to retrieve
|
451
|
+
#
|
452
|
+
# @yield [item]
|
453
|
+
#
|
454
|
+
# @yieldparam [Entity::Account]
|
455
|
+
#
|
456
|
+
# @return [Enumerator] if block is not given, otherwise self
|
457
|
+
#
|
458
|
+
#
|
459
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#reblogged_by
|
460
|
+
def each_boost_acct(status_id,
|
461
|
+
limit: nil,
|
462
|
+
&block)
|
463
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
464
|
+
rest_get_seq("statuses/#{status_id}/reblogged_by",
|
465
|
+
Entity::Account, query, block)
|
466
|
+
end
|
467
|
+
|
468
|
+
# Retrieve each account that favourited the given status.
|
469
|
+
#
|
470
|
+
# @param limit [Integer] Maximum number of items to retrieve
|
471
|
+
#
|
472
|
+
# @yield [item]
|
473
|
+
#
|
474
|
+
# @yieldparam [Entity::Account]
|
475
|
+
#
|
476
|
+
# @return [Enumerator] if block is not given, otherwise self
|
477
|
+
#
|
478
|
+
#
|
479
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#favourited_by
|
480
|
+
def each_fave_acct(status_id,
|
481
|
+
limit: nil,
|
482
|
+
&block)
|
483
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
484
|
+
rest_get_seq("statuses/#{status_id}/favourited_by",
|
485
|
+
Entity::Account, query, block)
|
486
|
+
end
|
487
|
+
|
488
|
+
|
489
|
+
# Retrieve each notification.
|
490
|
+
#
|
491
|
+
# @param types [Array<String>] list of notifications types to
|
492
|
+
# enumerate; others are ignoredn
|
493
|
+
#
|
494
|
+
# @param exclude_types [Array<String>] types of notifications to exclude
|
495
|
+
#
|
496
|
+
# @param limit [Integer] Maximum number of items to retrieve
|
497
|
+
#
|
498
|
+
# @param account_id [String] Only retrieve notifications from
|
499
|
+
# the account with this ID.
|
500
|
+
#
|
501
|
+
# @yield [item] Applied to each Notification
|
502
|
+
#
|
503
|
+
# @yieldparam [Entity::Notification]
|
504
|
+
#
|
505
|
+
# @return [Enumerator] if block is not given, otherwise self
|
506
|
+
#
|
507
|
+
# @see https://docs.joinmastodon.org/methods/notifications/#get
|
508
|
+
def each_notification(types: [],
|
509
|
+
exclude_types: [],
|
510
|
+
limit: nil,
|
511
|
+
account_id: nil,
|
512
|
+
&block)
|
513
|
+
allowed_notifications = %i{mention status reblog follow follow_request
|
514
|
+
favourite poll update admin.sign_up
|
515
|
+
admin.report}
|
516
|
+
|
517
|
+
# Ensure valid filter values
|
518
|
+
types.uniq!
|
519
|
+
exclude_types.uniq!
|
520
|
+
(types + exclude_types).each{|filter|
|
521
|
+
assert("Unknown notification type: #{filter}") {
|
522
|
+
allowed_notifications.include?(filter.intern)
|
523
|
+
}
|
524
|
+
}
|
525
|
+
|
526
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
527
|
+
rest_get_seq("notifications", Entity::Notification, query, block)
|
528
|
+
end
|
529
|
+
|
530
|
+
|
531
|
+
|
532
|
+
|
533
|
+
|
534
|
+
# Post a status containing the given text at the specified
|
535
|
+
# visibility with zero or more media attachments.
|
536
|
+
#
|
537
|
+
# visibility can be one of 'public', 'private', 'unlisted' or
|
538
|
+
# 'direct'; these can be strings of symbols)
|
539
|
+
#
|
540
|
+
# media_ids is an array containing the ID strings of any media
|
541
|
+
# that may need to be attached.
|
542
|
+
#
|
543
|
+
# @param visibility [Symbol] Status visibility; one of :public,
|
544
|
+
# :public, :unlisted or :direct
|
545
|
+
#
|
546
|
+
# @param media_ids [Array<String>] List of IDs of attached media items.
|
547
|
+
#
|
548
|
+
# @param spoiler_text [String] Content warning if non-empty string.
|
549
|
+
# Also sets `sensitive` to true.
|
550
|
+
#
|
551
|
+
# @param language [String] ISO language code
|
552
|
+
#
|
553
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#create
|
554
|
+
def post_status(text,
|
555
|
+
visibility: :private,
|
556
|
+
media_ids: [],
|
557
|
+
spoiler_text: "",
|
558
|
+
language: "")
|
559
|
+
raise Error::Caller.new("Invalid visibility: #{visibility}") unless
|
560
|
+
%i{public unlisted private direct}.include? visibility.intern
|
561
|
+
|
562
|
+
query = magically_get_caller_kwargs(binding, method(__method__))
|
563
|
+
query[:status] = text
|
564
|
+
query[:sensitive] = true if
|
565
|
+
spoiler_text && !spoiler_text.empty?
|
566
|
+
|
567
|
+
# We need to convert to an array of keys and values rather than
|
568
|
+
# a hash because passing an array argument requires duplicate
|
569
|
+
# keys. This causes Net::HTTP to submit it as a multipart form,
|
570
|
+
# but we can cope.
|
571
|
+
formdata = formhash2array(query)
|
572
|
+
|
573
|
+
return rest_post("statuses", Entity::Status, formdata)
|
574
|
+
end
|
575
|
+
|
576
|
+
# Update the status with the given Id.
|
577
|
+
#
|
578
|
+
# Requires token with sufficient permission for the account that
|
579
|
+
# owns the status.
|
580
|
+
#
|
581
|
+
# Notionally, this method will change *all* of the affected status
|
582
|
+
# parts each time it's invoked, passing the default parameter if
|
583
|
+
# none is given. This is because it is unclear how the API
|
584
|
+
# handles omitted fields so we just don't do that. (You can force
|
585
|
+
# it to omit an argument by setting it to nil; this may or may not
|
586
|
+
# work for you.)
|
587
|
+
#
|
588
|
+
# @param id [String] id of the status to edit
|
589
|
+
#
|
590
|
+
# @param status [String] new status text
|
591
|
+
#
|
592
|
+
# @param media_ids [Array<String>] array of media object IDs
|
593
|
+
# to attach to this status.
|
594
|
+
#
|
595
|
+
# @param spoiler_text [String] Sets or clears the content
|
596
|
+
# warning. Non-empty value also
|
597
|
+
# sets the `sensitive` field.
|
598
|
+
#
|
599
|
+
# @param language [String] The language of the status;
|
600
|
+
# defaults to "en". (Apologies for
|
601
|
+
# the anglocentrism; this should be
|
602
|
+
# consistent.)
|
603
|
+
#
|
604
|
+
# @return [Entity::Status] the updated status
|
605
|
+
#
|
606
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#edit
|
607
|
+
def edit_status(id, status,
|
608
|
+
media_ids: [],
|
609
|
+
spoiler_text: "",
|
610
|
+
language: "en")
|
611
|
+
formhash = magically_get_caller_kwargs(binding, method(__method__),
|
612
|
+
strip_ignorables: false)
|
613
|
+
formhash[:status] = status
|
614
|
+
formhash[:sensitive] = !!spoiler_text && !spoiler_text.empty?
|
615
|
+
|
616
|
+
formdata = formhash2array(formhash)
|
617
|
+
return rest_put("statuses/#{id}", Entity::Status, formdata)
|
618
|
+
end
|
619
|
+
|
620
|
+
|
621
|
+
# Upload the media contained in the file at 'path'.
|
622
|
+
#
|
623
|
+
# @see https://docs.joinmastodon.org/methods/media/#v2
|
624
|
+
def upload_media(path,
|
625
|
+
content_type: nil,
|
626
|
+
description: nil,
|
627
|
+
focus_x: nil,
|
628
|
+
focus_y: nil)
|
629
|
+
formdata = [
|
630
|
+
['filename', File.basename(path)],
|
631
|
+
['file', File.open(path, "rb"), {content_type: content_type}],
|
632
|
+
]
|
633
|
+
|
634
|
+
formdata.push ['description', description] if description
|
635
|
+
|
636
|
+
# Focus args are more exacting so we do some checks here.
|
637
|
+
!!focus_x == !!focus_y or
|
638
|
+
raise Error::Caller.new("Args 'focus_x/y' must *both* be set or unset.")
|
639
|
+
|
640
|
+
if focus_x
|
641
|
+
raise Error::Caller.new("focus_x/y not a float between -1 and 1") unless
|
642
|
+
(focus_x.is_a?(Float) && focus_y.is_a?(Float) &&
|
643
|
+
focus_x >= -1.0 && focus_x <= 1.0 &&
|
644
|
+
focus_y >= -1.0 && focus_y <= 1.0)
|
645
|
+
|
646
|
+
formdata.push ['focus', "#{focus_x},#{focus_y}"]
|
647
|
+
end
|
648
|
+
|
649
|
+
return rest_post("media", Entity::MediaAttachment, formdata, v2: true)
|
650
|
+
end
|
651
|
+
|
652
|
+
# Delete the status at ID.
|
653
|
+
#
|
654
|
+
# @return [Entity::Status] the deleted status
|
655
|
+
#
|
656
|
+
# @see https://docs.joinmastodon.org/methods/statuses/#delete
|
657
|
+
def delete_status(id) = rest_delete("statuses/#{id}", Entity::Status)
|
658
|
+
|
659
|
+
# Dismiss the notification with the given ID.
|
660
|
+
#
|
661
|
+
# Warning: due to the complexity involved in repeatably sending a
|
662
|
+
# notification to an account, there is limited test coverage for
|
663
|
+
# this method.
|
664
|
+
#
|
665
|
+
# @see https://docs.joinmastodon.org/methods/notifications/#dismiss
|
666
|
+
def dismiss_notification(id)
|
667
|
+
url = rest_uri("notifications/#{id}/dismiss", {})
|
668
|
+
basic_rest_post_or_put(url, {})
|
669
|
+
return nil
|
670
|
+
end
|
671
|
+
|
672
|
+
private
|
673
|
+
|
674
|
+
#
|
675
|
+
# High-level REST support
|
676
|
+
#
|
677
|
+
|
678
|
+
# Extract all of the keyword arguments and their values from the
|
679
|
+
# caller's context. (The context is 'cbinding', which must be a
|
680
|
+
# call to method 'cmethod'.)
|
681
|
+
#
|
682
|
+
# The results are returned as a hash mapping keyword to value.
|
683
|
+
#
|
684
|
+
# If 'strip_ignorables' is true, all key/value pairs whose value is
|
685
|
+
# deemed to be equivalent to omitting the pair entirely are
|
686
|
+
# removed. (We mostly do this so that caller's definition can
|
687
|
+
# signal the expected type of the value, but it's sometimes the
|
688
|
+
# wrong thing.)
|
689
|
+
def magically_get_caller_kwargs(cbinding, cmethod, strip_ignorables: true)
|
690
|
+
ignorables = Set.new([0, false, "", nil, []])
|
691
|
+
|
692
|
+
kwargs = {}
|
693
|
+
for type, name in cmethod.parameters
|
694
|
+
if type == :key || type == :keyreq
|
695
|
+
|
696
|
+
arg = cbinding.local_variable_get(name)
|
697
|
+
next if strip_ignorables && ignorables.include?(arg)
|
698
|
+
|
699
|
+
kwargs[name] = arg
|
700
|
+
end
|
701
|
+
end
|
702
|
+
|
703
|
+
return kwargs
|
704
|
+
end
|
705
|
+
|
706
|
+
def rest_get(path, result_klass, query_args)
|
707
|
+
uri = rest_uri(path, query_args)
|
708
|
+
result_obj, _ = basic_rest_get_or_delete(uri)
|
709
|
+
return result_klass.from(result_obj)
|
710
|
+
end
|
711
|
+
|
712
|
+
def rest_delete(path, result_klass)
|
713
|
+
uri = rest_uri(path, {})
|
714
|
+
result_obj, _ = basic_rest_get_or_delete(uri, also_delete: true)
|
715
|
+
return result_klass.from(result_obj)
|
716
|
+
end
|
717
|
+
|
718
|
+
# We do the block+enumerator thing here as a placeholder for
|
719
|
+
# pagination.
|
720
|
+
def rest_get_seq(path, result_klass, query_args, block)
|
721
|
+
return Enumerator.new{ |y|
|
722
|
+
rest_get_seq(path, result_klass, query_args, proc{|item| y << item})
|
723
|
+
} unless block
|
724
|
+
|
725
|
+
uri = rest_uri(path, query_args)
|
726
|
+
limit = query_args[:limit]
|
727
|
+
while true
|
728
|
+
result_obj, link = basic_rest_get_or_delete(uri)
|
729
|
+
assert{result_obj.is_a? Array}
|
730
|
+
link_next = link["next"]
|
731
|
+
|
732
|
+
if limit
|
733
|
+
limit -= result_obj.size
|
734
|
+
|
735
|
+
link_next = nil if limit <= 0 # set break condition if we're done
|
736
|
+
|
737
|
+
# Discard extras
|
738
|
+
while limit < 0
|
739
|
+
result_obj.pop
|
740
|
+
limit += 1
|
741
|
+
end
|
742
|
+
end
|
743
|
+
|
744
|
+
result_obj
|
745
|
+
.map{|obj| result_klass.from(obj)}
|
746
|
+
.each(&block)
|
747
|
+
|
748
|
+
break unless link_next
|
749
|
+
uri = URI(link_next)
|
750
|
+
end
|
751
|
+
|
752
|
+
return self
|
753
|
+
end
|
754
|
+
|
755
|
+
def rest_post(path, result_klass, formdata, v2: false)
|
756
|
+
url = rest_uri(path, {}, v2: v2)
|
757
|
+
result = basic_rest_post_or_put(url, formdata)
|
758
|
+
return result_klass.from(result)
|
759
|
+
end
|
760
|
+
|
761
|
+
def rest_put(path, result_klass, formdata, v2: false)
|
762
|
+
url = rest_uri(path, {}, v2: v2)
|
763
|
+
result = basic_rest_post_or_put(url, formdata, is_put: true)
|
764
|
+
return result_klass.from(result)
|
765
|
+
end
|
766
|
+
|
767
|
+
|
768
|
+
#
|
769
|
+
# Low-level REST support
|
770
|
+
#
|
771
|
+
|
772
|
+
# Given a hash of query arguments, return an array of key-value
|
773
|
+
# pairs.
|
774
|
+
#
|
775
|
+
# This is slightly more complex than using 'to_a' because if the
|
776
|
+
# value is an array, each array element becomes the value of a
|
777
|
+
# key-value pair where the key is the array name with '[]'
|
778
|
+
# appended.
|
779
|
+
#
|
780
|
+
# E.g. `foo : [1,2]` -> `["foo[]", "1"], ["foo[]", "2"]`
|
781
|
+
#
|
782
|
+
def formhash2array(query_arg_hash)
|
783
|
+
result = query_arg_hash.to_a.reject{|key, value| value.is_a?(Array)}
|
784
|
+
|
785
|
+
query_arg_hash
|
786
|
+
.select{|key, value| value.is_a?(Array)}
|
787
|
+
.each{|key, value| value.each{|v| result.push ["#{key}[]", v.to_s] } }
|
788
|
+
|
789
|
+
# And ensure the keys and values are all strings
|
790
|
+
result.map!{|k,v| [k.to_s, v.to_s]}
|
791
|
+
|
792
|
+
return result
|
793
|
+
end
|
794
|
+
|
795
|
+
def rest_uri(path, query_args, v2: false)
|
796
|
+
args = formhash2array(query_args)
|
797
|
+
.map{|pair| pair.join("=")}
|
798
|
+
.join('&')
|
799
|
+
args = "?#{args}" unless args.empty?
|
800
|
+
|
801
|
+
# Most of the API is v1 but a few calls have the v2 prefix.
|
802
|
+
version = v2 ? "v2" : "v1"
|
803
|
+
|
804
|
+
return URI("https://#{@host}/api/#{version}/#{path}#{args}")
|
805
|
+
end
|
806
|
+
|
807
|
+
def basic_rest_get_or_delete(uri, also_delete: false)
|
808
|
+
headers = headers_for(:get)
|
809
|
+
|
810
|
+
# Do the thing.
|
811
|
+
response = http_get_or_delete(uri, headers, also_delete)
|
812
|
+
update_rate_limit(response)
|
813
|
+
|
814
|
+
result = response.body() if response.class.body_permitted?
|
815
|
+
raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
|
816
|
+
|
817
|
+
result_obj = JSON.parse(result)
|
818
|
+
|
819
|
+
if result.is_a?(Hash) && result_obj.has_key?("error")
|
820
|
+
raise Error::Server.new(result_obj["error"])
|
821
|
+
end
|
822
|
+
|
823
|
+
link = parse_link_header(response["link"])
|
824
|
+
|
825
|
+
return [result_obj, link]
|
826
|
+
rescue JSON::JSONError => e
|
827
|
+
raise Error::Remote.new("Error parsing result JSON: #{e}")
|
828
|
+
end
|
829
|
+
|
830
|
+
def http_get_or_delete(uri, headers, is_delete)
|
831
|
+
@logger.debug("#{is_delete ? "Deleting" : "Requesting"} #{uri} " +
|
832
|
+
"(token: #{!!@token})")
|
833
|
+
|
834
|
+
response = Net::HTTP.start(
|
835
|
+
uri.hostname,
|
836
|
+
uri.port,
|
837
|
+
use_ssl: uri.scheme == 'https'
|
838
|
+
) { |http|
|
839
|
+
next is_delete ?
|
840
|
+
http.delete(uri.path, headers) :
|
841
|
+
http.request_get(uri, headers)
|
842
|
+
}
|
843
|
+
|
844
|
+
@logger.debug("Response: #{response}")
|
845
|
+
return response
|
846
|
+
end
|
847
|
+
|
848
|
+
def headers_for(method)
|
849
|
+
headers = {}
|
850
|
+
headers["Authorization"] = "Bearer #{@token}" if @token
|
851
|
+
|
852
|
+
if method == :post
|
853
|
+
extras = {
|
854
|
+
"Indempotency-Key": SecureRandom.uuid,
|
855
|
+
"Content-Type": "application/json",
|
856
|
+
}
|
857
|
+
headers.update extras
|
858
|
+
end
|
859
|
+
|
860
|
+
return headers
|
861
|
+
end
|
862
|
+
|
863
|
+
def update_rate_limit(response)
|
864
|
+
@rate_limit.limit = @rate_limit.remaining = @rate_limit.reset = nil
|
865
|
+
|
866
|
+
@rate_limit.limit = response['X-RateLimit-Limit'].to_i
|
867
|
+
@rate_limit.remaining = response['X-RateLimit-Remaining'].to_i
|
868
|
+
|
869
|
+
reset = response['X-RateLimit-Reset']
|
870
|
+
@rate_limit.reset = Time.iso8601(reset) if reset
|
871
|
+
|
872
|
+
@logger.debug "Rate limit: #{rate_limit_desc}"
|
873
|
+
end
|
874
|
+
|
875
|
+
# Retrieve the resource (assumed to be binary) at 'uri' and write
|
876
|
+
# it to 'filename'. For now, returns false if the request
|
877
|
+
# returned an error code and true on success.
|
878
|
+
def basic_get_binary(uri, filename)
|
879
|
+
headers = headers_for(:get)
|
880
|
+
|
881
|
+
@logger.debug("Requesting #{uri} (token: #{!!@token})")
|
882
|
+
Net::HTTP.get_response(uri, headers) {|response|
|
883
|
+
@logger.debug("Response: #{response.code}")
|
884
|
+
|
885
|
+
update_rate_limit(response)
|
886
|
+
|
887
|
+
if response.is_a? Net::HTTPClientError
|
888
|
+
@logger.warn("Got response #{response} for #{uri}.")
|
889
|
+
return false
|
890
|
+
end
|
891
|
+
|
892
|
+
@logger.debug("Writing body to #{filename}")
|
893
|
+
File.open(filename, "wb") { |outfile|
|
894
|
+
response.read_body { |chunk|
|
895
|
+
@logger.debug(" Writing #{chunk.size} bytes...")
|
896
|
+
outfile.write(chunk)
|
897
|
+
}
|
898
|
+
}
|
899
|
+
@logger.debug("Done (#{filename})")
|
900
|
+
}
|
901
|
+
|
902
|
+
return true
|
903
|
+
end
|
904
|
+
|
905
|
+
def parse_link_header(hdr)
|
906
|
+
result = {}
|
907
|
+
return result unless hdr # could be nil
|
908
|
+
|
909
|
+
for link in hdr.split(', ')
|
910
|
+
md = link.match(/^<([^>]+)>; rel="([^"]+)"/)
|
911
|
+
assert{md}
|
912
|
+
|
913
|
+
result[ md[2] ] = md[1]
|
914
|
+
end
|
915
|
+
|
916
|
+
return result
|
917
|
+
end
|
918
|
+
|
919
|
+
|
920
|
+
def basic_rest_post_or_put(uri, formdata, is_put: false)
|
921
|
+
headers = headers_for(:post)
|
922
|
+
|
923
|
+
# Do the thing.
|
924
|
+
@logger.debug("Posting #{uri} (token: #{!!@token})")
|
925
|
+
response = http_post_form(uri, headers, formdata, is_put)
|
926
|
+
@logger.debug("Response: #{response}")
|
927
|
+
|
928
|
+
update_rate_limit(response)
|
929
|
+
|
930
|
+
result = response.body() if response.class.body_permitted?
|
931
|
+
raise Error::Http.new(response) if response.is_a? Net::HTTPClientError
|
932
|
+
|
933
|
+
result_obj = JSON.parse(result)
|
934
|
+
|
935
|
+
if result.is_a?(Hash) && result_obj.has_key?("error")
|
936
|
+
raise Error::Server.new(result_obj["error"])
|
937
|
+
end
|
938
|
+
|
939
|
+
return result_obj
|
940
|
+
rescue JSON::JSONError => e
|
941
|
+
raise Error::Remote.new("Error parsing result JSON: #{e}")
|
942
|
+
end
|
943
|
+
|
944
|
+
def http_post_form(url, headers, formdata, is_put)
|
945
|
+
request = is_put ?
|
946
|
+
Net::HTTP::Put.new(url, headers) :
|
947
|
+
Net::HTTP::Post.new(url, headers)
|
948
|
+
|
949
|
+
enctype = 'multipart/form-data' if formdata.is_a?(Array)
|
950
|
+
enctype = 'application/x-www-form-urlencoded' if
|
951
|
+
formdata.is_a?(Hash)
|
952
|
+
enctype or
|
953
|
+
raise Error::Caller.new("Unknown formdate type: #{formdata.class}")
|
954
|
+
request.set_form(formdata, enctype)
|
955
|
+
|
956
|
+
|
957
|
+
http = Net::HTTP.new(url.hostname, url.port)
|
958
|
+
http.use_ssl = (url.scheme == 'https')
|
959
|
+
|
960
|
+
if @debug_http
|
961
|
+
http.set_debug_output(STDERR)
|
962
|
+
request['Accept-Encoding'] = 'identity'
|
963
|
+
end
|
964
|
+
|
965
|
+
return http.start {|http|
|
966
|
+
http.request(request)
|
967
|
+
}
|
968
|
+
end
|
969
|
+
end
|
970
|
+
end
|