@evanp/activitypub-bot 0.45.12 → 0.45.14

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/CHANGELOG.md CHANGED
@@ -9,6 +9,21 @@ and this project adheres to
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.45.14] - 2026-05-10
13
+
14
+ ### Changed
15
+
16
+ - Depend on inbox forwarding for distribution to remote collections.
17
+ - More robust handling of client request failures in delivery and distribution.
18
+ - More robust handling of intake, fanout throttle errors.
19
+ - Store error stack for failing jobs in queue job entries.
20
+
21
+ ## [0.45.13] - 2026-05-10
22
+
23
+ ### Changed
24
+
25
+ - Replace in-memory ObjectCache with persistent and shared RemoteObjectCache.
26
+
12
27
  ## [0.45.12] - 2026-05-09
13
28
 
14
29
  ### Changed
@@ -4,6 +4,7 @@ import * as ttlcachePkg from '@isaacs/ttlcache'
4
4
 
5
5
  import as2 from './activitystreams.js'
6
6
  import BotMaker from './botmaker.js'
7
+ import { ThrottleError } from './requestthrottler.js'
7
8
 
8
9
  const TTLCache =
9
10
  ttlcachePkg.TTLCache ?? ttlcachePkg.default ?? ttlcachePkg
@@ -128,6 +129,7 @@ export class ActivityDeliverer {
128
129
  const deliveredTo = new Set()
129
130
  const actor = this.getActor(activity)
130
131
  const recipients = this.getRecipients(activity)
132
+ let fullActor
131
133
 
132
134
  for (const recipient of recipients) {
133
135
  this.#logger.debug({ recipient: recipient.id }, 'Checking recipient')
@@ -152,8 +154,18 @@ export class ActivityDeliverer {
152
154
  )
153
155
  }
154
156
  } else {
155
- const fullActor = await this.#client.get(actor.id)
156
- const fullRecipient = await this.#client.get(recipient.id)
157
+ fullActor = fullActor ?? await this.#client.get(actor.id)
158
+ let fullRecipient
159
+ try {
160
+ fullRecipient = await this.#client.get(recipient.id)
161
+ } catch (err) {
162
+ if (err instanceof ThrottleError) throw err
163
+ this.#logger.warn(
164
+ { recipient: recipient.id },
165
+ 'Unreachable remote recipient'
166
+ )
167
+ continue
168
+ }
157
169
  if (await this.#isRemoteActor(fullRecipient)) {
158
170
  this.#logger.warn({ recipient: recipient.id }, 'Skipping remote actor')
159
171
  } else if (await this.#isRemoteFollowersCollection(fullActor, fullRecipient)) {
@@ -164,6 +164,14 @@ export class ActivityDistributor {
164
164
  yield id
165
165
  } else if (this.#isRemoteCollection(obj)) {
166
166
  this.#logger.debug({ id }, 'Remote collection')
167
+ const owner = await this.#getCollectionOwner(obj, username)
168
+ if (owner && this.#isRecipient(activity, owner)) {
169
+ this.#logger.debug(
170
+ { id, owner: owner.id },
171
+ 'Leaving remote collection up to inbox forwarding'
172
+ )
173
+ continue
174
+ }
167
175
  try {
168
176
  for await (const item of this.#client.items(obj.id, username)) {
169
177
  this.#logger.debug({ id: item.id }, 'Remote collection member')
@@ -194,7 +202,16 @@ export class ActivityDistributor {
194
202
  return sharedInbox
195
203
  }
196
204
 
197
- const obj = await this.#client.get(actorId, username)
205
+ let obj
206
+ try {
207
+ obj = await this.#client.get(actorId, username)
208
+ } catch (err) {
209
+ this.#logger.warn(
210
+ { actorId, username, err },
211
+ 'Could not get actor in #getInbox'
212
+ )
213
+ return null
214
+ }
198
215
 
199
216
  // Get the shared inbox if it exists
200
217
 
@@ -232,7 +249,16 @@ export class ActivityDistributor {
232
249
  return directInbox
233
250
  }
234
251
 
235
- const obj = await this.#client.get(actorId, username)
252
+ let obj
253
+ try {
254
+ obj = await this.#client.get(actorId, username)
255
+ } catch (err) {
256
+ this.#logger.warn(
257
+ { actorId, username, err },
258
+ 'Could not get actor in #getDirectInbox'
259
+ )
260
+ return null
261
+ }
236
262
 
237
263
  if (!obj.inbox) {
238
264
  return null
@@ -274,4 +300,47 @@ export class ActivityDistributor {
274
300
  ? obj.type.some(t => COLLECTION_TYPES.includes(t))
275
301
  : COLLECTION_TYPES.includes(obj.type)
276
302
  }
303
+
304
+ async #getCollectionOwner (obj, username) {
305
+ if (obj.attributedTo) {
306
+ return obj.attributedTo.first
307
+ } else {
308
+ let ownerId
309
+ let owner
310
+
311
+ if (obj.id.endsWith('/followers')) {
312
+ ownerId = obj.id.replace('/followers', '')
313
+ } else if (obj.id.endsWith('/following')) {
314
+ ownerId = obj.id.replace('/following', '')
315
+ }
316
+
317
+ if (ownerId) {
318
+ try {
319
+ owner = await this.#client.get(ownerId, username)
320
+ } catch (err) {
321
+ this.#logger.debug(
322
+ { ownerId },
323
+ 'Owner unreachable'
324
+ )
325
+ }
326
+ }
327
+
328
+ return owner
329
+ }
330
+ }
331
+
332
+ #isRecipient (activity, obj) {
333
+ const props = ['to', 'cc', 'audience', 'bto', 'bcc']
334
+ for (const prop of props) {
335
+ const p = activity.get(prop)
336
+ if (p) {
337
+ for (const value of p) {
338
+ if (value.id === obj.id) {
339
+ return true
340
+ }
341
+ }
342
+ }
343
+ }
344
+ return false
345
+ }
277
346
  }
