@evanp/activitypub-bot 0.21.0 → 0.21.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.
@@ -1,7 +1,6 @@
1
1
  import BotMaker from './botmaker.js'
2
2
  import assert from 'node:assert'
3
3
  import as2 from './activitystreams.js'
4
- import PQueue from 'p-queue'
5
4
  import * as ttlcachePkg from '@isaacs/ttlcache'
6
5
 
7
6
  const TTLCache =
@@ -21,27 +20,28 @@ const COLLECTION_TYPES = [
21
20
  ]
22
21
 
23
22
  export class ActivityDeliverer {
24
- static #MAX_ALL_QUEUE = 16
25
- static #MAX_BOT_QUEUE = 32
23
+ static #QUEUE_ID = 'delivery'
26
24
  static #TTL_SEEN_PUBLIC = 3 * 24 * 60 * 60 * 1000 // 3 days
27
25
  static #MAX_SEEN_PUBLIC = 1000000
28
26
  #actorStorage
29
- #activityHandler
30
27
  #formatter
31
28
  #logger
32
29
  #client
33
- #deliverAllQueue
34
- #deliverBotQueue
30
+ #jobQueue
35
31
  #seenPublic
36
32
 
37
- constructor (actorStorage, activityHandler, formatter, logger, client) {
33
+ constructor (actorStorage, formatter, logger, client, jobQueue) {
34
+ assert.strictEqual(typeof actorStorage, 'object')
35
+ assert.strictEqual(typeof formatter, 'object')
36
+ assert.strictEqual(typeof logger, 'object')
37
+ assert.strictEqual(typeof client, 'object')
38
+ assert.strictEqual(typeof jobQueue, 'object')
39
+
38
40
  this.#actorStorage = actorStorage
39
- this.#activityHandler = activityHandler
40
41
  this.#formatter = formatter
41
42
  this.#client = client
42
43
  this.#logger = logger.child({ class: this.constructor.name })
43
- this.#deliverAllQueue = new PQueue({ max: ActivityDeliverer.#MAX_ALL_QUEUE })
44
- this.#deliverBotQueue = new PQueue({ max: ActivityDeliverer.#MAX_BOT_QUEUE })
44
+ this.#jobQueue = jobQueue
45
45
  this.#seenPublic = new TTLCache({
46
46
  ttl: ActivityDeliverer.#TTL_SEEN_PUBLIC,
47
47
  max: ActivityDeliverer.#MAX_SEEN_PUBLIC
@@ -96,40 +96,20 @@ export class ActivityDeliverer {
96
96
  }
97
97
 
98
98
  async deliverTo (activity, bot) {
99
- this.#deliverBotQueue.add(() =>
100
- this.#deliverTo(activity, bot)
99
+ await this.#jobQueue.enqueue(
100
+ ActivityDeliverer.#QUEUE_ID,
101
+ { botUsername: bot.username, activity: await activity.export() }
101
102
  )
102
103
  }
103
104
 
104
105
  async onIdle () {
105
106
  this.#logger.debug('Awaiting delivery queues')
106
- await this.#deliverAllQueue.onIdle()
107
- await this.#deliverBotQueue.onIdle()
107
+ await this.#jobQueue.onIdle(ActivityDeliverer.#QUEUE_ID)
108
108
  this.#logger.debug('Done awaiting delivery queues')
109
109
  }
110
110
 
111
- async #deliverTo (activity, bot) {
112
- if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
113
- this.#logger.info(
114
- { activity: activity.id, username: bot.username },
115
- 'skipping redelivery for activity already in the inbox'
116
- )
117
- return
118
- }
119
- try {
120
- await this.#activityHandler.handleActivity(bot, activity)
121
- } catch (err) {
122
- this.#logger.warn(err)
123
- }
124
- this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
125
- await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
126
- this.#logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
127
- }
128
-
129
111
  async deliverToAll (activity, bots) {
130
- this.#deliverAllQueue.add(() =>
131
- this.#deliverToAll(activity, bots)
132
- )
112
+ await this.#deliverToAll(activity, bots)
133
113
  }
134
114
 
135
115
  async #deliverToAll (activity, bots) {
@@ -1,8 +1,6 @@
1
1
  import assert from 'node:assert'
2
2
  import as2 from './activitystreams.js'
3
3
  import { LRUCache } from 'lru-cache'
4
- import PQueue from 'p-queue'
5
- import { setTimeout } from 'node:timers/promises'
6
4
 
7
5
  const NS = 'https://www.w3.org/ns/activitystreams#'
8
6
 
@@ -12,9 +10,9 @@ const COLLECTION_TYPES = [
12
10
  ]
13
11
 
