@evanp/activitypub-bot 0.15.4 → 0.16.2

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.
package/README.md CHANGED
@@ -276,6 +276,11 @@ Sends an `Undo`/`Like` activity for the passed-in object in [activitystrea.ms](#
276
276
 
277
277
  Sends an `Announce` activity for the passed-in object in [activitystrea.ms](#activitystreams) form to followers.
278
278
 
279
+ #### async unannounceObject (obj)
280
+
281
+ Sends an `Undo`/`Announce` activity for the passed-in object. The addressees
282
+ match the `Announce` activity being undone.
283
+
279
284
  #### async followActor (actor)
280
285
 
281
286
  Sends a `Follow` activity for the passed-in actor in [activitystrea.ms](#activitystreams) form.
@@ -1,6 +1,9 @@
1
+ import AS2 from 'activitystrea.ms'
1
2
  import as2 from './activitystreams.js'
2
3
  import assert from 'node:assert'
3
4
 
5
+ const AS2_NS = 'https://www.w3.org/ns/activitystreams#'
6
+
4
7
  export class ActorStorage {
5
8
  #connection = null
6
9
  #formatter = null
@@ -256,6 +259,77 @@ export class ActorStorage {
256
259
  return usernames
257
260
  }
258
261
 
262
+ async getLastActivity (username, type, object) {
263
+ assert.ok(username, 'username is required')
264
+ assert.equal(typeof username, 'string', 'username must be a string')
265
+ assert.ok(type, 'type is required')
266
+ assert.equal(typeof type, 'string', 'type must be a string')
267
+ assert.ok(object, 'object is required')
268
+ assert.equal(typeof object, 'object', 'object must be an object')
269
+ assert.ok(object.id, 'object.id is required')
270
+ assert.equal(typeof object.id, 'string', 'object.id must be a string')
271
+
272
+ const [result] = await this.#connection.query(
273
+ `SELECT activity_id
274
+ FROM lastactivity
275
+ WHERE username = ? AND type = ? and object_id = ?`,
276
+ { replacements: [username, this.#clearNS(type), object.id] }
277
+ )
278
+
279
+ return (result.length > 0)
280
+ ? result[0].activity_id
281
+ : null
282
+ }
283
+
284
+ async clearLastActivity (username, type, object) {
285
+ assert.ok(username, 'username is required')
286
+ assert.equal(typeof username, 'string', 'username must be a string')
287
+ assert.ok(type, 'type is required')
288
+ assert.equal(typeof type, 'string', 'type must be a string')
289
+ assert.ok(object, 'object is required')
290
+ assert.equal(typeof object, 'object', 'object must be an object')
291
+ assert.ok(object.id, 'object.id is required')
292
+ assert.equal(typeof object.id, 'string', 'object.id must be a string')
293
+
294
+ await this.#connection.query(
295
+ `DELETE FROM lastactivity
296
+ WHERE username = ? AND type = ? and object_id = ?`,
297
+ { replacements: [username, this.#clearNS(type), object.id] }
298
+ )
299
+ }
300
+
301
+ async setLastActivity (username, activity) {
302
+ assert.ok(username, 'username is required')
303
+ assert.equal(typeof username, 'string', 'username must be a string')
304
+ assert.ok(activity, 'activity is required')
305
+ assert.equal(typeof activity, 'object', 'activity must be an object')
306
+ assert.ok(activity.id, 'activity.id is required')
307
+ assert.equal(typeof activity.id, 'string', 'id must be a string')
308
+ assert.ok(activity.type, 'activity.type is required')
309
+ assert.equal(typeof activity.type, 'string', 'type must be a string')
310
+ assert.ok(activity.object, 'activity.object is required')
311
+ assert.equal(typeof activity.object, 'object', 'activity.object must be an object')
312
+ assert.ok(activity.object.first.id, 'activity.object.id is required')
313
+ assert.equal(typeof activity.object.first.id, 'string', 'activity.object.id must be a string')
314
+
315
+ await this.#connection.query(
316
+ `
317
+ INSERT INTO lastactivity (username, type, object_id, activity_id)
318
+ VALUES (?, ?, ?, ?)
319
+ ON CONFLICT DO UPDATE
320
+ SET activity_id = EXCLUDED.activity_id,
321
+ updatedAt = CURRENT_TIMESTAMP
322
+ `,
323
+ { replacements: [
324
+ username,
325
+ this.#clearNS(activity.type),
326
+ activity.object.first.id,
327
+ activity.id
328
+ ]
329
+ }
330
+ )
331
+ }
332
+
259
333
  async #getCollectionInfo (username, property) {
260
334
  const [result] = await this.#connection.query(
261
335
  `SELECT first, totalItems, createdAt, updatedAt
@@ -287,4 +361,10 @@ export class ActorStorage {
287
361
  )
288
362
  return rows[0][0].item_count
289
363
  }
364
+
365
+ #clearNS (type) {
366
+ return (type.startsWith(AS2_NS))
367
+ ? type.slice(AS2_NS.length)
368
+ : type
369
+ }
290
370
  }
package/lib/botcontext.js CHANGED
@@ -143,14 +143,13 @@ export class BotContext {
143
143
  attributedTo: this.#formatter.format({ username: this.#botId })
144
144
  })
145
145
  await this.#objectStorage.create(note)
146
- const activity = await as2.import({
146
+ const activity = await this.#doActivity({
147
+ '@context': [
148
+ 'https://www.w3.org/ns/activitystreams',
149
+ 'https://purl.archive.org/socialweb/thread/1.0',
150
+ { ostatus: 'http://ostatus.org/schema/1.0/' }
151
+ ],
147
152
  type: 'Create',
148
- id: this.#formatter.format({
149
- username: this.#botId,
150
- type: 'create',
151
- nanoid: nanoid()
152
- }),
153
- actor: this.#formatter.format({ username: this.#botId }),
154
153
  to,
155
154
  cc,
156
155
  bto,
@@ -158,10 +157,6 @@ export class BotContext {
158
157
  audience,
159
158
  object: note
160
159
  })
161
- await this.#objectStorage.create(activity)
162
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
163
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
164
- await this.#distributor.distribute(activity, this.#botId)
165
160
  return note
166
161
  }
167
162
 
@@ -205,23 +200,14 @@ export class BotContext {
205
200
  const owners = obj.attributedTo
206
201
  ? Array.from(obj.attributedTo).map((owner) => owner.id)
207
202
  : Array.from(obj.actor).map((owner) => owner.id)
208
- const activity = await as2.import({
203
+ await this.#actorStorage.addToCollection(this.#botId, 'liked', obj)
204
+ const activity = await this.#doActivity({
209
205
  type: 'Like',
210
- id: this.#formatter.format({
211
- username: this.#botId,
212
- type: 'like',
213
- nanoid: nanoid()
214
- }),
215
- actor: this.#formatter.format({ username: this.#botId }),
216
206
  object: obj.id,
217
207
  to: owners,
218
208
  cc: 'https://www.w3.org/ns/activitystreams#Public'
219
209
  })
220
- await this.#objectStorage.create(activity)
221
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
222
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
223
- await this.#actorStorage.addToCollection(this.#botId, 'liked', obj)
224
- await this.#distributor.distribute(activity, this.#botId)
210
+ await this.#actorStorage.setLastActivity(this.#botId, activity)
225
211
  return activity
226
212
  }
227
213
 
@@ -234,93 +220,51 @@ export class BotContext {
234
220
  if (!(await this.#actorStorage.isInCollection(this.#botId, 'liked', obj))) {
235
221
  throw new Error(`not already liked: ${obj.id} by ${this.#botId}`)
236
222
  }
237
- const likeActivity = this.#findInOutbox('Like', obj)
223
+ const likeActivity = this.#actorStorage.getLastActivity(
224
+ this.#botId,
225
+ 'Like',
226
+ obj
227
+ )
238
228
  if (!likeActivity) {
239
229
  throw new Error('no like activity')
240
230
  }
241
- const undoActivity = await as2.import({
231
+ await this.#actorStorage.removeFromCollection(this.#botId, 'liked', obj)
232
+ return await this.#doActivity({
242
233
  type: 'Undo',
243
- id: this.#formatter.format({
244
- username: this.#botId,
245
- type: 'undo',
246
- nanoid: nanoid()
247
- }),
248
- actor: this.#formatter.format({ username: this.#botId }),
249
234
  object: likeActivity,
250
235
  to: owners,
251
236
  cc: 'https://www.w3.org/ns/activitystreams#Public'
252
237
  })
253
- await this.#objectStorage.create(undoActivity)
254
- await this.#actorStorage.addToCollection(
255
- this.#botId,
256
- 'outbox',
257
- undoActivity
258
- )
259
- await this.#actorStorage.addToCollection(
260
- this.#botId,
261
- 'inbox',
262
- undoActivity
263
- )
264
- await this.#actorStorage.removeFromCollection(this.#botId, 'liked', obj)
265
- await this.#distributor.distribute(undoActivity, this.#botId)
266
- return undoActivity
267
238
  }
