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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/Copyright.txt +8 -0
  3. data/LICENSE.txt +697 -0
  4. data/README.md +101 -0
  5. data/Rakefile +52 -0
  6. data/doc/Shep/Entity/Account.html +193 -0
  7. data/doc/Shep/Entity/Context.html +165 -0
  8. data/doc/Shep/Entity/CustomEmoji.html +171 -0
  9. data/doc/Shep/Entity/MediaAttachment.html +175 -0
  10. data/doc/Shep/Entity/Notification.html +171 -0
  11. data/doc/Shep/Entity/Status.html +217 -0
  12. data/doc/Shep/Entity/StatusSource.html +167 -0
  13. data/doc/Shep/Entity/Status_Application.html +167 -0
  14. data/doc/Shep/Entity/Status_Mention.html +169 -0
  15. data/doc/Shep/Entity/Status_Tag.html +165 -0
  16. data/doc/Shep/Entity.html +1457 -0
  17. data/doc/Shep/Error/Caller.html +147 -0
  18. data/doc/Shep/Error/Http.html +329 -0
  19. data/doc/Shep/Error/Remote.html +143 -0
  20. data/doc/Shep/Error/Server.html +147 -0
  21. data/doc/Shep/Error/Type.html +233 -0
  22. data/doc/Shep/Error.html +149 -0
  23. data/doc/Shep/Session.html +4094 -0
  24. data/doc/Shep.html +128 -0
  25. data/doc/_index.html +300 -0
  26. data/doc/class_list.html +51 -0
  27. data/doc/css/common.css +1 -0
  28. data/doc/css/full_list.css +58 -0
  29. data/doc/css/style.css +497 -0
  30. data/doc/file.README.html +159 -0
  31. data/doc/file_list.html +56 -0
  32. data/doc/frames.html +17 -0
  33. data/doc/index.html +300 -0
  34. data/doc/js/app.js +314 -0
  35. data/doc/js/full_list.js +216 -0
  36. data/doc/js/jquery.js +4 -0
  37. data/doc/method_list.html +387 -0
  38. data/doc/top-level-namespace.html +110 -0
  39. data/lib/shep/entities.rb +164 -0
  40. data/lib/shep/entity_base.rb +378 -0
  41. data/lib/shep/exceptions.rb +78 -0
  42. data/lib/shep/session.rb +970 -0
  43. data/lib/shep/typeboxes.rb +180 -0
  44. data/lib/shep.rb +22 -0
  45. data/run_rake_test.example.sh +46 -0
  46. data/shep.gemspec +28 -0
  47. data/spec/data/smallimg.jpg +0 -0
  48. data/spec/data/smallish.jpg +0 -0
  49. data/spec/entity_common.rb +120 -0
  50. data/spec/entity_t1_spec.rb +168 -0
  51. data/spec/entity_t2_spec.rb +123 -0
  52. data/spec/entity_t3_spec.rb +30 -0
  53. data/spec/json_objects/account.1.json +25 -0
  54. data/spec/json_objects/account.2.json +36 -0
  55. data/spec/json_objects/status.1.json +85 -0
  56. data/spec/json_objects/status.2.json +59 -0
  57. data/spec/json_objects/status.3.json +95 -0
  58. data/spec/json_objects/status.4.json +95 -0
  59. data/spec/json_objects/status.5.json +74 -0
  60. data/spec/json_objects/status.6.json +140 -0
  61. data/spec/json_objects/status.7.json +84 -0
  62. data/spec/session_reader_1_unauth_spec.rb +366 -0
  63. data/spec/session_reader_2_auth_spec.rb +96 -0
  64. data/spec/session_writer_spec.rb +183 -0
  65. data/spec/spec_helper.rb +73 -0
  66. data/yard_helper.rb +30 -0
  67. metadata +154 -0
@@ -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