14
12
  export class ActivityDistributor {
13
+ static #DISTRIBUTION_QUEUE_ID = 'distribution'
14
+ static #DELIVERY_QUEUE_ID = 'delivery'
15
15
  static #MAX_CACHE_SIZE = 1000000
16
- static #CONCURRENCY = 32
17
- static #MAX_ATTEMPTS = 16
18
16
  static #PUBLIC = [
19
17
  'https://www.w3.org/ns/activitystreams#Public',
20
18
  'as:Public',
@@ -26,23 +24,27 @@ export class ActivityDistributor {
26
24
  #actorStorage = null
27
25
  #directInboxCache = null
28
26
  #sharedInboxCache = null
29
- #queue = null
30
- #retryQueue = null
31
27
  #logger = null
32
-
33
- constructor (client, formatter, actorStorage, logger = null) {
28
+ #jobQueue = null
29
+
30
+ constructor (client, formatter, actorStorage, logger, jobQueue) {
31
+ assert.ok(client)
32
+ assert.ok(formatter)
33
+ assert.ok(actorStorage)
34
+ assert.ok(logger)
35
+ assert.ok(jobQueue)
34
36
  this.#client = client
35
37
  this.#formatter = formatter
36
38
  this.#actorStorage = actorStorage
37
39
  this.#logger = logger.child({ class: this.constructor.name })
40
+ this.#jobQueue = jobQueue
38
41
  this.#directInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE })
39
42
  this.#sharedInboxCache = new LRUCache({ max: ActivityDistributor.#MAX_CACHE_SIZE })
40
- this.#queue = new PQueue({ concurrency: ActivityDistributor.#CONCURRENCY })
41
- this.#retryQueue = new PQueue()
42
43
  }
43
44
 
44
45
  async distribute (activity, username) {
45
46
  const stripped = await this.#strip(activity)
47
+ const raw = await stripped.export()
46
48
  const actorId = this.#formatter.format({ username })
47
49
 
48
50
  const delivered = new Set()
@@ -52,8 +54,11 @@ export class ActivityDistributor {
52
54
  if (await this.#isLocal(recipient)) {
53
55
  if (recipient !== actorId && !localDelivered.has(recipient)) {
54
56
  localDelivered.add(recipient)
55
- this.#queue.add(() =>
56
- this.#deliverLocal(recipient, stripped, username))
57
+ const parts = this.#formatter.unformat(recipient)
58
+ await this.#jobQueue.enqueue(
59
+ ActivityDistributor.#DELIVERY_QUEUE_ID,
60
+ { botUsername: parts.username, activity: raw }
61
+ )
57
62
  }
58
63
  } else {
59
64
  const inbox = await this.#getInbox(recipient, username)
@@ -61,8 +66,9 @@ export class ActivityDistributor {
61
66
  this.#logger.warn({ id: recipient.id }, 'No inbox')
62
67
  } else if (!delivered.has(inbox)) {
63
68
  delivered.add(inbox)
64
- this.#queue.add(() =>
65
- this.#deliver(inbox, stripped, username)
69
+ await this.#jobQueue.enqueue(
70
+ ActivityDistributor.#DISTRIBUTION_QUEUE_ID,
71
+ { inbox, activity: raw, username }
66
72
  )
67
73
  }
68
74
  }
@@ -72,8 +78,11 @@ export class ActivityDistributor {
72
78
  if (await this.#isLocal(recipient)) {
73
79
  if (recipient !== actorId && !localDelivered.has(recipient)) {
74
80
  localDelivered.add(recipient)
75
- this.#queue.add(() =>
76
- this.#deliverLocal(recipient, stripped, username))
81
+ const parts = this.#formatter.unformat(recipient)
82
+ await this.#jobQueue.enqueue(
83
+ ActivityDistributor.#DELIVERY_QUEUE_ID,
84
+ { botUsername: parts.username, activity: raw }
85
+ )
77
86
  }
78
87
  } else {
79
88
  const inbox = await this.#getDirectInbox(recipient, username)
@@ -81,8 +90,9 @@ export class ActivityDistributor {
81
90
  this.#logger.warn({ id: recipient.id }, 'No direct inbox')
82
91
  } else if (!delivered.has(inbox)) {
83
92
  delivered.add(inbox)
84
- this.#queue.add(() =>
85
- this.#deliver(inbox, stripped, username)
93
+ await this.#jobQueue.enqueue(
94
+ ActivityDistributor.#DISTRIBUTION_QUEUE_ID,
95
+ { inbox, activity: raw, username }
86
96
  )
87
97
  }
88
98
  }
@@ -90,8 +100,8 @@ export class ActivityDistributor {
90
100
  }
91
101
 
92
102
  async onIdle () {
93
- await this.#retryQueue.onIdle()
94
- await this.#queue.onIdle()
103
+ await this.#jobQueue.onIdle(ActivityDistributor.#DELIVERY_QUEUE_ID)
104
+ await this.#jobQueue.onIdle(ActivityDistributor.#DISTRIBUTION_QUEUE_ID)
95
105
  }
96
106
 
97
107
  async * #public (activity, username) {
@@ -237,58 +247,10 @@ export class ActivityDistributor {
237
247
  return await as2.import(exported)
238
248
  }
239
249
 
240
- async #deliver (inbox, activity, username, attempt = 1) {
241
- try {
242
- await this.#client.post(inbox, activity, username)
243
- this.#logInfo(`Delivered ${activity.id} to ${inbox}`)
244
- } catch (error) {
245
- if (!error.status) {
246
- this.#logError(`Could not deliver ${activity.id} to ${inbox}: ${error.message}`)
247
- this.#logError(error.stack)
248
- } else if (error.status >= 300 && error.status < 400) {
249
- this.#logError(`Unexpected redirect code delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
250
- } else if (error.status >= 400 && error.status < 500) {
251
- this.#logError(`Bad request delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
252
- } else if (error.status >= 500 && error.status < 600) {
253
- if (attempt >= ActivityDistributor.#MAX_ATTEMPTS) {
254
- this.#logError(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; giving up after ${attempt} attempts`)
255
- }
256
- const delay = Math.round((2 ** (attempt - 1) * 1000) * (0.5 + Math.random()))
257
- this.#logWarning(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; will retry in ${delay} ms (${attempt} of ${ActivityDistributor.#MAX_ATTEMPTS})`)
258
- this.#retryQueue.add(() => setTimeout(delay).then(() => this.#deliver(inbox, activity, username, attempt + 1)))
259
- }
260
- }
261
- }
262
-
263
- #logError (message) {
264
- if (this.#logger) {
265
- this.#logger.error(message)
266
- }
267
- }
268
-
269
- #logWarning (message) {
270
- if (this.#logger) {
271
- this.#logger.warn(message)
272
- }
273
- }
274
-
275
- #logInfo (message) {
276
- if (this.#logger) {
277
- this.#logger.info(message)
278
- }
279
- }
280
-
281
250
  #isLocal (id) {