268
239
 
269
240
  async followActor (actor) {
270
241
  assert.ok(actor)
271
242
  assert.equal(typeof actor, 'object')
272
- const activity = await as2.import({
273
- type: 'Follow',
274
- id: this.#formatter.format({
275
- username: this.#botId,
276
- type: 'follow',
277
- nanoid: nanoid()
278
- }),
279
- actor: this.#formatter.format({ username: this.#botId }),
280
- object: actor.id,
281
- to: actor.id
282
- })
283
- await this.#objectStorage.create(activity)
284
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
285
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
286
243
  await this.#actorStorage.addToCollection(
287
244
  this.#botId,
288
245
  'pendingFollowing',
289
246
  actor
290
247
  )
291
- await this.#distributor.distribute(activity, this.#botId)
248
+ const activity = await this.#doActivity({
249
+ type: 'Follow',
250
+ object: actor.id,
251
+ to: actor.id
252
+ })
253
+ await this.#actorStorage.setLastActivity(this.#botId, activity)
292
254
  return activity
293
255
  }
294
256
 
295
257
  async unfollowActor (actor) {
296
258
  assert.ok(actor)
297
259
  assert.equal(typeof actor, 'object')
298
- const followActivity = this.#findInOutbox('Follow', actor)
260
+ const followActivity = this.#actorStorage.getLastActivity(
261
+ this.#botId,
262
+ 'Follow',
263
+ actor
264
+ )
299
265
  if (!followActivity) {
300
266
  throw new Error('no follow activity')
301
267
  }