@@ -47,6 +47,12 @@ export class ActivityHandler {
47
47
  'activity parameter must be an object'
48
48
  )
49
49
 
50
+ await this.#cache.set(
51
+ activity.id,
52
+ bot.username,
53
+ await activity.export({ useOriginalContext: true })
54
+ )
55
+
50
56
  let handled = false
51
57
 
52
58
  try {
@@ -96,9 +102,11 @@ export class ActivityHandler {
96
102
  return
97
103
  }
98
104
  if (await this.#authz.sameOrigin(activity, object)) {
99
- await this.#cache.save(object)
100
- } else {
101
- await this.#cache.saveReceived(object)
105
+ await this.#cache.set(
106
+ object.id,
107
+ bot.username,
108
+ await object.export({ useOriginalContext: true })
109
+ )
102
110
  }
103
111
  await this.#handleCreateReplies(bot, activity, actor, object)
104
112
  await this.#handleCreateThread(bot, activity, actor, object)
@@ -183,6 +191,7 @@ export class ActivityHandler {
183
191
  'Not a local thread',
184
192
  { thread: thread.id }
185
193
  )
194
+ await this.#cache.clear(thread.id)
186
195
  return
187
196
  }
188
197
  const root = await this.#threadRoot(thread)
@@ -231,52 +240,22 @@ export class ActivityHandler {
231
240
 
232
241
  async #handleUpdate (bot, activity) {
233
242
  const object = this.#getObject(activity)
234
- if (await this.#authz.sameOrigin(activity, object)) {
235
- await this.#cache.save(object)
236
- } else {
237
- await this.#cache.saveReceived(object)
238
- }
243
+ await this.#cache.clear(object.id)
239
244
  }
240
245
 
241
246
  async #handleDelete (bot, activity) {
242
247
  const object = this.#getObject(activity)
243
- await this.#cache.clear(object)
248
+ await this.#cache.clear(object.id)
244
249
  }
245
250
 
246
251
  async #handleAdd (bot, activity) {
247
- const actor = this.#getActor(activity)
248
252
  const target = this.#getTarget(activity)