282
251
  return this.#formatter.isLocal(id)
283
252
  }
284
253
 
285
- async #deliverLocal (id, activity) {
286
- const username = this.#formatter.getUserName(id)
287
- if (username) {
288
- await this.#actorStorage.addToCollection(username, 'inbox', activity)
289
- }
290
- }
291
-
292
254
  #isLocalActor (parts) {
293
255
  return parts.username && !parts.type && !parts.collection
294
256
  }
package/lib/app.js CHANGED
@@ -29,8 +29,12 @@ import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js'
29
29
  import { Digester } from './digester.js'
30
30
  import { runMigrations } from './migrations/index.js'
31
31
  import { ActivityDeliverer } from './activitydeliverer.js'
32
+ import { JobQueue } from './jobqueue.js'
33
+ import { JobReaper } from './jobreaper.js'
34
+ import { DeliveryWorker } from './deliveryworker.js'
35
+ import { DistributionWorker } from './distributionworker.js'
32
36
 
33
- export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
37
+ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8) {
34
38
  const logger = Logger({
35
39
  level: logLevel
36
40
  })
@@ -50,11 +54,13 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
50
54
  new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
51
55
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
52
56
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
57
+ const jobQueue = new JobQueue(connection, logger)
53
58
  const distributor = new ActivityDistributor(
54
59
  client,
55
60
  formatter,
56
61
  actorStorage,
57
- logger
62
+ logger,
63
+ jobQueue
58
64
  )
59
65
  const authorizer = new Authorizer(actorStorage, formatter, client)
60
66
  const cache = new ObjectCache({
@@ -74,10 +80,10 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
74
80
  )
75
81
  const deliverer = new ActivityDeliverer(
76
82
  actorStorage,
77
- activityHandler,
78
83
  formatter,
79
84
  logger,
80
- client
85
+ client,
86
+ jobQueue
81
87
  )
82
88
 
83
89
  // TODO: Make an endpoint for tagged objects
@@ -99,6 +105,41 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
99
105
  ))
100
106
  )
101
107
 
108
+ const deliveryWorkers = new Array(deliveryWorkerCount)
109
+ const deliveryWorkerRuns = new Array(deliveryWorkerCount)
110
+
111
+ for (let i = 0; i < deliveryWorkers.length; i++) {
112
+ deliveryWorkers[i] = new DeliveryWorker(
113
+ jobQueue,
114
+ actorStorage,
115
+ activityHandler,
116
+ logger,
117
+ bots
118
+ )
119
+ deliveryWorkerRuns[i] = deliveryWorkers[i].run().catch((err) => {
120
+ logger.error({ err, workerIndex: i }, 'unexpected error in delivery worker')
121
+ })
122
+ }
123
+
124
+ const distributionWorkers = new Array(distributionWorkerCount)
125
+ const distributionWorkerRuns = new Array(distributionWorkerCount)
126
+
127
+ for (let i = 0; i < distributionWorkers.length; i++) {
128
+ distributionWorkers[i] = new DistributionWorker(
129
+ jobQueue,
130
+ client,
131
+ logger
132
+ )
133
+ distributionWorkerRuns[i] = distributionWorkers[i].run().catch((err) => {
134
+ logger.error({ err, workerIndex: i }, 'unexpected error in distribution worker')
135
+ })
136
+ }
137
+
138
+ const jobReaper = new JobReaper(jobQueue, logger)
139
+ const jobReaperRun = jobReaper.run().catch((err) => {
140
+ logger.error({ err }, 'unexpected error in job reaper')
141
+ })
142
+
102
143
  const app = express()
103
144
 
104
145
  app.locals = {
@@ -117,7 +158,8 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
117
158
  bots,
118
159
  activityHandler,
119
160
  origin,
120
- deliverer
161
+ deliverer,
162
+ deliveryWorkers
121
163
  }
122
164
 