302
- const undoActivity = await as2.import({
303
- type: 'Undo',
304
- id: this.#formatter.format({
305
- username: this.#botId,
306
- type: 'undo',
307
- nanoid: nanoid()
308
- }),
309
- actor: this.#formatter.format({ username: this.#botId }),
310
- object: followActivity,
311
- to: actor.id
312
- })
313
- await this.#objectStorage.create(undoActivity)
314
- await this.#actorStorage.addToCollection(
315
- this.#botId,
316
- 'outbox',
317
- undoActivity
318
- )
319
- await this.#actorStorage.addToCollection(
320
- this.#botId,
321
- 'inbox',
322
- undoActivity
323
- )
324
268
  await this.#actorStorage.removeFromCollection(
325
269
  this.#botId,
326
270
  'pendingFollowing',
@@ -331,26 +275,16 @@ export class BotContext {
331
275
  'following',
332
276
  actor
333
277
  )
334
- await this.#distributor.distribute(undoActivity, this.#botId)
335
- return undoActivity
278
+ return await this.#doActivity({
279
+ type: 'Undo',
280
+ object: followActivity,
281
+ to: actor.id
282
+ })
336
283
  }
337
284
 
338
285
  async blockActor (actor) {
339
286
  assert.ok(actor)
340
287
  assert.equal(typeof actor, 'object')
341
- const activity = await as2.import({
342
- type: 'Block',
343
- id: this.#formatter.format({
344
- username: this.#botId,
345
- type: 'block',
346
- nanoid: nanoid()
347
- }),
348
- actor: this.#formatter.format({ username: this.#botId }),
349
- object: actor.id
350
- })
351
- await this.#objectStorage.create(activity)
352
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
353
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
354
288
  await this.#actorStorage.addToCollection(this.#botId, 'blocked', actor)