249
- const object = this.#getObject(activity)
250
- if (await this.#authz.sameOrigin(actor, object)) {
251
- await this.#cache.save(object)
252
- } else {
253
- await this.#cache.saveReceived(object)
254
- }
255
- if (await this.#authz.sameOrigin(actor, target)) {
256
- await this.#cache.save(target)
257
- await this.#cache.saveMembership(target, object)
258
- } else {
259
- await this.#cache.saveReceived(target)
260
- await this.#cache.saveMembershipReceived(target, object)
261
- }
253
+ await this.#cache.clear(target.id)
262
254
  }
263
255
 
264
256
  async #handleRemove (bot, activity) {
265
- const actor = this.#getActor(activity)
266
257
  const target = this.#getTarget(activity)
267
- const object = this.#getObject(activity)
268
- if (await this.#authz.sameOrigin(actor, object)) {
269
- await this.#cache.save(object)
270
- } else {
271
- await this.#cache.saveReceived(object)
272
- }
273
- if (await this.#authz.sameOrigin(actor, target)) {
274
- await this.#cache.save(target)
275
- await this.#cache.saveMembership(target, object, false)
276
- } else {
277
- await this.#cache.saveReceived(target)
278
- await this.#cache.saveMembershipReceived(target, object, false)
279
- }
258
+ await this.#cache.clear(target.id)
280
259
  }
281
260
 
282
261
  async #handleFollow (bot, activity) {
