@evanp/activitypub-bot 0.8.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 (67) hide show
  1. package/.github/dependabot.yml +11 -0
  2. package/.github/workflows/main-docker.yml +45 -0
  3. package/.github/workflows/tag-docker.yml +54 -0
  4. package/Dockerfile +23 -0
  5. package/LICENSE +661 -0
  6. package/README.md +82 -0
  7. package/bots/index.js +7 -0
  8. package/docs/activitypub.bot.drawio +110 -0
  9. package/index.js +23 -0
  10. package/lib/activitydistributor.js +263 -0
  11. package/lib/activityhandler.js +999 -0
  12. package/lib/activitypubclient.js +126 -0
  13. package/lib/activitystreams.js +41 -0
  14. package/lib/actorstorage.js +300 -0
  15. package/lib/app.js +173 -0
  16. package/lib/authorizer.js +133 -0
  17. package/lib/bot.js +44 -0
  18. package/lib/botcontext.js +520 -0
  19. package/lib/botdatastorage.js +87 -0
  20. package/lib/bots/donothing.js +11 -0
  21. package/lib/bots/ok.js +41 -0
  22. package/lib/digester.js +23 -0
  23. package/lib/httpsignature.js +195 -0
  24. package/lib/httpsignatureauthenticator.js +81 -0
  25. package/lib/keystorage.js +113 -0
  26. package/lib/microsyntax.js +140 -0
  27. package/lib/objectcache.js +48 -0
  28. package/lib/objectstorage.js +319 -0
  29. package/lib/remotekeystorage.js +116 -0
  30. package/lib/routes/collection.js +92 -0
  31. package/lib/routes/health.js +24 -0
  32. package/lib/routes/inbox.js +83 -0
  33. package/lib/routes/object.js +69 -0
  34. package/lib/routes/server.js +47 -0
  35. package/lib/routes/user.js +63 -0
  36. package/lib/routes/webfinger.js +36 -0
  37. package/lib/urlformatter.js +97 -0
  38. package/package.json +51 -0
  39. package/tests/activitydistributor.test.js +606 -0
  40. package/tests/activityhandler.test.js +2185 -0
  41. package/tests/activitypubclient.test.js +225 -0
  42. package/tests/actorstorage.test.js +261 -0
  43. package/tests/app.test.js +17 -0
  44. package/tests/authorizer.test.js +306 -0
  45. package/tests/bot.donothing.test.js +30 -0
  46. package/tests/bot.ok.test.js +101 -0
  47. package/tests/botcontext.test.js +674 -0
  48. package/tests/botdatastorage.test.js +87 -0
  49. package/tests/digester.test.js +56 -0
  50. package/tests/fixtures/bots.js +15 -0
  51. package/tests/httpsignature.test.js +200 -0
  52. package/tests/httpsignatureauthenticator.test.js +463 -0
  53. package/tests/keystorage.test.js +89 -0
  54. package/tests/microsyntax.test.js +122 -0
  55. package/tests/objectcache.test.js +133 -0
  56. package/tests/objectstorage.test.js +148 -0
  57. package/tests/remotekeystorage.test.js +76 -0
  58. package/tests/routes.actor.test.js +207 -0
  59. package/tests/routes.collection.test.js +434 -0
  60. package/tests/routes.health.test.js +41 -0
  61. package/tests/routes.inbox.test.js +135 -0
  62. package/tests/routes.object.test.js +519 -0
  63. package/tests/routes.server.test.js +69 -0
  64. package/tests/routes.webfinger.test.js +41 -0
  65. package/tests/urlformatter.test.js +164 -0
  66. package/tests/utils/digest.js +7 -0
  67. package/tests/utils/nock.js +276 -0