355
289
  for (const coll of [
356
290
  'following',
@@ -360,45 +294,34 @@ export class BotContext {
360
294
  ]) {
361
295
  await this.#actorStorage.removeFromCollection(this.#botId, coll, actor)
362
296
  }
363
- // Do not distribute!
297
+ const activity = await this.#doActivity({
298
+ type: 'Block',
299
+ object: actor.id
300
+ }, false)
301
+ await this.#actorStorage.setLastActivity(this.#botId, activity)
364
302
  return activity
365
303
  }
366
304
 
367
305
  async unblockActor (actor) {
368
306
  assert.ok(actor)
369
307
  assert.equal(typeof actor, 'object')
370
- const blockActivity = this.#findInOutbox('Block', actor)
308
+ const blockActivity = this.#actorStorage.getLastActivity(
309
+ this.#botId,
310
+ 'Block',
311
+ actor
312
+ )
371
313
  if (!blockActivity) {
372
314
  throw new Error('no block activity')
373
315
  }
374
- const undoActivity = await as2.import({
375
- type: 'Undo',
376
- id: this.#formatter.format({
377
- username: this.#botId,
378
- type: 'undo',
379
- nanoid: nanoid()
380
- }),
381
- actor: this.#formatter.format({ username: this.#botId }),
382
- object: blockActivity
383
- })
384
- await this.#objectStorage.create(undoActivity)
385
- await this.#actorStorage.addToCollection(
386
- this.#botId,
387
- 'outbox',
388
- undoActivity
389
- )
390
- await this.#actorStorage.addToCollection(
391
- this.#botId,
392
- 'inbox',
393
- undoActivity
394
- )
395
316
  await this.#actorStorage.removeFromCollection(
396
317
  this.#botId,
397
318
  'blocked',
398
319
  actor
399
320
  )
400
- // Do not distribute!
401
- return undoActivity
321
+ return await this.#doActivity({
322
+ type: 'Undo',
323
+ object: blockActivity
324
+ }, false)
402
325
  }
403
326
 
404
327
  async updateNote (note, content) {
@@ -410,14 +333,9 @@ export class BotContext {
410
333
  exported.content = content
411
334
  const updated = await as2.import(exported)
412
335
  const { to, cc, bto, bcc, audience } = this.#getRecipients(note)
413
- const activity = await as2.import({
336
+ await this.#objectStorage.update(updated)
337
+ await this.#doActivity({
414
338
  type: 'Update',
415
- id: this.#formatter.format({
416
- username: this.#botId,
417
- type: 'update',
418
- nanoid: nanoid()
419
- }),
420
- actor: this.#formatter.format({ username: this.#botId }),
421
339
  object: updated,
422
340
  to,
423
341
  cc,
@@ -425,11 +343,6 @@ export class BotContext {
425
343
  bcc,
426
344
  audience
427
345
  })
428
- await this.#objectStorage.update(updated)
429
- await this.#objectStorage.create(activity)
430
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
431
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
432
- await this.#distributor.distribute(activity, this.#botId)
433
346
  return updated
434
347
  }
435
348
 
@@ -449,14 +362,9 @@ export class BotContext {
449
362
  bcc,
450
363
  audience
451
364
  })
452
- const activity = await as2.import({
365
+ await this.#objectStorage.update(tombstone)
366
+ return await this.#doActivity({
453
367
  type: 'Delete',
454
- id: this.#formatter.format({
455
- username: this.#botId,
456
- type: 'delete',
457
- nanoid: nanoid()
458
- }),
459
- actor: this.#formatter.format({ username: this.#botId }),
460
368
  object: tombstone,
461
369
  to,
462
370
  cc,
@@ -464,12 +372,6 @@ export class BotContext {
464
372
  bcc,
465
373
  audience
466
374
  })
467
- await this.#objectStorage.update(tombstone)
468
- await this.#objectStorage.create(activity)
469
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
470
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
471
- await this.#distributor.distribute(activity, this.#botId)
472
- return activity
473
375
  }
474
376
 