@@ -993,7 +972,7 @@ export class ActivityHandler {
993
972
  // Check it from cache
994
973
  const id = object.id
995
974
  this.#logger.debug({ msg: 'Checking cache', object: id })
996
- object = await this.#cache.get(id)
975
+ object = await as2.import((await this.#cache.get(id, bot.username)).object)
997
976
  if (object &&
998
977
  (!required.includes('type') || object.type) &&
999
978
  !others.find((prop) => !object.has(prop))) {
@@ -1004,7 +983,11 @@ export class ActivityHandler {
1004
983
  this.#logger.debug({ msg: 'Checking remote', object: id })
1005
984
  object = await this.#client.get(id, bot.username)
1006
985
  this.#logger.debug({ msg: 'Object fetched from remote', object: object.id, objectText: await object.write() })
1007
- this.#cache.save(object)
986
+ await this.#cache.set(
987
+ object.id,
988
+ bot.username,
989
+ await object.export({ useOriginalContext: true })
990
+ )
1008
991
  if (object &&
1009
992
  (!required.includes('type') || object.type) &&
1010
993
  !others.find((prop) => !object.has(prop))) {
package/lib/app.js CHANGED
@@ -23,7 +23,6 @@ import { HTTPSignature } from './httpsignature.js'
23
23
  import { Authorizer } from './authorizer.js'
24
24
  import { RemoteKeyStorage } from './remotekeystorage.js'
25
25
  import { ActivityHandler } from './activityhandler.js'
26
- import { ObjectCache } from '../lib/objectcache.js'
27
26
  import serverRouter from './routes/server.js'
28
27
  import userRouter from './routes/user.js'
29
28
  import objectRouter from './routes/object.js'
@@ -116,17 +115,12 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
116
115
  endpointCache
117
116
  )
118
117
  const authorizer = new Authorizer(actorStorage, formatter, client)
119
- const cache = new ObjectCache({
120
- longTTL: 3600 * 1000,
121
- shortTTL: 300 * 1000,
122
- maxItems: 1000
123
- })
124
118
  const activityHandler = new ActivityHandler(
125
119
  actorStorage,
126
120
  objectStorage,
127
121
  distributor,
128
122
  formatter,
129
- cache,
123
+ remoteObjectCache,
130
124
  authorizer,
131
125
  logger,
132
126
  client
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert'
2
2
 
3
3
  import as2 from './activitystreams.js'
4
- import { Worker } from './worker.js'
4
+ import { Worker, RecoverableError } from './worker.js'
5
+ import { ThrottleError } from './requestthrottler.js'
5
6
 
6
7
  export class FanoutWorker extends Worker {
7
8
  #distributor
@@ -15,6 +16,17 @@ export class FanoutWorker extends Worker {
15
16
  async doJob (payload, attempts) {
16
17
  const activity = await as2.import(payload.activity)
17
18
  const { username } = payload
18
- await this.#distributor.fanout(activity, username)
19
+ try {
20
+ await this.#distributor.fanout(activity, username)
21
+ } catch (err) {
22
+ if (err instanceof ThrottleError) {
23
+ this._logger.warn(
24
+ { err, activity: activity.id, username },
25
+ 'Throttled fanout, waiting to retry')
26
+ throw new RecoverableError(err.message, err.waitTime)
27
+ } else {
28
+ throw err
29
+ }
30
+ }
19
31
  }
20
32
  }
@@ -1,7 +1,8 @@
1
1
  import assert from 'node:assert'
2
2
 
3
3
  import as2 from './activitystreams.js'
4
- import { Worker } from './worker.js'
4
+ import { Worker, RecoverableError } from './worker.js'
5
+ import { ThrottleError } from './requestthrottler.js'
5
6
 
6
7
  export class IntakeWorker extends Worker {
7
8
  #deliverer
@@ -17,6 +18,17 @@ export class IntakeWorker extends Worker {
17
18
  async doJob (payload, attempts) {
18
19
  const raw = payload.activity
19
20
  const activity = await as2.import(raw)
20
- await this.#deliverer.deliverToAll(activity, this.#bots)
21
+ try {
22
+ await this.#deliverer.deliverToAll(activity, this.#bots)
23
+ } catch (err) {
24
+ if (err instanceof ThrottleError) {
25
+ this._logger.warn(
26
+ { err, activity: activity.id },
27
+ 'Throttled intake, waiting to retry')
28
+ throw new RecoverableError(err.message, err.waitTime)
29
+ } else {
30
+ throw err
31
+ }
32
+ }
21
33
  }
22
34
  }
package/lib/jobqueue.js CHANGED
@@ -87,12 +87,11 @@ export class JobQueue {
87
87
  this.#logger.debug({ method: 'release', jobRunnerId, jobId }, 'released job')
88
88
  }
89
89
 
90
- async retryAfter (jobId, jobRunnerId, delay) {
90
+ async retryAfter (jobId, jobRunnerId, delay, lastError = null) {
91
91
  assert.ok(jobId)
92
92
  assert.strictEqual(typeof jobId, 'string')
93
93
  assert.ok(jobRunnerId)
94
94
  assert.strictEqual(typeof jobRunnerId, 'string')
95
- assert.ok(delay)
96
95
  assert.strictEqual(typeof delay, 'number')
97
96
 
98
97
  const retryAfter = new Date(Date.now() + delay)
@@ -102,9 +101,10 @@ export class JobQueue {
102
101
  SET claimed_by = NULL,
103
102
  claimed_at = NULL,
104
103
  updated_at = CURRENT_TIMESTAMP,
105
- retry_after = ?
104
+ retry_after = ?,
105
+ last_error = ?
106
106
  WHERE job_id = ? AND claimed_by = ?;`,
107
- { replacements: [retryAfter, jobId, jobRunnerId] }
107
+ { replacements: [retryAfter, lastError, jobId, jobRunnerId] }
108
108
  )
109
109
  this.#logger.debug(
110
110
  { method: 'retry', jobRunnerId, jobId, delay },
@@ -157,7 +157,7 @@ export class JobQueue {
157
157
  this.#ac.abort()
158
158
  }
159
159
 
160
- async fail (jobId, jobRunnerId) {
160
+ async fail (jobId, jobRunnerId, lastError = null) {
161
161
  await this.#connection.query(`
162
162
  INSERT INTO failed_job
163
163
  (job_id, queue_id, priority, payload, claimed_at, claimed_by, attempts, retry_after, created_at, updated_at)
@@ -170,6 +170,14 @@ export class JobQueue {
170
170
  DELETE FROM job
171
171
  WHERE job_id = ? AND claimed_by = ?;`,
172
172
  { replacements: [jobId, jobRunnerId] })
173
+
174
+ if (lastError) {
175
+ await this.#connection.query(`
176
+ UPDATE failed_job
177
+ SET last_error = ?
178
+ WHERE job_id = ?;`,
179
+ { replacements: [lastError, jobId] })
180
+ }
173
181
  this.#logger.debug({ method: 'complete', jobRunnerId, jobId }, 'completed job')
174
182
  }
175
183
 
@@ -0,0 +1,12 @@
1
+ export const id = '011-job-last-error'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ ALTER TABLE job
6
+ ADD COLUMN last_error TEXT;
7
+ `, queryOptions)
8
+ await connection.query(`
9
+ ALTER TABLE failed_job
10
+ ADD COLUMN last_error TEXT;
11
+ `, queryOptions)
12
+ }
@@ -59,7 +59,7 @@ export class RemoteObjectCache {
59
59
  return result
60
60
  }
61
61
 
62
- async set (id, username, object, headers) {
62
+ async set (id, username, object, headers = new Headers()) {
63
63
  assert.ok(id)
64
64
  assert.strictEqual(typeof id, 'string')
65
65
  assert.ok(username)
@@ -95,6 +95,14 @@ export class RemoteObjectCache {
95
95
  )
96
96
  }
97
97
 
98
+ async clear (id) {
99
+ await this.#connection.query(
100
+ `DELETE FROM remote_object_cache
101
+ WHERE id = ?`,
102
+ { replacements: [id] }
103
+ )
104
+ }
105
+
98
106
  async #getExpiry (object, headers) {
99
107
  assert.ok(object)
100
108
  assert.strictEqual(typeof object, 'object')
package/lib/worker.js CHANGED
@@ -55,7 +55,7 @@ export class Worker {
55
55
  this.#logger.warn({ err, jobId }, 'error before job, retrying')
56
56
  if (jobId) {
57
57
  const delay = this.#retryDelay(attempts ?? 1)
58
- await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
58
+ await this.#jobQueue.retryAfter(jobId, this.#workerId, delay, err.stack)
59
59
  }
60
60
  continue
61
61
  }
@@ -69,9 +69,9 @@ export class Worker {
69
69
  await this.#jobQueue.complete(jobId, this.#workerId)
70
70
  } catch (err) {
71
71
  if (err instanceof RecoverableError) {
72
- await this.#jobQueue.retryAfter(jobId, this.#workerId, err.delay)
72
+ await this.#jobQueue.retryAfter(jobId, this.#workerId, err.delay, err.stack)
73
73
  } else {
74
- await this.#jobQueue.fail(jobId, this.#workerId)
74
+ await this.#jobQueue.fail(jobId, this.#workerId, err.stack)
75
75
  }
76
76
  }
77
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.45.12",
3
+ "version": "0.45.14",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1,51 +0,0 @@
1
- import * as ttlcachePkg from '@isaacs/ttlcache'
2
-
3
- const TTLCache =
4
- ttlcachePkg.TTLCache ?? ttlcachePkg.default ?? ttlcachePkg
5
-
6
- export class ObjectCache {
7
- #objects = null
8
- #members = null
9
- constructor ({ longTTL, shortTTL, maxItems }) {
10
- this.#objects = new TTLCache({ ttl: shortTTL, max: maxItems })
11
- this.#members = new TTLCache({ ttl: shortTTL, max: maxItems })
12
- this.longTTL = longTTL
13
- this.shortTTL = shortTTL
14
- this.maxItems = maxItems
15
- }
16
-
17
- async initialize () {
18
- }
19
-
20
- async get (id) {
21
- return this.#objects.get(id)
22
- }
23
-
24
- async save (object) {
25
- return this.#objects.set(object.id, object, { ttl: this.longTTL })
26
- }
27
-
28
- async saveReceived (object) {
29
- return this.#objects.set(object.id, object, { ttl: this.shortTTL })
30
- }
31
-
32
- async clear (object) {
33
- return this.#objects.delete(object.id)
34
- }
35
-
36
- membershipKey (collection, object) {
37
- return `${collection.id}:${object.id}`
38
- }
39
-
40
- async saveMembership (collection, object, isMember = true) {
41
- return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.longTTL })
42
- }
43
-
44
- async saveMembershipReceived (collection, object, isMember = true) {
45
- return this.#members.set(this.membershipKey(collection, object), isMember, { ttl: this.shortTTL })
46
- }
47
-
48
- async isMember (collection, object) {
49
- return this.#members.get(this.membershipKey(collection, object))
50
- }
51
- }