@@ -0,0 +1,999 @@
1
+ import as2 from './activitystreams.js'
2
+ import { nanoid } from 'nanoid'
3
+
4
+ const AS2 = 'https://www.w3.org/ns/activitystreams#'
5
+
6
+ const THREAD_PROP = 'https://purl.archive.org/socialweb/thread#thread'
7
+
8
+ export class ActivityHandler {
9
+ #actorStorage = null
10
+ #objectStorage = null
11
+ #distributor = null
12
+ #cache = null
13
+ #formatter = null
14
+ #authz = null
15
+ #logger = null
16
+ #client = null
17
+ constructor (
18
+ actorStorage,
19
+ objectStorage,
20
+ distributor,
21
+ formatter,
22
+ cache,
23
+ authz,
24
+ logger,
25
+ client
26
+ ) {
27
+ this.#actorStorage = actorStorage
28
+ this.#objectStorage = objectStorage
29
+ this.#distributor = distributor
30
+ this.#formatter = formatter
31
+ this.#cache = cache
32
+ this.#authz = authz
33
+ this.#logger = logger.child({ class: this.constructor.name })
34
+ this.#client = client
35
+ }
36
+
37
+ async handleActivity (bot, activity) {
38
+ switch (activity.type) {
39
+ case AS2 + 'Create': await this.#handleCreate(bot, activity); break
40
+ case AS2 + 'Update': await this.#handleUpdate(bot, activity); break
41
+ case AS2 + 'Delete': await this.#handleDelete(bot, activity); break
42
+ case AS2 + 'Add': await this.#handleAdd(bot, activity); break
43
+ case AS2 + 'Remove': await this.#handleRemove(bot, activity); break
44
+ case AS2 + 'Follow': await this.#handleFollow(bot, activity); break
45
+ case AS2 + 'Accept': await this.#handleAccept(bot, activity); break
46
+ case AS2 + 'Reject': await this.#handleReject(bot, activity); break
47
+ case AS2 + 'Like': await this.#handleLike(bot, activity); break
48
+ case AS2 + 'Announce': await this.#handleAnnounce(bot, activity); break
49
+ case AS2 + 'Undo': await this.#handleUndo(bot, activity); break
50
+ case AS2 + 'Block': await this.#handleBlock(bot, activity); break
51
+ case AS2 + 'Flag': await this.#handleFlag(bot, activity); break
52
+ default:
53
+ this.#logger.warn(`Unhandled activity type: ${activity.type}`)
54
+ }
55
+ }
56
+
57
+ async #handleCreate (bot, activity) {
58
+ const actor = this.#getActor(activity)
59
+ if (!actor) {
60
+ this.#logger.warn(
61
+ 'Create activity has no actor',
62
+ { activity: activity.id }
63
+ )
64
+ return
65
+ }
66
+ const object = this.#getObject(activity)
67
+ if (!object) {
68
+ this.#logger.warn(
69
+ 'Create activity has no object',
70
+ { activity: activity.id }
71
+ )
72
+ return
73
+ }
74
+ if (await this.#authz.sameOrigin(activity, object)) {
75
+ await this.#cache.save(object)
76
+ } else {
77
+ await this.#cache.saveReceived(object)
78
+ }
79
+ await this.#handleCreateReplies(bot, activity, actor, object)
80
+ await this.#handleCreateThread(bot, activity, actor, object)
81
+ if (this.#isMention(bot, object)) {
82
+ await bot.onMention(object, activity)
83
+ }
84
+ }
85
+
86
+ async #handleCreateReplies (bot, activity, actor, object) {
87
+ const inReplyTo = object.inReplyTo?.first
88
+ if (
89
+ inReplyTo &&
90
+ this.#formatter.isLocal(inReplyTo.id)
91
+ ) {
92
+ let original = null
93
+ try {
94
+ original = await this.#objectStorage.read(inReplyTo.id)
95
+ } catch (err) {
96
+ this.#logger.warn(
97
+ 'Create activity references not found original object',
98
+ { activity: activity.id, original: inReplyTo.id }
99
+ )
100
+ return
101
+ }
102
+ if (this.#authz.isOwner(await this.#botActor(bot), original)) {
103
+ if (!await this.#authz.canRead(actor, original)) {
104
+ this.#logger.warn(
105
+ 'Create activity references inaccessible original object',
106
+ { activity: activity.id, original: original.id }
107
+ )
108
+ return
109
+ }
110
+ if (await this.#objectStorage.isInCollection(original.id, 'replies', object)) {
111
+ this.#logger.warn(
112
+ 'Create activity object already in replies collection',
113
+ {
114
+ activity: activity.id,
115
+ object: object.id,
116
+ original: original.id
117
+ }
118
+ )
119
+ return
120
+ }
121
+ await this.#objectStorage.addToCollection(
122
+ original.id,
123
+ 'replies',
124
+ object
125
+ )
126
+ const recipients = this.#getRecipients(original)
127
+ this.#addRecipient(recipients, actor, 'to')
128
+ await this.#doActivity(bot, await as2.import({
129
+ type: 'Add',
130
+ id: this.#formatter.format({
131
+ username: bot.username,
132
+ type: 'add',
133
+ nanoid: nanoid()
134
+ }),
135
+ actor: original.actor,
136
+ object,
137
+ target: original.replies,
138
+ ...recipients
139
+ }))
140
+ }
141
+ }
142
+ }
143
+
144
+ async #handleCreateThread (bot, activity, actor, object) {
145
+ const thread = await this.#objectThread(object)
146
+ if (!thread) {
147
+ this.#logger.warn(
148
+ 'Object has no thread',
149
+ { object: object.id }
150
+ )
151
+ return
152
+ }
153
+ if (!this.#formatter.isLocal(thread.id)) {
154
+ this.#logger.warn(
155
+ 'Not a local thread',
156
+ { thread: thread.id }
157
+ )
158
+ return
159
+ }
160
+ const root = await this.#threadRoot(thread)
161
+ if (root && this.#formatter.isLocal(root.id)) {
162
+ if (this.#authz.isOwner(await this.#botActor(bot), root)) {
163
+ if (!await this.#authz.canRead(actor, root)) {
164
+ this.#logger.warn(
165
+ 'Create activity references inaccessible root object',
166
+ { activity: activity.id, root: root.id }
167
+ )
168
+ return
169
+ }
170
+ if (await this.#objectStorage.isInCollection(root.id, 'thread', object)) {
171
+ this.#logger.warn(
172
+ 'Create activity object already in replies collection',
173
+ {
174
+ activity: activity.id,
175
+ object: object.id,
176
+ root: root.id
177
+ }
178
+ )
179
+ return
180
+ }
181
+ await this.#objectStorage.addToCollection(
182
+ root.id,
183
+ 'thread',
184
+ object
185
+ )
186
+ const recipients = this.#getRecipients(root)
187
+ this.#addRecipient(recipients, actor, 'to')
188
+ await this.#doActivity(bot, await as2.import({
189
+ type: 'Add',
190
+ id: this.#formatter.format({
191
+ username: bot.username,
192
+ type: 'add',
193
+ nanoid: nanoid()
194
+ }),
195
+ actor: this.#botActor(bot),
196
+ object,
197
+ target: root.thread,
198
+ ...recipients
199
+ }))
200
+ }
201
+ }
202
+ }
203
+
204
+ async #handleUpdate (bot, activity) {
205
+ const object = this.#getObject(activity)
206
+ if (await this.#authz.sameOrigin(activity, object)) {
207
+ await this.#cache.save(object)
208
+ } else {
209
+ await this.#cache.saveReceived(object)
210
+ }
211
+ }
212
+
213
+ async #handleDelete (bot, activity) {
214
+ const object = this.#getObject(activity)
215
+ await this.#cache.clear(object)
216
+ }
217
+
218
+ async #handleAdd (bot, activity) {
219
+ const actor = this.#getActor(activity)
220
+ const target = this.#getTarget(activity)
221
+ const object = this.#getObject(activity)
222
+ if (await this.#authz.sameOrigin(actor, object)) {
223
+ await this.#cache.save(object)
224
+ } else {
225
+ await this.#cache.saveReceived(object)
226
+ }
227
+ if (await this.#authz.sameOrigin(actor, target)) {
228
+ await this.#cache.save(target)
229
+ await this.#cache.saveMembership(target, object)
230
+ } else {
231
+ await this.#cache.saveReceived(target)
232
+ await this.#cache.saveMembershipReceived(target, object)
233
+ }
234
+ }
235
+
236
+ async #handleRemove (bot, activity) {
237
+ const actor = this.#getActor(activity)
238
+ const target = this.#getTarget(activity)
239
+ const object = this.#getObject(activity)
240
+ if (await this.#authz.sameOrigin(actor, object)) {
241
+ await this.#cache.save(object)
242
+ } else {
243
+ await this.#cache.saveReceived(object)
244
+ }
245
+ if (await this.#authz.sameOrigin(actor, target)) {
246
+ await this.#cache.save(target)
247
+ await this.#cache.saveMembership(target, object, false)
248
+ } else {
249
+ await this.#cache.saveReceived(target)
250
+ await this.#cache.saveMembershipReceived(target, object, false)
251
+ }
252
+ }
253
+
254
+ async #handleFollow (bot, activity) {
255
+ const actor = this.#getActor(activity)
256
+ const object = this.#getObject(activity)
257
+ if (object.id !== this.#botId(bot)) {
258
+ this.#logger.warn({
259
+ msg: 'Follow activity object is not the bot',
260
+ activity: activity.id,
261
+ object: object.id
262
+ })
263
+ return
264
+ }
265
+ if (await this.#actorStorage.isInCollection(bot.username, 'blocked', actor)) {
266
+ this.#logger.warn({
267
+ msg: 'Follow activity from blocked actor',
268
+ activity: activity.id,
269
+ actor: actor.id
270
+ })
271
+ return
272
+ }
273
+ if (await this.#actorStorage.isInCollection(bot.username, 'followers', actor)) {
274
+ this.#logger.warn({
275
+ msg: 'Duplicate follow activity',
276
+ activity: activity.id,
277
+ actor: actor.id
278
+ })
279
+ return
280
+ }
281
+ this.#logger.info({
282
+ msg: 'Adding follower',
283
+ actor: actor.id
284
+ })
285
+ await this.#actorStorage.addToCollection(bot.username, 'followers', actor)
286
+ this.#logger.info(
287
+ 'Sending accept',
288
+ { actor: actor.id }
289
+ )
290
+ const addActivityId = this.#formatter.format({
291
+ username: bot.username,
292
+ type: 'add',
293
+ nanoid: nanoid()
294
+ })
295
+ await this.#doActivity(bot, await as2.import({
296
+ id: addActivityId,
297
+ type: 'Add',
298
+ actor: this.#formatter.format({ username: bot.username }),
299
+ object: actor,
300
+ target: this.#formatter.format({
301
+ username: bot.username,
302
+ collection: 'followers'
303
+ }),
304
+ to: ['as:Public', actor.id]
305
+ }))
306
+ await this.#doActivity(bot, await as2.import({
307
+ id: this.#formatter.format({
308
+ username: bot.username,
309
+ type: 'accept',
310
+ nanoid: nanoid()
311
+ }),
312
+ type: 'Accept',
313
+ actor: this.#formatter.format({ username: bot.username }),
314
+ object: activity,
315
+ to: actor
316
+ }))
317
+ }
318
+
319
+ async #handleAccept (bot, activity) {
320
+ let objectActivity = this.#getObject(activity)
321
+ if (!this.#formatter.isLocal(objectActivity.id)) {
322
+ this.#logger.warn({ msg: 'Accept activity for a non-local activity' })
323
+ return
324
+ }
325
+ try {
326
+ objectActivity = await this.#objectStorage.read(objectActivity.id)
327
+ } catch (err) {
328
+ this.#logger.warn({ msg: 'Accept activity object not found' })
329
+ return
330
+ }
331
+ switch (objectActivity.type) {
332
+ case AS2 + 'Follow':
333
+ await this.#handleAcceptFollow(bot, activity, objectActivity)
334
+ break
335
+ default:
336
+ console.log('Unhandled accept', objectActivity.type)
337
+ break
338
+ }
339
+ }
340
+
341
+ async #handleAcceptFollow (bot, activity, followActivity) {
342
+ const actor = this.#getActor(activity)
343
+ if (
344
+ !(await this.#actorStorage.isInCollection(
345
+ bot.username,
346
+ 'pendingFollowing',
347
+ followActivity
348
+ ))
349
+ ) {
350
+ this.#logger.warn({ msg: 'Accept activity object not found' })
351
+ return
352
+ }
353
+ if (await this.#actorStorage.isInCollection(bot.username, 'following', actor)) {
354
+ this.#logger.warn({ msg: 'Already following' })
355
+ return
356
+ }
357
+ if (await this.#actorStorage.isInCollection(bot.username, 'blocked', actor)) {
358
+ this.#logger.warn({ msg: 'blocked' })
359
+ return
360
+ }
361
+ const object = this.#getObject(followActivity)
362
+ if (object.id !== actor.id) {
363
+ this.#logger.warn({ msg: 'Object does not match actor' })
364
+ return
365
+ }
366
+ this.#logger.info({ msg: 'Adding to following' })
367
+ await this.#actorStorage.addToCollection(bot.username, 'following', actor)
368
+ await this.#actorStorage.removeFromCollection(
369
+ bot.username,
370
+ 'pendingFollowing',
371
+ followActivity
372
+ )
373
+ await this.#doActivity(bot, await as2.import({
374
+ id: this.#formatter.format({
375
+ username: bot.username,
376
+ type: 'add',
377
+ nanoid: nanoid()
378
+ }),
379
+ type: 'Add',
380
+ actor: this.#formatter.format({ username: bot.username }),
381
+ object: actor,
382
+ target: this.#formatter.format({
383
+ username: bot.username,
384
+ collection: 'following'
385
+ }),
386
+ to: ['as:Public', actor.id]
387
+ }))
388
+ }
389
+
390
+ async #handleReject (bot, activity) {
391
+ let objectActivity = this.#getObject(activity)
392
+ if (!this.#formatter.isLocal(objectActivity.id)) {
393
+ this.#logger.warn({ msg: 'Reject activity for a non-local activity' })
394
+ return
395
+ }
396
+ try {
397
+ objectActivity = await this.#objectStorage.read(objectActivity.id)
398
+ } catch (err) {
399
+ this.#logger.warn({ msg: 'Reject activity object not found' })
400
+ return
401
+ }
402
+ switch (objectActivity.type) {
403
+ case AS2 + 'Follow':
404
+ await this.#handleRejectFollow(bot, activity, objectActivity)
405
+ break
406
+ default:
407
+ this.#logger.warn({ msg: 'Unhandled reject' })
408
+ break
409
+ }
410
+ }
411
+
412
+ async #handleRejectFollow (bot, activity, followActivity) {
413
+ const actor = this.#getActor(activity)
414
+ if (
415
+ !(await this.#actorStorage.isInCollection(
416
+ bot.username,
417
+ 'pendingFollowing',
418
+ followActivity
419
+ ))
420
+ ) {
421
+ this.#logger.warn({ msg: 'Reject activity object not found' })
422
+ return
423
+ }
424
+ if (await this.#actorStorage.isInCollection(bot.username, 'following', actor)) {
425
+ this.#logger.warn({ msg: 'Already following' })
426
+ return
427
+ }
428
+ if (await this.#actorStorage.isInCollection(bot.username, 'blocked', actor)) {
429
+ this.#logger.warn({ msg: 'blocked' })
430
+ return
431
+ }
432
+ const object = this.#getObject(followActivity)
433
+ if (object.id !== actor.id) {
434
+ this.#logger.warn({ msg: 'Object does not match actor' })
435
+ return
436
+ }
437
+ this.#logger.info({ msg: 'Removing from pending' })
438
+ await this.#actorStorage.removeFromCollection(
439
+ bot.username,
440
+ 'pendingFollowing',
441
+ followActivity
442
+ )
443
+ }
444
+
445
+ async #handleLike (bot, activity) {
446
+ const actor = this.#getActor(activity)
447
+ let object = this.#getObject(activity)
448
+ if (!this.#formatter.isLocal(object.id)) {
449
+ this.#logger.warn({
450
+ msg: 'Like activity object is not local',
451
+ activity: activity.id,
452
+ object: object.id
453
+ })
454
+ return
455
+ }
456
+ try {
457
+ object = await this.#objectStorage.read(object.id)
458
+ } catch (err) {
459
+ this.#logger.warn({
460
+ msg: 'Like activity object not found',
461
+ activity: activity.id,
462
+ object: object.id
463
+ })
464
+ return
465
+ }
466
+ if (!(await this.#authz.canRead(actor, object))) {
467
+ this.#logger.warn({
468
+ msg: 'Like activity object is not readable',
469
+ activity: activity.id,
470
+ object: object.id
471
+ })
472
+ return
473
+ }
474
+ const owner = this.#getOwner(object)
475
+ if (!owner || owner.id !== this.#botId(bot)) {
476
+ this.#logger.warn({
477
+ msg: 'Like activity object is not owned by bot',
478
+ activity: activity.id,
479
+ object: object.id
480
+ })
481
+ return
482
+ }
483
+ if (await this.#objectStorage.isInCollection(object.id, 'likes', activity)) {
484
+ this.#logger.warn({
485
+ msg: 'Like activity already in likes collection',
486
+ activity: activity.id,
487
+ object: object.id
488
+ })
489
+ return
490
+ }
491
+ if (await this.#objectStorage.isInCollection(object.id, 'likers', actor)) {
492
+ this.#logger.warn({
493
+ msg: 'Actor already in likers collection',
494
+ activity: activity.id,
495
+ actor: actor.id,
496
+ object: object.id
497
+ })
498
+ return
499
+ }
500
+ await this.#objectStorage.addToCollection(object.id, 'likes', activity)
501
+ await this.#objectStorage.addToCollection(object.id, 'likers', actor)
502
+ const recipients = this.#getRecipients(object)
503
+ this.#addRecipient(recipients, actor, 'to')
504
+ await this.#doActivity(bot, await as2.import({
505
+ type: 'Add',
506
+ id: this.#formatter.format({
507
+ username: bot.username,
508
+ type: 'add',
509
+ nanoid: nanoid()
510
+ }),
511
+ actor: this.#botId(bot),
512
+ object: activity,
513
+ target: this.#formatter.format({
514
+ username: bot.username,
515
+ collection: 'likes'
516
+ }),
517
+ ...recipients
518
+ }))
519
+ }
520
+
521
+ async #handleAnnounce (bot, activity) {
522
+ const actor = this.#getActor(activity)
523
+ let object = this.#getObject(activity)
524
+ if (!this.#formatter.isLocal(object.id)) {
525
+ this.#logger.warn({
526
+ msg: 'Announce activity object is not local',
527
+ activity: activity.id,
528
+ object: object.id
529
+ })
530
+ return
531
+ }
532
+ try {
533
+ object = await this.#objectStorage.read(object.id)
534
+ } catch (err) {
535
+ this.#logger.warn({
536
+ msg: 'Announce activity object not found',
537
+ activity: activity.id,
538
+ object: object.id
539
+ })
540
+ return
541
+ }
542
+ const owner = this.#getOwner(object)
543
+ if (!owner || owner.id !== this.#botId(bot)) {
544
+ this.#logger.warn({
545
+ msg: 'Announce activity object is not owned by bot',
546
+ activity: activity.id,
547
+ object: object.id
548
+ })
549
+ return
550
+ }
551
+ if (!(await this.#authz.canRead(actor, object))) {
552
+ this.#logger.warn({
553
+ msg: 'Announce activity object is not readable',
554
+ activity: activity.id,
555
+ object: object.id
556
+ })
557
+ return
558
+ }
559
+ if (await this.#objectStorage.isInCollection(object.id, 'shares', activity)) {
560
+ this.#logger.warn({
561
+ msg: 'Announce activity already in shares collection',
562
+ activity: activity.id,
563
+ object: object.id
564
+ })
565
+ return
566
+ }
567
+ if (await this.#objectStorage.isInCollection(object.id, 'sharers', actor)) {
568
+ this.#logger.warn({
569
+ msg: 'Actor already in sharers collection',
570
+ activity: activity.id,
571
+ actor: actor.id,
572
+ object: object.id
573
+ })
574
+ return
575
+ }
576
+ await this.#objectStorage.addToCollection(object.id, 'shares', activity)
577
+ await this.#objectStorage.addToCollection(object.id, 'sharers', actor)
578
+ const recipients = this.#getRecipients(object)
579
+ this.#addRecipient(recipients, actor, 'to')
580
+ await this.#doActivity(bot, await as2.import({
581
+ type: 'Add',
582
+ id: this.#formatter.format({
583
+ username: bot.username,
584
+ type: 'add',
585
+ nanoid: nanoid()
586
+ }),
587
+ actor: this.#botId(bot),
588
+ object: activity,
589
+ target: this.#formatter.format({
590
+ username: bot.username,
591
+ collection: 'shares'
592
+ }),
593
+ ...recipients
594
+ }))
595
+ }
596
+
597
+ async #handleBlock (bot, activity) {
598
+ const actor = this.#getActor(activity)
599
+ const object = this.#getObject(activity)
600
+ if (object.id === this.#botId(bot)) {
601
+ // These skip if not found
602
+ await this.#actorStorage.removeFromCollection(
603
+ bot.username,
604
+ 'followers',
605
+ actor
606
+ )
607
+ await this.#actorStorage.removeFromCollection(
608
+ bot.username,
609
+ 'following',
610
+ actor
611
+ )
612
+ await this.#actorStorage.removeFromCollection(
613
+ bot.username,
614
+ 'pendingFollowing',
615
+ actor
616
+ )
617
+ await this.#actorStorage.removeFromCollection(
618
+ bot.username,
619
+ 'pendingFollowers',
620
+ actor
621
+ )
622
+ }
623
+ }
624
+
625
+ async #handleFlag (bot, activity) {
626
+ const actor = this.#getActor(activity)
627
+ const object = this.#getObject(activity)
628
+ this.#logger.warn(`Actor ${actor.id} flagged object ${object.id} for review.`)
629
+ }
630
+
631
+ async #handleUndo (bot, undoActivity) {
632
+ const undoActor = this.#getActor(undoActivity)
633
+ let activity = await this.#getObject(undoActivity)
634
+ if (!activity) {
635
+ this.#logger.warn({
636
+ msg: 'Undo activity has no object',
637
+ activity: undoActivity.id
638
+ })
639
+ return
640
+ }
641
+ activity = await this.#ensureProps(bot, undoActivity, activity, ['type'])
642
+ this.#logger.debug({ activityType: activity.type })
643
+ if (await this.#authz.sameOrigin(undoActivity, activity)) {
644
+ this.#logger.info({
645
+ msg: 'Assuming undo activity can undo an activity with same origin',
646
+ undoActivity: undoActivity.id,
647
+ activity: activity.id
648
+ })
649
+ } else {
650
+ activity = await this.#ensureProps(bot, undoActivity, activity, ['actor'])
651
+ const activityActor = this.#getActor(activity)
652
+ if (undoActor.id !== activityActor.id) {
653
+ this.#logger.warn({
654
+ msg: 'Undo activity actor does not match object activity actor',
655
+ activity: undoActivity.id,
656
+ object: activity.id
657
+ })
658
+ return
659
+ }
660
+ }
661
+ switch (activity.type) {
662
+ case AS2 + 'Like':
663
+ await this.#handleUndoLike(bot, undoActivity, activity)
664
+ break
665
+ case AS2 + 'Announce':
666
+ await this.#handleUndoAnnounce(bot, undoActivity, activity)
667
+ break
668
+ case AS2 + 'Block':
669
+ await this.#handleUndoBlock(bot, undoActivity, activity)
670
+ break
671
+ case AS2 + 'Follow':
672
+ await this.#handleUndoFollow(bot, undoActivity, activity)
673
+ break
674
+ default:
675
+ this.#logger.warn({
676
+ msg: 'Unhandled undo',
677
+ undoActivity: undoActivity.id,
678
+ activity: activity.id,
679
+ type: activity.type
680
+ })
681
+ break
682
+ }
683
+ }
684
+
685
+ async #handleUndoLike (bot, undoActivity, likeActivity) {
686
+ const actor = this.#getActor(undoActivity)
687
+ likeActivity = await this.#ensureProps(bot, undoActivity, likeActivity, ['object'])
688
+ let object = this.#getObject(likeActivity)
689
+ if (!this.#formatter.isLocal(object.id)) {
690
+ this.#logger.warn({
691
+ msg: 'Undo activity object is not local',
692
+ activity: undoActivity.id,
693
+ likeActivity: likeActivity.id,
694
+ object: object.id
695
+ })
696
+ return
697
+ }
698
+ try {
699
+ object = await this.#objectStorage.read(object.id)
700
+ } catch (err) {
701
+ this.#logger.warn({
702
+ msg: 'Like activity object not found',
703
+ activity: undoActivity.id,
704
+ likeActivity: likeActivity.id,
705
+ object: object.id
706
+ })
707
+ return
708
+ }
709
+ if (!(await this.#authz.canRead(actor, object))) {
710
+ this.#logger.warn({
711
+ msg: 'Like activity object is not readable',
712
+ activity: undoActivity.id,
713
+ likeActivity: likeActivity.id,
714
+ object: object.id
715
+ })
716
+ return
717
+ }
718
+ this.#logger.info({
719
+ msg: 'Removing like',
720
+ actor: actor.id,
721
+ object: object.id,
722
+ likeActivity: likeActivity.id,
723
+ undoActivity: undoActivity.id
724
+ })
725
+ await this.#objectStorage.removeFromCollection(object.id, 'likes', likeActivity)
726
+ await this.#objectStorage.removeFromCollection(object.id, 'likers', actor)
727
+ }
728
+
729
+ async #handleUndoAnnounce (bot, undoActivity, shareActivity) {
730
+ const actor = this.#getActor(undoActivity)
731
+ shareActivity = await this.#ensureProps(bot, undoActivity, shareActivity, ['object'])
732
+ let object = this.#getObject(shareActivity)
733
+ if (!this.#formatter.isLocal(object.id)) {
734
+ this.#logger.warn({
735
+ msg: 'Undo activity object is not local',
736
+ activity: undoActivity.id,
737
+ shareActivity: shareActivity.id,
738
+ object: object.id
739
+ })
740
+ return
741
+ }
742
+ try {
743
+ object = await this.#objectStorage.read(object.id)
744
+ } catch (err) {
745
+ this.#logger.warn({
746
+ msg: 'Share activity object not found',
747
+ activity: undoActivity.id,
748
+ shareActivity: shareActivity.id,
749
+ object: object.id
750
+ })
751
+ return
752
+ }
753
+ if (!(await this.#authz.canRead(actor, object))) {
754
+ this.#logger.warn({
755
+ msg: 'Share activity object is not readable',
756
+ activity: undoActivity.id,
757
+ shareActivity: shareActivity.id,
758
+ object: object.id
759
+ })
760
+ return
761
+ }
762
+ this.#logger.info({
763
+ msg: 'Removing share',
764
+ actor: actor.id,
765
+ object: object.id,
766
+ shareActivity: shareActivity.id,
767
+ undoActivity: undoActivity.id
768
+ })
769
+ await this.#objectStorage.removeFromCollection(object.id, 'shares', shareActivity)
770
+ await this.#objectStorage.removeFromCollection(object.id, 'sharers', actor)
771
+ }
772
+
773
+ async #handleUndoBlock (bot, undoActivity, blockActivity) {
774
+ const actor = this.#getActor(undoActivity)
775
+ blockActivity = await this.#ensureProps(bot, undoActivity, blockActivity, ['object'])
776
+ const object = this.#getObject(blockActivity)
777
+ if (object.id !== this.#botId(bot)) {
778
+ this.#logger.warn({
779
+ msg: 'Block activity object is not the bot',
780
+ activity: undoActivity.id,
781
+ object: object.id
782
+ })
783
+ } else {
784
+ this.#logger.info({
785
+ msg: 'Block removed',
786
+ actor: actor.id,
787
+ undoActivity: undoActivity.id,
788
+ blockActivity: blockActivity.id,
789
+ object: object.id
790
+ })
791
+ }
792
+ }
793
+
794
+ async #handleUndoFollow (bot, undoActivity, followActivity) {
795
+ const actor = this.#getActor(undoActivity)
796
+ followActivity = await this.#ensureProps(bot, undoActivity, followActivity, ['object'])
797
+ const object = this.#getObject(followActivity)
798
+ if (object.id !== this.#botId(bot)) {
799
+ this.#logger.warn({
800
+ msg: 'Follow activity object is not the bot',
801
+ activity: undoActivity.id,
802
+ object: object.id
803
+ })
804
+ return
805
+ }
806
+ if (!(await this.#actorStorage.isInCollection(bot.username, 'followers', actor))) {
807
+ this.#logger.warn({
808
+ msg: 'Undo follow activity from actor not in followers',
809
+ activity: undoActivity.id,
810
+ followActivity: followActivity.id,
811
+ actor: actor.id
812
+ })
813
+ return
814
+ }
815
+ await this.#actorStorage.removeFromCollection(bot.username, 'followers', actor)
816
+ await this.#actorStorage.removeFromCollection(bot.username, 'pendingFollowers', actor)
817
+ }
818
+
819
+ async onIdle () {
820
+ await this.#distributor.onIdle()
821
+ }
822
+
823
+ #getRecipients (obj) {
824
+ const to = obj.to ? Array.from(obj.to).map((to) => to.id) : null
825
+ const cc = obj.cc ? Array.from(obj.cc).map((cc) => cc.id) : null
826
+ const bto = obj.bto ? Array.from(obj.bto).map((bto) => bto.id) : null
827
+ const bcc = obj.bcc ? Array.from(obj.bcc).map((bcc) => bcc.id) : null
828
+ const audience = obj.audience
829
+ ? Array.from(obj.audience).map((audience) => audience.id)
830
+ : null
831
+ return { to, cc, bto, bcc, audience }
832
+ }
833
+
834
+ #removeRecipient (recipients, actor) {
835
+ const remove = (list) => {
836
+ if (!list) {
837
+ return
838
+ }
839
+ const index = list.indexOf(actor.id)
840
+ if (index !== -1) {
841
+ list.splice(index, 1)
842
+ }
843
+ }
844
+ remove(recipients.to)
845
+ remove(recipients.cc)
846
+ remove(recipients.bto)
847
+ remove(recipients.bcc)
848
+ remove(recipients.audience)
849
+ }
850
+
851
+ #addRecipient (recipients, actor, key = 'to') {
852
+ if (!actor.id) {
853
+ return
854
+ }
855
+ if (!recipients[key]) {
856
+ recipients[key] = []
857
+ }
858
+ if (recipients[key].indexOf(actor.id) === -1) {
859
+ recipients[key].push(actor.id)
860
+ }
861
+ }
862
+
863
+ async #doActivity (bot, activity) {
864
+ await this.#objectStorage.create(activity)
865
+ await this.#actorStorage.addToCollection(bot.username, 'outbox', activity)
866
+ await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
867
+ await this.#distributor.distribute(activity, bot.username)
868
+ }
869
+
870
+ #getActor (activity) {
871
+ return activity.actor?.first
872
+ }
873
+
874
+ #getObject (activity) {
875
+ return activity.object?.first
876
+ }
877
+
878
+ #getTarget (activity) {
879
+ return activity.target?.first
880
+ }
881
+
882
+ #getOwner (object) {
883
+ if (object.attributedTo) {
884
+ return object.attributedTo.first
885
+ } else if (object.actor) {
886
+ return object.actor.first
887
+ } else if (object.owner) {
888
+ return object.owner.first
889
+ } else {
890
+ return null
891
+ }
892
+ }
893
+
894
+ async #ensureProps (bot, source, object, required = ['id']) {
895
+ const others = required.filter((prop) => prop !== 'id' && prop !== 'type')
896
+ if (!object.id) {
897
+ return null
898
+ }
899
+ this.#logger.debug({ msg: 'Ensuring object', source: source.id, object: object.id, required })
900
+ // Try getting the object from the source
901
+ if (this.#authz.sameOrigin(source, object) &&
902
+ (!required.includes('type') || object.type) &&
903
+ !others.find((prop) => !object.has(prop))) {
904
+ this.#logger.debug('Object is already complete')
905
+ return object
906
+ }
907
+ // Check if it is a local object
908
+ if (this.#formatter.isLocal(object.id)) {
909
+ this.#logger.debug({ msg: 'Checking local', object: object.id })
910
+ object = await this.#objectStorage.read(object.id)
911
+ if (object &&
912
+ (!required.includes('type') || object.type) &&
913
+ !others.find((prop) => !object.has(prop))) {
914
+ this.#logger.debug('Object fetched from storage')
915
+ return object
916
+ } else {
917
+ return null
918
+ }
919
+ } else {
920
+ // Check it from cache
921
+ const id = object.id
922
+ this.#logger.debug({ msg: 'Checking cache', object: id })
923
+ object = await this.#cache.get(id)
924
+ if (object &&
925
+ (!required.includes('type') || object.type) &&
926
+ !others.find((prop) => !object.has(prop))) {
927
+ this.#logger.debug('Object fetched from cache')
928
+ return object
929
+ }
930
+ // Fetch it from the Web
931
+ this.#logger.debug({ msg: 'Checking remote', object: id })
932
+ object = await this.#client.get(id, bot.username)
933
+ this.#logger.debug({ msg: 'Object fetched from remote', object: object.id, objectText: await object.write() })
934
+ this.#cache.save(object)
935
+ if (object &&
936
+ (!required.includes('type') || object.type) &&
937
+ !others.find((prop) => !object.has(prop))) {
938
+ this.#logger.debug({ msg: 'Object fetched from remote is correct', object: object.id, objectText: await object.write() })
939
+ return object
940
+ }
941
+ return null
942
+ }
943
+ }
944
+
945
+ #botId (bot) {
946
+ return this.#formatter.format({ username: bot.username })
947
+ }
948
+
949
+ async #botActor (bot) {
950
+ return await as2.import({
951
+ id: this.#formatter.format({ username: bot.username })
952
+ })
953
+ }
954
+
955
+ #isMention (bot, object) {
956
+ if (!object.tag) {
957
+ return false
958
+ }
959
+ const url = this.#formatter.format({ username: bot.username })
960
+ for (const tag of object.tag) {
961
+ if (tag.type === AS2 + 'Mention' && tag.href === url) {
962
+ return true
963
+ }
964
+ }
965
+ return false
966
+ }
967
+
968
+ async #objectThread (object) {
969
+ const threadProp = object.get(THREAD_PROP)
970
+ if (threadProp) {
971
+ return Array.from(threadProp)[0]
972
+ }
973
+ const contextProp = object.get('context')
974
+ if (contextProp) {
975
+ return Array.from(contextProp)[0]
976
+ }
977
+ const inReplyTo = object.inReplyTo?.first
978
+ if (
979
+ inReplyTo &&
980
+ this.#formatter.isLocal(inReplyTo.id)
981
+ ) {
982
+ const inReplyToObject = await this.#objectStorage.read(inReplyTo.id)
983
+ return await this.#objectThread(inReplyToObject)
984
+ }
985
+ return null
986
+ }
987
+
988
+ async #threadRoot (thread) {
989
+ const parts = this.#formatter.unformat(thread.id)
990
+ if (parts.username && parts.type && parts.nanoid && parts.collection === 'thread') {
991
+ const id = this.#formatter.format(
992
+ { username: parts.username, type: parts.type, nanoid: parts.nanoid }
993
+ )
994
+ return await this.#objectStorage.read(id)
995
+ } else {
996
+ return null
997
+ }
998
+ }
999
+ }