475
377
  async toActorId (webfinger) {
@@ -510,14 +412,8 @@ export class BotContext {
510
412
  const owners = obj.attributedTo
511
413
  ? Array.from(obj.attributedTo).map((owner) => owner.id)
512
414
  : Array.from(obj.actor).map((owner) => owner.id)
513
- const activity = await as2.import({
415
+ const activity = await this.#doActivity({
514
416
  type: 'Announce',
515
- id: this.#formatter.format({
516
- username: this.#botId,
517
- type: 'Announce',
518
- nanoid: nanoid()
519
- }),
520
- actor: this.#formatter.format({ username: this.#botId }),
521
417
  summary: {
522
418
  en: `${this.#botId} shared "${await this.#nameOf(obj)}"`
523
419
  },
@@ -531,13 +427,32 @@ export class BotContext {
531
427
  ],
532
428
  cc: owners
533
429
  })
534
- await this.#objectStorage.create(activity)
535
- await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
536
- await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
537
- await this.#distributor.distribute(activity, this.#botId)
430
+ await this.#actorStorage.setLastActivity(this.#botId, activity)
538
431
  return activity
539
432
  }
540
433
 
434
+ async unannounceObject (obj) {
435
+ assert.ok(obj)
436
+ assert.equal(typeof obj, 'object')
437
+ const announceActivity = await this.#actorStorage.getLastActivity(
438
+ this.#botId,
439
+ 'Announce',
440
+ obj
441
+ )
442
+ if (!announceActivity) {
443
+ throw new Error(`No matching announce activity for ${obj.id}`)
444
+ }
445
+ const recipients = this.#getRecipients(announceActivity)
446
+ return await this.#doActivity({
447
+ type: 'Undo',
448
+ summary: {
449
+ en: `${this.#botId} shared "${await this.#nameOf(obj)}"`
450
+ },
451
+ object: announceActivity,
452
+ ...recipients
453
+ })
454
+ }
455
+
541
456
  async #findInOutbox (type, obj) {
542
457
  const full = `https://www.w3.org/ns/activitystreams#${type}`
543
458
  let found = null
@@ -576,6 +491,33 @@ export class BotContext {
576
491
  }
577
492
  }
578
493
 
494
+ async #doActivity (activityData, distribute = true) {
495
+ const now = new Date().toISOString()
496
+ const type = activityData.type || 'Activity'
497
+ const activity = await as2.import({
498
+ ...activityData,
499
+ type,
500
+ id: this.#formatter.format({
501
+ username: this.#botId,
502
+ type: type,
503
+ nanoid: nanoid()
504
+ }),
505
+ actor: {
506
+ id: this.#formatter.format({ username: this.#botId }),
507
+ type: 'Service'
508
+ },
509
+ published: now,
510
+ updated: now
511
+ })
512
+ await this.#objectStorage.create(activity)
513
+ await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
514
+ await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
515
+ if (distribute) {
516
+ await this.#distributor.distribute(activity, this.#botId)
517
+ }
518
+ return activity
519
+ }
520
+
579
521
  async onIdle () {
580
522
  await this.#distributor.onIdle()
581
523
  }
@@ -0,0 +1,15 @@
1
+ export const id = '002-last-activity'
2
+
3
+ export async function up (connection) {
4
+ await connection.query(`
5
+ CREATE TABLE lastactivity (
6
+ username varchar(512) NOT NULL,
7
+ type varchar(512) NOT NULL,
8
+ object_id varchar(512) NOT NULL,
9
+ activity_id varchar(512),
10
+ createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
11
+ updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+ PRIMARY KEY (username, type, object_id)
13
+ );
14
+ `)
15
+ }
@@ -1,7 +1,9 @@
1
1
  import { id as initialId, up as initialUp } from './001-initial.js'
2
+ import { id as lastId, up as lastUp } from './002-last-activity.js'
2
3
 
3
4
  const migrations = [
4
- { id: initialId, up: initialUp }
5
+ { id: initialId, up: initialUp },
6
+ { id: lastId, up: lastUp }
5
7
  ]
6
8
 
7
9
  export async function runMigrations (connection) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.15.4",
3
+ "version": "0.16.2",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",