123
165
  app.use(HTTPLogger({
@@ -196,10 +238,20 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
196
238
 
197
239
  app.cleanup = async () => {
198
240
  logger.info('Closing app')
199
- logger.info('Waiting for distributor queue')
200
- await distributor.onIdle()
241
+ for (const worker of deliveryWorkers) {
242
+ worker.stop()
243
+ }
244
+ for (const worker of distributionWorkers) {
245
+ worker.stop()
246
+ }
247
+ jobReaper.stop()
248
+ jobQueue.abort()
249
+ await Promise.allSettled(deliveryWorkerRuns)
250
+ await Promise.allSettled(distributionWorkerRuns)
251
+ await jobReaperRun
201
252
  logger.info('Closing database connection')
202
253
  await connection.close()
254
+ logger.info('Done')
203
255
  }
204
256
 
205
257
  return app
package/lib/authorizer.js CHANGED
@@ -1,14 +1,26 @@
1
1
  import as2 from './activitystreams.js'
2
2
 
3
+ const NS = 'https://www.w3.org/ns/activitystreams#'
4
+
5
+ const PUBLICS = [
6
+ `${NS}Public`,
7
+ 'as:Public',
8
+ 'Public'
9
+ ]
10
+
11
+ const COLLECTION_TYPES = [
12
+ `${NS}Collection`,
13
+ `${NS}OrderedCollection`
14
+ ]
15
+
3
16
  export class Authorizer {
4
- #PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
5
17
  #actorStorage = null
6
18
  #formatter = null
7
- #activityPubClient = null
8
- constructor (actorStorage, formatter, activityPubClient) {
19
+ #client = null
20
+ constructor (actorStorage, formatter, client) {
9
21
  this.#actorStorage = actorStorage
10
22
  this.#formatter = formatter
11
- this.#activityPubClient = activityPubClient
23
+ this.#client = client
12
24
  }
13
25
 
14
26
  async canRead (actor, object) {
@@ -40,7 +52,7 @@ export class Authorizer {
40
52
  async #canReadLocal (actor, object) {
41
53
  const recipients = this.#getRecipients(object)
42
54
  if (!actor) {
43
- return recipients.has(this.#PUBLIC)
55
+ return PUBLICS.some(id => recipients.has(id))
44
56
  }
45
57
  const ownerId = (await this.#getOwner(object))?.id
46
58
  if (!ownerId) {
@@ -63,25 +75,38 @@ export class Authorizer {
63
75
  if (recipients.has(actor.id)) {
64
76
  return true
65
77
  }
66
- if (recipients.has(this.#PUBLIC)) {
78
+ if (PUBLICS.some(id => recipients.has(id))) {
67
79
  return true
68
80
  }
69
- const followers = this.#formatter.format({ username: ownerName, collection: 'followers' })
70
- if (recipients.has(followers) && await this.#actorStorage.isInCollection(ownerName, 'followers', actor)) {
71
- return true
81
+
82
+ const lcolls = this.#getLocalCollections(recipients)
83
+
84
+ for (const id of lcolls) {
85
+ if (await this.#isInLocalCollection(actor, id)) {
86
+ return true
87
+ }
88
+ }
89
+
90
+ const rcolls = await this.#getRemoteCollections(recipients, ownerName)
91
+
92
+ for (const id of rcolls) {
93
+ if (await this.#isInRemoteCollection(actor, id, ownerName)) {
94
+ return true
95
+ }
72
96
  }
97
+
73
98
  return false
74
99
  }
75
100
 
76
101
  async #canReadRemote (actor, object) {
77
102
  const recipients = this.#getRecipients(object)
78
103
  if (!actor) {
79
- return recipients.has(this.#PUBLIC)
104
+ return PUBLICS.some(id => recipients.has(id))
80
105
  }
81
106
  if (recipients.has(actor.id)) {
82
107
  return true
83
108
  }
84
- if (recipients.has(this.#PUBLIC)) {
109
+ if (PUBLICS.some(id => recipients.has(id))) {
85
110
  return true
86
111
  }
87
112
  // TODO: check if it's to followers, actor is local, and actor
@@ -137,4 +162,82 @@ export class Authorizer {
137
162
  }
138
163
  return recipientIds
139
164
  }
165
+
166
+ #getLocalCollections (recipients) {
167
+ const lcolls = new Set()
168
+ for (const recipient of recipients) {
169
+ if (this.#isLocalCollection(recipient)) {
170
+ lcolls.add(recipient)
171
+ }
172
+ }
173
+ return lcolls
174
+ }
175
+
176
+ #isLocalCollection (recipient) {
177
+ if (!this.#formatter.isLocal(recipient)) {
178
+ return false
179
+ } else {
180
+ const parts = this.#formatter.unformat(recipient)
181
+ return !!(parts.username && parts.collection && !parts.type)
182
+ }
183
+ }
184
+
185
+ #isInLocalCollection (actor, recipient) {
186
+ const parts = this.#formatter.unformat(recipient)
187
+ return this.#actorStorage.isInCollection(
188
+ parts.username,
189
+ parts.collection,
190
+ actor
191
+ )
192
+ }
193
+
194
+ async #getRemoteCollections (recipients, ownerName) {
195
+ const rcolls = new Set()
196
+ for (const recipient of recipients) {
197
+ if (await this.#isRemoteCollection(recipient, ownerName)) {
198
+ rcolls.add(recipient)
199
+ }
200
+ }
201
+ return rcolls
202
+ }
203
+
204
+ async #isRemoteCollection (recipient, ownerName) {
205
+ if (this.#formatter.isLocal(recipient)) {
206
+ return false
207
+ } else {
208
+ const obj = await this.#client.get(recipient, ownerName)
209
+
210
+ return (Array.isArray(obj.type))
211
+ ? obj.type.some(item => COLLECTION_TYPES.includes(item))
212
+ : COLLECTION_TYPES.includes(obj.type)
213
+ }
214
+ }
215
+
216
+ async #isInRemoteCollection (actor, id, ownerName) {
217
+ const coll = await this.#client.get(id, ownerName)
218
+ const collOwner = await this.#getOwner(coll)
219
+ const collOwnerFull = await this.#client.get(collOwner.id, ownerName)
220
+
221
+ // Special case for followers, following collections, since we track
222
+ // that information locally, too
223
+
224
+ if (this.#formatter.isLocal(actor.id)) {
225
+ const { username } = this.#formatter.unformat(actor.id)
226
+ if (coll.id === collOwnerFull.followers.first.id) {
227
+ return await this.#actorStorage.isInCollection(username, 'following', collOwnerFull)
228
+ } else if (coll.id === collOwnerFull.following.first.id) {
229
+ return await this.#actorStorage.isInCollection(username, 'followers', collOwnerFull)
230
+ }
231
+ }
232
+
233
+ // Worst case!
234
+
235
+ for await (const item of this.#client.items(coll.id, ownerName)) {
236
+ if (item.id === actor.id) {
237
+ return true
238
+ }
239
+ }
240
+
241
+ return false
242
+ }
140
243
  }
package/lib/botcontext.js CHANGED
@@ -369,10 +369,12 @@ export class BotContext {
369
369
  const owners = obj.attributedTo
370
370
  ? Array.from(obj.attributedTo).map((owner) => owner.id)
371
371
  : Array.from(obj.actor).map((owner) => owner.id)
372
+ const summary = `${this.#botId} shared "${await this.#nameOf(obj)}"`
372
373
  const activity = await this.#doActivity({
373
374
  type: 'Announce',
374
- summary: {
375
- en: `${this.#botId} shared "${await this.#nameOf(obj)}"`
375
+ summary,
376
+ summaryMap: {
377
+ en: summary
376
378
  },
377
379
  object: obj.id,
378
380
  to: [
@@ -380,7 +382,7 @@ export class BotContext {
380
382
  username: this.#botId,
381
383
  collection: 'followers'
382
384
  }),
383
- 'https://www.w3.org/ns/activitystreams#Public'
385
+ 'as:Public'
384
386
  ],
385
387
  cc: owners
386
388
  })
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert'
2
+
3
+ import { nanoid } from 'nanoid'
4
+
5
+ import as2 from './activitystreams.js'
6
+ import BotMaker from './botmaker.js'
7
+
8
+ export class DeliveryWorker {
9
+ static #QUEUE_ID = 'delivery'
10
+ #queue
11
+ #actorStorage
12
+ #activityHandler
13
+ #logger
14
+ #running
15
+ #workerId
16
+ #bots
17
+
18
+ constructor (queue, actorStorage, activityHandler, logger, bots) {
19
+ this.#queue = queue
20
+ this.#actorStorage = actorStorage
21
+ this.#activityHandler = activityHandler
22
+ this.#workerId = nanoid()
23
+ this.#logger = logger.child({
24
+ class: this.constructor.name,
25
+ worker: this.#workerId
26
+ })
27
+ this.#bots = bots
28
+ }
29
+
30
+ async run () {
31
+ this.#running = true
32
+ while (this.#running) {
33
+ let jobId
34
+ let payload
35
+ try {
36
+ this.#logger.debug('dequeueing');
37
+ ({ jobId, payload } = await this.#queue.dequeue(
38
+ DeliveryWorker.#QUEUE_ID,
39
+ this.#workerId
40
+ ))
41
+ this.#logger.debug({ jobId, payload }, 'got a job')
42
+ const activity = await as2.import(payload.activity)
43
+ assert.ok(payload.botUsername)
44
+ const bot = await BotMaker.makeBot(this.#bots, payload.botUsername)
45
+ assert.ok(bot)
46
+ assert.ok(bot.username)
47
+ this.#logger.debug(
48
+ { bot: bot.username, activity: activity.id, jobId },
49
+ 'delivering to bot'
50
+ )
51
+ await this.#deliverTo(activity, bot)
52
+ this.#logger.debug({ bot: bot.username, activity: activity.id, jobId }, 'done')
53
+ await this.#queue.complete(jobId, this.#workerId)
54
+ } catch (err) {
55
+ if (err?.name === 'AbortError') {
56
+ this.#logger.info('Worker received abort signal')
57
+ break
58
+ }
59
+ this.#logger.warn({ err, jobId }, 'Error delivering to bot')
60
+ if (jobId) {
61
+ await this.#queue.release(jobId, this.#workerId)
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ stop () {
68
+ this.#running = false
69
+ }
70
+
71
+ async #deliverTo (activity, bot) {
72
+ assert.ok(activity)
73
+ assert.strictEqual(typeof activity, 'object')
74
+ assert.ok(bot)
75
+ assert.strictEqual(typeof bot, 'object')
76
+ assert.strictEqual(typeof bot.username, 'string')
77
+ if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
78
+ this.#logger.info(
79
+ { activity: activity.id, username: bot.username },
80
+ 'skipping redelivery for activity already in the inbox'
81
+ )
82
+ return
83
+ }
84
+ try {
85
+ await this.#activityHandler.handleActivity(bot, activity)
86
+ } catch (err) {
87
+ this.#logger.warn(
88
+ { err, activity: activity.id, bot: bot.username },
89
+ 'handler failed for activity'
90
+ )
91
+ }
92
+ this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
93
+ await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
94
+ this.#logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
95
+ }
96
+ }
@@ -0,0 +1,79 @@
1
+ import { nanoid } from 'nanoid'
2
+
3
+ import as2 from './activitystreams.js'
4
+
5
+ export class DistributionWorker {
6
+ static #QUEUE_ID = 'distribution'
7
+ static #MAX_ATTEMPTS = 16
8
+ #jobQueue
9
+ #client
10
+ #logger
11
+ #running
12
+ #workerId
13
+
14
+ constructor (jobQueue, client, logger) {
15
+ this.#jobQueue = jobQueue
16
+ this.#client = client
17
+ this.#workerId = nanoid()
18
+ this.#logger = logger.child({
19
+ class: this.constructor.name,
20
+ workerId: this.#workerId
21
+ })
22
+ }
23
+
24
+ async run () {
25
+ this.#running = true
26
+ while (this.#running) {
27
+ let jobId
28
+ let payload
29
+ let attempts
30
+ try {
31
+ this.#logger.debug('dequeueing');
32
+ ({ jobId, payload, attempts } = await this.#jobQueue.dequeue(
33
+ DistributionWorker.#QUEUE_ID,
34
+ this.#workerId
35
+ ))
36
+ this.#logger.debug({ jobId, payload, attempts }, 'got a job')
37
+ const { inbox, activity, username } = payload
38
+ const activityObj = await as2.import(activity)
39
+ try {
40
+ await this.#client.post(inbox, activityObj, username)
41
+ this.#logger.info(`Delivered ${activity.id} to ${inbox}`)
42
+ await this.#jobQueue.complete(jobId, this.#workerId)
43
+ this.#logger.info({ jobId }, 'completed job')
44
+ } catch (error) {
45
+ if (!error.status) {
46
+ this.#logger.error(`Could not deliver ${activity.id} to ${inbox}: ${error.message}`)
47
+ this.#logger.error(error.stack)
48
+ } else if (error.status >= 300 && error.status < 400) {
49
+ this.#logger.error(`Unexpected redirect code delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
50
+ } else if (error.status >= 400 && error.status < 500) {
51
+ this.#logger.error(`Bad request delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}`)
52
+ } else if (error.status >= 500 && error.status < 600) {
53
+ if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
54
+ this.#logger.error(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; giving up after ${attempts} attempts`)
55
+ await this.#jobQueue.complete(jobId, this.#workerId)
56
+ } else {
57
+ const delay = Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
58
+ this.#logger.warn(`Server error delivering ${activity.id} to ${inbox}: ${error.status} ${error.message}; will retry in ${delay} ms (${attempts} of ${DistributionWorker.#MAX_ATTEMPTS})`)
59
+ await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
60
+ }
61
+ }
62
+ }
63
+ } catch (err) {
64
+ if (err?.name === 'AbortError') {
65
+ this.#logger.info('Worker received abort signal')
66
+ break
67
+ }
68
+ this.#logger.warn({ err, jobId }, 'Error delivering to bot')
69
+ if (jobId) {
70
+ await this.#jobQueue.release(jobId, this.#workerId)
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ stop () {
77
+ this.#running = false
78
+ }
79
+ }
@@ -0,0 +1,224 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises'
2
+ import assert from 'node:assert'
3
+
4
+ import { nanoid } from 'nanoid'
5
+ import { QueryTypes } from 'sequelize'
6
+
7
+ const MAX_DELAY = 30000
8
+ const DEFAULT_PRIORITY = 1000000
9
+
10
+ export class JobQueue {
11
+ #connection
12
+ #logger
13
+ #ac
14
+ #wakeAc
15
+
16
+ constructor (connection, logger) {
17
+ this.#connection = connection
18
+ this.#logger = logger.child({ class: this.constructor.name })
19
+ this.#ac = new AbortController()
20
+ this.#wakeAc = new AbortController()
21
+ }
22
+
23
+ async enqueue (queueId, payload = {}, options = { priority: DEFAULT_PRIORITY }) {
24
+ const { priority } = options
25
+ const jobId = nanoid()
26
+ const encoded = JSON.stringify(payload)
27
+ await this.#connection.query(
28
+ `INSERT INTO job (job_id, queue_id, payload, priority)
29
+ VALUES (?, ?, ?, ?);`,
30
+ { replacements: [jobId, queueId, encoded, priority] }
31
+ )
32
+ this.#logger.debug({ method: 'enqueue', queueId, jobId }, 'enqueued job')
33
+ const old = this.#wakeAc
34
+ this.#wakeAc = new AbortController()
35
+ old.abort()
36
+ return jobId
37
+ }
38
+
39
+ async dequeue (queueId, jobRunnerId) {
40
+ let res
41
+ let delay = 50
42
+
43
+ this.#logger.debug(
44
+ { method: 'dequeue', queueId, jobRunnerId },
45
+ 'dequeueing job'
46
+ )
47
+
48
+ while (!res) {
49
+ res = await this.#claimNextJob(queueId, jobRunnerId)
50
+ if (res) break
51
+ delay = Math.min(delay * 2, MAX_DELAY)
52
+ this.#logger.debug({ method: 'dequeue', delay }, 'sleeping')
53
+ try {
54
+ await sleep(delay, null, { signal: AbortSignal.any([this.#ac.signal, this.#wakeAc.signal]) })
55
+ } catch (err) {
56
+ if (this.#ac.signal.aborted) throw err
57
+ delay = 50
58
+ }
59
+ }
60
+
61
+ const jobId = res.job_id
62
+ const payload = JSON.parse(res.payload)
63
+ const attempts = res.attempts
64
+
65
+ this.#logger.debug({ method: 'dequeue', queueId, jobId }, 'dequeued job')
66
+ return { jobId, payload, attempts }
67
+ }
68
+
69
+ async complete (jobId, jobRunnerId) {
70
+ await this.#connection.query(`
71
+ DELETE FROM job
72
+ WHERE job_id = ? AND claimed_by = ?;`,
73
+ { replacements: [jobId, jobRunnerId] }
74
+ )
75
+ this.#logger.debug({ method: 'complete', jobRunnerId, jobId }, 'completed job')
76
+ }
77
+
78
+ async release (jobId, jobRunnerId) {
79
+ await this.#connection.query(`
80
+ UPDATE job
81
+ SET claimed_by = NULL,
82
+ claimed_at = NULL,
83
+ updated_at = CURRENT_TIMESTAMP
84
+ WHERE job_id = ? AND claimed_by = ?;`,
85
+ { replacements: [jobId, jobRunnerId] }
86
+ )
87
+ this.#logger.debug({ method: 'release', jobRunnerId, jobId }, 'released job')
88
+ }
89
+
90
+ async retryAfter (jobId, jobRunnerId, delay) {
91
+ assert.ok(jobId)
92
+ assert.strictEqual(typeof jobId, 'string')
93
+ assert.ok(jobRunnerId)
94
+ assert.strictEqual(typeof jobRunnerId, 'string')
95
+ assert.ok(delay)
96
+ assert.strictEqual(typeof delay, 'number')
97
+
98
+ const retryAfter = new Date(Date.now() + delay)
99
+
100
+ await this.#connection.query(`
101
+ UPDATE job
102
+ SET claimed_by = NULL,
103
+ claimed_at = NULL,
104
+ updated_at = CURRENT_TIMESTAMP,
105
+ retry_after = ?
106
+ WHERE job_id = ? AND claimed_by = ?;`,
107
+ { replacements: [retryAfter, jobId, jobRunnerId] }
108
+ )
109
+ this.#logger.debug(
110
+ { method: 'retry', jobRunnerId, jobId, delay },
111
+ 'released job'
112
+ )
113
+ }
114
+
115
+ async onIdle (queueId) {
116
+ assert.ok(queueId)
117
+ assert.strictEqual(typeof queueId, 'string')
118
+
119
+ let delay = 50
120
+
121
+ this.#logger.debug({ method: 'onIdle', queueId }, 'getting queue size')
122
+
123
+ let jobCount = await this.#countJobs(queueId)
124
+
125
+ this.#logger.debug({ method: 'onIdle', queueId, jobCount }, 'got queue size')
126
+
127
+ while (jobCount > 0) {
128
+ delay = Math.min(delay * 2, MAX_DELAY)
129
+ this.#logger.debug({ method: 'onIdle', delay }, 'sleeping')
130
+ await sleep(delay, null, { signal: this.#ac.signal })
131
+ jobCount = await this.#countJobs(queueId)
132
+ this.#logger.debug({ method: 'onIdle', queueId, jobCount }, 'got queue size')
133
+ }
134
+ this.#logger.debug({ method: 'onIdle', queueId }, 'Now idle')
135
+ }
136
+
137
+ async sweep (timeout) {
138
+ const cutoff = new Date(Date.now() - timeout)
139
+ const [, meta] = await this.#connection.query(`
140
+ UPDATE job
141
+ SET claimed_by = NULL,
142
+ claimed_at = NULL,
143
+ updated_at = CURRENT_TIMESTAMP
144
+ WHERE claimed_at < ?;`,
145
+ { replacements: [cutoff] }
146
+ )
147
+ const count = meta?.changes ?? meta?.rowCount ?? 0
148
+ if (count > 0) {
149
+ this.#logger.warn({ method: 'sweep', count }, 'released stalled jobs')
150
+ } else {
151
+ this.#logger.debug({ method: 'sweep' }, 'no stalled jobs found')
152
+ }
153
+ }
154
+
155
+ abort () {
156
+ this.#logger.debug('Aborting queue server')
157
+ this.#ac.abort()
158
+ }
159
+
160
+ async #countJobs (queueId) {
161
+ this.#logger.debug({ method: '#countJobs', queueId }, 'checking queue size')
162
+ const rows = await this.#connection.query(`
163
+ SELECT COUNT(*) as job_count
164
+ FROM JOB
165
+ WHERE queue_id = ?;
166
+ `, { replacements: [queueId] })
167
+ if (rows[0]) {
168
+ const size = rows[0][0].job_count
169
+ this.#logger.debug({ method: '#countJobs', queueId, size }, 'got queue size')
170
+ return size
171
+ } else {
172
+ this.#logger.debug({ method: '#countJobs', queueId }, 'no queue size')
173
+ return 0
174
+ }
175
+ }
176
+
177
+ async #claimNextJob (queueId, jobRunnerId) {
178
+ this.#logger.debug(
179
+ { method: '#claimNextJob', queueId, jobRunnerId },
180
+ 'claiming next job'
181
+ )
182
+ const skipLocked = this.#connection.getDialect() === 'postgres' ? 'FOR UPDATE SKIP LOCKED' : ''
183
+ const rows = await this.#connection.query(`
184
+ UPDATE job
185
+ SET claimed_by = ?,
186
+ claimed_at = CURRENT_TIMESTAMP,
187
+ updated_at = CURRENT_TIMESTAMP,
188
+ attempts = attempts + 1
189
+ WHERE job_id = (
190
+ SELECT job_id
191
+ FROM job
192
+ WHERE queue_id = ?
193
+ AND (
194
+ claimed_by IS NULL
195
+ )
196
+ AND (
197
+ retry_after IS NULL OR
198
+ retry_after < ?
199
+ )
200
+ ORDER BY priority ASC, created_at ASC
201
+ LIMIT 1
202
+ ${skipLocked}
203
+ )
204
+ RETURNING job_id, payload, attempts;`,
205
+ {
206
+ replacements: [jobRunnerId, queueId, new Date()],
207
+ type: QueryTypes.SELECT
208
+ }
209
+ )
210
+ if (rows[0]) {
211
+ this.#logger.debug(
212
+ { method: '#claimNextJob', queueId, jobRunnerId, jobId: rows[0].job_id },
213
+ 'got a job'
214
+ )
215
+ return rows[0]
216
+ } else {
217
+ this.#logger.debug(
218
+ { method: '#claimNextJob', queueId, jobRunnerId },
219
+ 'no job found'
220
+ )
221
+ return null
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,38 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises'
2
+
3
+ const DEFAULT_TIMEOUT = 5 * 60 * 1000 // 5 minutes
4
+ const DEFAULT_INTERVAL = 60 * 1000 // 1 minute
5
+
6
+ export class JobReaper {
7
+ #jobQueue
8
+ #logger
9
+ #timeout
10
+ #interval
11
+ #ac
12
+
13
+ constructor (jobQueue, logger, { timeout = DEFAULT_TIMEOUT, interval = DEFAULT_INTERVAL } = {}) {
14
+ this.#jobQueue = jobQueue
15
+ this.#logger = logger.child({ class: this.constructor.name })
16
+ this.#timeout = timeout
17
+ this.#interval = interval
18
+ this.#ac = new AbortController()
19
+ }
20
+
21
+ async run () {
22
+ this.#logger.debug('JobReaper started')
23
+ while (!this.#ac.signal.aborted) {
24
+ try {
25
+ await sleep(this.#interval, null, { signal: this.#ac.signal })
26
+ } catch {
27
+ break
28
+ }
29
+ await this.#jobQueue.sweep(this.#timeout)
30
+ }
31
+ this.#logger.debug('JobReaper stopped')
32
+ }
33
+
34
+ stop () {
35
+ this.#logger.debug('Stopping JobReaper')
36
+ this.#ac.abort()
37
+ }
38
+ }
@@ -0,0 +1,20 @@
1
+ export const id = '003-jobqueue'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ CREATE TABLE job (
6
+ job_id char(21) NOT NULL PRIMARY KEY,
7
+ queue_id varchar(64) NOT NULL,
8
+ priority INTEGER,
9
+ payload TEXT,
10
+ claimed_at TIMESTAMP,
11
+ claimed_by varchar(64),
12
+ attempts INTEGER default 0,
13
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
15
+ );
16
+ `, queryOptions)
17
+ await connection.query(`
18
+ CREATE INDEX job_queue_id on job (queue_id);
19
+ `, queryOptions)
20
+ }
@@ -0,0 +1,8 @@
1
+ export const id = '003-jobqueue'
2
+
3
+ export async function up (connection, queryOptions = {}) {
4
+ await connection.query(`
5
+ ALTER TABLE job
6
+ ADD COLUMN retry_after TIMESTAMP;
7
+ `, queryOptions)
8
+ }
@@ -1,10 +1,19 @@
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'
1
+ import { fileURLToPath } from 'url'
2
+ import { dirname, resolve } from 'path'
3
+ import { readdirSync } from 'fs'
3
4
 
4
- const migrations = [
5
- { id: initialId, up: initialUp },
6
- { id: lastId, up: lastUp }
7
- ]
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ const migrationFiles = readdirSync(__dirname)
8
+ .filter(file => /^\d{3}-.*\.js$/.test(file))
9
+ .sort()
10
+
11
+ const migrations = await Promise.all(
12
+ migrationFiles.map(async (file) => {
13
+ const module = await import(resolve(__dirname, file))
14
+ return { id: module.id, up: module.up }
15
+ })
16
+ )
8
17
 
9
18
  async function runMigrationsInternal (connection, queryOptions = {}) {
10
19
  await connection.query(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",