@evanp/activitypub-bot 0.34.1 → 0.35.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.
- package/README.md +45 -3
- package/bin/activitypub-bot.js +8 -0
- package/lib/activitydeliverer.js +10 -0
- package/lib/activitydistributor.js +11 -1
- package/lib/activityhandler.js +11 -5
- package/lib/actorstorage.js +1 -1
- package/lib/app.js +28 -34
- package/lib/deliveryworker.js +26 -61
- package/lib/distributionworker.js +68 -108
- package/lib/fanoutworker.js +20 -0
- package/lib/intakeworker.js +22 -0
- package/lib/objectstorage.js +1 -1
- package/lib/routes/sharedinbox.js +1 -1
- package/lib/worker.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,6 +58,12 @@ Options:
|
|
|
58
58
|
--port <number> Port to listen on
|
|
59
59
|
--bots-config-file <path> Path to bots config module
|
|
60
60
|
--log-level <level> Log level (e.g., info, debug)
|
|
61
|
+
--delivery <number> Number of background delivery workers
|
|
62
|
+
--distribution <number> Number of background distribution workers
|
|
63
|
+
--fanout <number> Number of background fanout workers
|
|
64
|
+
--intake <number> Number of background intake workers
|
|
65
|
+
--index-file <path> HTML page to show at root path
|
|
66
|
+
--profile-file <path> HTML page to show for bot profiles
|
|
61
67
|
-h, --help Show this help
|
|
62
68
|
```
|
|
63
69
|
|
|
@@ -109,11 +115,29 @@ The number of background distribution workers to run for this server. These wor
|
|
|
109
115
|
|
|
110
116
|
Falls back to the `DISTRIBUTION` environment variable. Default is 8.
|
|
111
117
|
|
|
118
|
+
#### --fanout
|
|
119
|
+
|
|
120
|
+
The number of background fanout workers to run for this server. These workers resolve the recipients for bot activities — expanding follower collections and other addressing — and enqueue individual delivery jobs. If activities are slow to reach recipients, increase this number.
|
|
121
|
+
|
|
122
|
+
Falls back to the `FANOUT` environment variable. Default is 4.
|
|
123
|
+
|
|
124
|
+
#### --intake
|
|
125
|
+
|
|
126
|
+
The number of background intake workers to run for this server. These workers accept activities arriving at the shared inbox and route them to the appropriate local bots. If shared-inbox processing is a bottleneck, increase this number.
|
|
127
|
+
|
|
128
|
+
Falls back to the `INTAKE` environment variable. Default is 2.
|
|
129
|
+
|
|
112
130
|
#### --index-file
|
|
113
131
|
|
|
114
132
|
Path to the HTML file to show for the home page of your server. The activitypub.bot server doesn't support any other files, so any images or CSS stylesheets or JavaScript in this page have to be hosted elsewhere. Or just skip them!
|
|
115
133
|
|
|
116
|
-
Falls back to the
|
|
134
|
+
Falls back to the `INDEX_FILE` environment variable. The default index file is in `web/index.html` and just says that this is an activitypub.bot server with a link to the GitHub repo.
|
|
135
|
+
|
|
136
|
+
#### --profile-file
|
|
137
|
+
|
|
138
|
+
Path to the HTML file to show for bot profile pages. Like `--index-file`, any external assets must be hosted elsewhere.
|
|
139
|
+
|
|
140
|
+
Falls back to the `PROFILE_FILE` environment variable. The default profile file is in `web/profile.html`.
|
|
117
141
|
|
|
118
142
|
### Config file
|
|
119
143
|
|
|
@@ -184,9 +208,15 @@ Bots will receive a [BotContext](#botcontext) object at initialization. The BotC
|
|
|
184
208
|
|
|
185
209
|
The Bot interface has the following methods.
|
|
186
210
|
|
|
187
|
-
#### constructor (username)
|
|
211
|
+
#### constructor (username, options)
|
|
212
|
+
|
|
213
|
+
The constructor; receives the `username` and an optional `options` object. Initialization should probably be deferred to the `initialize()` method. The default implementation stores the username and the following options:
|
|
188
214
|
|
|
189
|
-
|
|
215
|
+
- `fullname` — display name for the bot. Default: `'Bot'`.
|
|
216
|
+
- `description` — bio text for the bot. Default: `'Default bot'`.
|
|
217
|
+
- `icon` — URL string for the bot's avatar image. Default: `null`.
|
|
218
|
+
- `image` — URL string for the bot's header/banner image. Default: `null`.
|
|
219
|
+
- `checkSignature` — whether to require valid HTTP signatures on incoming activities. Default: `true`.
|
|
190
220
|
|
|
191
221
|
#### async initialize (context)
|
|
192
222
|
|
|
@@ -200,6 +230,18 @@ A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Fun
|
|
|
200
230
|
|
|
201
231
|
A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the bio of the bot.
|
|
202
232
|
|
|
233
|
+
#### get icon ()
|
|
234
|
+
|
|
235
|
+
A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the avatar image URL of the bot. Returns `null` if not set.
|
|
236
|
+
|
|
237
|
+
#### get image ()
|
|
238
|
+
|
|
239
|
+
A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the header/banner image URL of the bot. Returns `null` if not set.
|
|
240
|
+
|
|
241
|
+
#### get checkSignature ()
|
|
242
|
+
|
|
243
|
+
A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for whether the bot requires valid HTTP signatures on incoming activities.
|
|
244
|
+
|
|
203
245
|
#### get username ()
|
|
204
246
|
|
|
205
247
|
A [getter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) for the username of the bot. Should match the constructor argument.
|
package/bin/activitypub-bot.js
CHANGED
|
@@ -14,6 +14,8 @@ const { values } = parseArgs({
|
|
|
14
14
|
'log-level': { type: 'string' },
|
|
15
15
|
delivery: { type: 'string' },
|
|
16
16
|
distribution: { type: 'string' },
|
|
17
|
+
fanout: { type: 'string' },
|
|
18
|
+
intake: { type: 'string' },
|
|
17
19
|
'index-file': { type: 'string' },
|
|
18
20
|
help: { type: 'boolean', short: 'h' }
|
|
19
21
|
},
|
|
@@ -31,6 +33,8 @@ Options:
|
|
|
31
33
|
--log-level <level> Log level (e.g., info, debug)
|
|
32
34
|
--delivery <number> Number of background delivery workers
|
|
33
35
|
--distribution <number> Number of background distribution workers
|
|
36
|
+
--fanout <number> Number of background fanout workers
|
|
37
|
+
--intake <number> Number of background intake workers
|
|
34
38
|
--index-file <path> HTML page to show at root path
|
|
35
39
|
--profile-file <path> HTML page to show for bot profiles
|
|
36
40
|
-h, --help Show this help
|
|
@@ -61,6 +65,8 @@ const LOG_LEVEL =
|
|
|
61
65
|
(process.env.NODE_ENV === 'test' ? 'silent' : 'info')
|
|
62
66
|
const DELIVERY = parseNumber(values.delivery) || parseNumber(process.env.DELIVERY) || 2
|
|
63
67
|
const DISTRIBUTION = parseNumber(values.distribution) || parseNumber(process.env.DISTRIBUTION) || 8
|
|
68
|
+
const FANOUT = parseNumber(values.fanout) || parseNumber(process.env.FANOUT) || 4
|
|
69
|
+
const INTAKE = parseNumber(values.intake) || parseNumber(process.env.INTAKE) || 2
|
|
64
70
|
const INDEX_FILE = values['index-file'] || process.env.INDEX_FILE || DEFAULT_INDEX_FILE
|
|
65
71
|
const PROFILE_FILE = values['profile-file'] || process.env.PROFILE_FILE || DEFAULT_PROFILE_FILE
|
|
66
72
|
|
|
@@ -73,6 +79,8 @@ const app = await makeApp({
|
|
|
73
79
|
logLevel: LOG_LEVEL,
|
|
74
80
|
deliveryWorkerCount: DELIVERY,
|
|
75
81
|
distributionWorkerCount: DISTRIBUTION,
|
|
82
|
+
fanoutWorkerCount: FANOUT,
|
|
83
|
+
intakeWorkerCount: INTAKE,
|
|
76
84
|
indexFileName: INDEX_FILE,
|
|
77
85
|
profileFileName: PROFILE_FILE
|
|
78
86
|
})
|
package/lib/activitydeliverer.js
CHANGED
|
@@ -23,6 +23,7 @@ const COLLECTION_TYPES = [
|
|
|
23
23
|
|
|
24
24
|
export class ActivityDeliverer {
|
|
25
25
|
static #QUEUE_ID = 'delivery'
|
|
26
|
+
static #INTAKE_QUEUE_ID = 'intake'
|
|
26
27
|
static #TTL_SEEN_PUBLIC = 3 * 24 * 60 * 60 * 1000 // 3 days
|
|
27
28
|
static #MAX_SEEN_PUBLIC = 1000000
|
|
28
29
|
#actorStorage
|
|
@@ -106,10 +107,19 @@ export class ActivityDeliverer {
|
|
|
106
107
|
|
|
107
108
|
async onIdle () {
|
|
108
109
|
this.#logger.debug('Awaiting delivery queues')
|
|
110
|
+
await this.#jobQueue.onIdle(ActivityDeliverer.#INTAKE_QUEUE_ID)
|
|
109
111
|
await this.#jobQueue.onIdle(ActivityDeliverer.#QUEUE_ID)
|
|
110
112
|
this.#logger.debug('Done awaiting delivery queues')
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
async intake (activity, subject) {
|
|
116
|
+
const raw = await activity.export({ useOriginalContext: true })
|
|
117
|
+
await this.#jobQueue.enqueue(
|
|
118
|
+
ActivityDeliverer.#INTAKE_QUEUE_ID,
|
|
119
|
+
{ activity: raw, subject }
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
async deliverToAll (activity, bots) {
|
|
114
124
|
await this.#deliverToAll(activity, bots)
|
|
115
125
|
}
|
|
@@ -14,6 +14,7 @@ const COLLECTION_TYPES = [
|
|
|
14
14
|
export class ActivityDistributor {
|
|
15
15
|
static #DISTRIBUTION_QUEUE_ID = 'distribution'
|
|
16
16
|
static #DELIVERY_QUEUE_ID = 'delivery'
|
|
17
|
+
static #FANOUT_QUEUE_ID = 'fanout'
|
|
17
18
|
static #MAX_CACHE_SIZE = 1000000
|
|
18
19
|
static #PUBLIC = [
|
|
19
20
|
'https://www.w3.org/ns/activitystreams#Public',
|
|
@@ -45,6 +46,14 @@ export class ActivityDistributor {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
async distribute (activity, username) {
|
|
49
|
+
const raw = await activity.export({ useOriginalContext: true })
|
|
50
|
+
await this.#jobQueue.enqueue(
|
|
51
|
+
ActivityDistributor.#FANOUT_QUEUE_ID,
|
|
52
|
+
{ activity: raw, username }
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async fanout (activity, username) {
|
|
48
57
|
const stripped = await this.#strip(activity)
|
|
49
58
|
const raw = await stripped.export()
|
|
50
59
|
const actorId = this.#formatter.format({ username })
|
|
@@ -102,8 +111,9 @@ export class ActivityDistributor {
|
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
async onIdle () {
|
|
105
|
-
await this.#jobQueue.onIdle(ActivityDistributor.#
|
|
114
|
+
await this.#jobQueue.onIdle(ActivityDistributor.#FANOUT_QUEUE_ID)
|
|
106
115
|
await this.#jobQueue.onIdle(ActivityDistributor.#DISTRIBUTION_QUEUE_ID)
|
|
116
|
+
await this.#jobQueue.onIdle(ActivityDistributor.#DELIVERY_QUEUE_ID)
|
|
107
117
|
}
|
|
108
118
|
|
|
109
119
|
async * #public (activity, username) {
|
package/lib/activityhandler.js
CHANGED
|
@@ -311,14 +311,14 @@ export class ActivityHandler {
|
|
|
311
311
|
actor: actor.id
|
|
312
312
|
})
|
|
313
313
|
await this.#actorStorage.addToCollection(bot.username, 'followers', actor)
|
|
314
|
-
this.#logger.info(
|
|
315
|
-
'Sending accept',
|
|
316
|
-
{ actor: actor.id }
|
|
317
|
-
)
|
|
318
314
|
const followersId = this.#formatter.format({
|
|
319
315
|
username: bot.username,
|
|
320
316
|
collection: 'followers'
|
|
321
317
|
})
|
|
318
|
+
this.#logger.debug(
|
|
319
|
+
'Sending add activity for adding actor to followers',
|
|
320
|
+
{ actor: actor.id, followers: followersId, follow: activity.id }
|
|
321
|
+
)
|
|
322
322
|
const add = await this.#doActivity(bot, {
|
|
323
323
|
type: 'Add',
|
|
324
324
|
object: actor.id,
|
|
@@ -329,6 +329,10 @@ export class ActivityHandler {
|
|
|
329
329
|
const recipients = await this.#getRecipients(activity)
|
|
330
330
|
this.#addRecipient(recipients, actor, 'to')
|
|
331
331
|
this.#removeRecipient(recipients, await this.#botActor(bot))
|
|
332
|
+
this.#logger.debug(
|
|
333
|
+
'Sending accept activity for follow',
|
|
334
|
+
{ bot: this.#botId(bot), actor: actor.id, follow: activity.id }
|
|
335
|
+
)
|
|
332
336
|
const accept = await this.#doActivity(bot, {
|
|
333
337
|
type: 'Accept',
|
|
334
338
|
object: activity.id,
|
|
@@ -337,7 +341,9 @@ export class ActivityHandler {
|
|
|
337
341
|
this.#logger.debug({ accept: await accept.export() }, 'accepted follow')
|
|
338
342
|
this.#logger.debug({
|
|
339
343
|
msg: 'Notifying bot of new follow',
|
|
340
|
-
actor: actor.id
|
|
344
|
+
actor: actor.id,
|
|
345
|
+
bot: this.#botId(bot),
|
|
346
|
+
follow: activity.id
|
|
341
347
|
})
|
|
342
348
|
await bot.onFollow(actor, activity)
|
|
343
349
|
}
|
package/lib/actorstorage.js
CHANGED
|
@@ -6,7 +6,7 @@ const AS2_NS = 'https://www.w3.org/ns/activitystreams#'
|
|
|
6
6
|
export class ActorStorage {
|
|
7
7
|
#connection = null
|
|
8
8
|
#formatter = null
|
|
9
|
-
static #MAX_ITEMS_PER_PAGE =
|
|
9
|
+
static #MAX_ITEMS_PER_PAGE = 256
|
|
10
10
|
constructor (connection, formatter) {
|
|
11
11
|
this.#connection = connection
|
|
12
12
|
this.#formatter = formatter
|
package/lib/app.js
CHANGED
|
@@ -41,12 +41,20 @@ import { DeliveryWorker } from './deliveryworker.js'
|
|
|
41
41
|
import { DistributionWorker } from './distributionworker.js'
|
|
42
42
|
import { RateLimiter } from '../lib/ratelimiter.js'
|
|
43
43
|
import DoNothingBot from './bots/donothing.js'
|
|
44
|
+
import { FanoutWorker } from './fanoutworker.js'
|
|
45
|
+
import { IntakeWorker } from './intakeworker.js'
|
|
44
46
|
|
|
45
47
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
46
48
|
const DEFAULT_INDEX_FILENAME = resolve(currentDir, '..', 'web', 'index.html')
|
|
47
49
|
const DEFAULT_PROFILE_FILENAME = resolve(currentDir, '..', 'web', 'profile.html')
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
function createWorkers (logger, count, WorkerClass, ...args) {
|
|
52
|
+
const workers = Array.from({ length: count }, () => new WorkerClass(...args))
|
|
53
|
+
const runs = workers.map(w => w.run().catch(err => logger.error({ err }, `unexpected error in ${WorkerClass.name}`)))
|
|
54
|
+
return { workers, runs }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent', deliveryWorkerCount = 2, distributionWorkerCount = 8, fanoutWorkerCount = 4, intakeWorkerCount = 2, indexFileName = DEFAULT_INDEX_FILENAME, profileFileName = DEFAULT_PROFILE_FILENAME }) {
|
|
50
58
|
const logger = Logger({
|
|
51
59
|
level: logLevel
|
|
52
60
|
})
|
|
@@ -136,35 +144,10 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
136
144
|
logger
|
|
137
145
|
))
|
|
138
146
|
|
|
139
|
-
const deliveryWorkers =
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
deliveryWorkers[i] = new DeliveryWorker(
|
|
144
|
-
jobQueue,
|
|
145
|
-
actorStorage,
|
|
146
|
-
activityHandler,
|
|
147
|
-
logger,
|
|
148
|
-
bots
|
|
149
|
-
)
|
|
150
|
-
deliveryWorkerRuns[i] = deliveryWorkers[i].run().catch((err) => {
|
|
151
|
-
logger.error({ err, workerIndex: i }, 'unexpected error in delivery worker')
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const distributionWorkers = new Array(distributionWorkerCount)
|
|
156
|
-
const distributionWorkerRuns = new Array(distributionWorkerCount)
|
|
157
|
-
|
|
158
|
-
for (let i = 0; i < distributionWorkers.length; i++) {
|
|
159
|
-
distributionWorkers[i] = new DistributionWorker(
|
|
160
|
-
jobQueue,
|
|
161
|
-
client,
|
|
162
|
-
logger
|
|
163
|
-
)
|
|
164
|
-
distributionWorkerRuns[i] = distributionWorkers[i].run().catch((err) => {
|
|
165
|
-
logger.error({ err, workerIndex: i }, 'unexpected error in distribution worker')
|
|
166
|
-
})
|
|
167
|
-
}
|
|
147
|
+
const { workers: deliveryWorkers, runs: deliveryWorkerRuns } = createWorkers(logger, deliveryWorkerCount, DeliveryWorker, jobQueue, logger, { actorStorage, activityHandler, bots })
|
|
148
|
+
const { workers: distributionWorkers, runs: distributionWorkerRuns } = createWorkers(logger, distributionWorkerCount, DistributionWorker, jobQueue, logger, { client })
|
|
149
|
+
const { workers: fanoutWorkers, runs: fanoutWorkerRuns } = createWorkers(logger, fanoutWorkerCount, FanoutWorker, jobQueue, logger, { distributor })
|
|
150
|
+
const { workers: intakeWorkers, runs: intakeWorkerRuns } = createWorkers(logger, intakeWorkerCount, IntakeWorker, jobQueue, logger, { deliverer, bots })
|
|
168
151
|
|
|
169
152
|
const jobReaper = new JobReaper(jobQueue, logger)
|
|
170
153
|
const jobReaperRun = jobReaper.run().catch((err) => {
|
|
@@ -273,23 +256,34 @@ export async function makeApp ({ databaseUrl, origin, bots, logLevel = 'silent',
|
|
|
273
256
|
|
|
274
257
|
app.onIdle = async () => {
|
|
275
258
|
logger.debug('Awaiting components')
|
|
276
|
-
await
|
|
277
|
-
await
|
|
259
|
+
await jobQueue.onIdle('intake')
|
|
260
|
+
await jobQueue.onIdle('delivery')
|
|
261
|
+
await jobQueue.onIdle('fanout')
|
|
262
|
+
await jobQueue.onIdle('delivery')
|
|
263
|
+
await jobQueue.onIdle('distribution')
|
|
278
264
|
logger.debug('Done awaiting components')
|
|
279
265
|
}
|
|
280
266
|
|
|
281
267
|
app.cleanup = async () => {
|
|
282
268
|
logger.info('Closing app')
|
|
283
|
-
for (const worker of
|
|
269
|
+
for (const worker of intakeWorkers) {
|
|
270
|
+
worker.stop()
|
|
271
|
+
}
|
|
272
|
+
for (const worker of fanoutWorkers) {
|
|
284
273
|
worker.stop()
|
|
285
274
|
}
|
|
286
275
|
for (const worker of distributionWorkers) {
|
|
287
276
|
worker.stop()
|
|
288
277
|
}
|
|
278
|
+
for (const worker of deliveryWorkers) {
|
|
279
|
+
worker.stop()
|
|
280
|
+
}
|
|
289
281
|
jobReaper.stop()
|
|
290
282
|
jobQueue.abort()
|
|
291
|
-
await Promise.allSettled(
|
|
283
|
+
await Promise.allSettled(intakeWorkerRuns)
|
|
284
|
+
await Promise.allSettled(fanoutWorkerRuns)
|
|
292
285
|
await Promise.allSettled(distributionWorkerRuns)
|
|
286
|
+
await Promise.allSettled(deliveryWorkerRuns)
|
|
293
287
|
await jobReaperRun
|
|
294
288
|
logger.info('Closing database connection')
|
|
295
289
|
await connection.close()
|
package/lib/deliveryworker.js
CHANGED
|
@@ -1,71 +1,36 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
2
|
|
|
3
|
-
import { nanoid } from 'nanoid'
|
|
4
|
-
|
|
5
3
|
import as2 from './activitystreams.js'
|
|
6
4
|
import BotMaker from './botmaker.js'
|
|
5
|
+
import { Worker } from './worker.js'
|
|
7
6
|
|
|
8
|
-
export class DeliveryWorker {
|
|
9
|
-
static #QUEUE_ID = 'delivery'
|
|
10
|
-
#queue
|
|
7
|
+
export class DeliveryWorker extends Worker {
|
|
11
8
|
#actorStorage
|
|
12
9
|
#activityHandler
|
|
13
|
-
#logger
|
|
14
|
-
#running
|
|
15
|
-
#workerId
|
|
16
10
|
#bots
|
|
17
11
|
|
|
18
|
-
constructor (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
this.#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
})
|
|
27
|
-
this.#bots = bots
|
|
12
|
+
constructor (jobQueue, logger, options = {}) {
|
|
13
|
+
super(jobQueue, logger, options)
|
|
14
|
+
assert.ok(options.actorStorage)
|
|
15
|
+
assert.ok(options.activityHandler)
|
|
16
|
+
assert.ok(options.bots)
|
|
17
|
+
this.#actorStorage = options.actorStorage
|
|
18
|
+
this.#activityHandler = options.activityHandler
|
|
19
|
+
this.#bots = options.bots
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
async
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
22
|
+
async doJob (payload, attempts) {
|
|
23
|
+
const activity = await as2.import(payload.activity)
|
|
24
|
+
assert.ok(payload.botUsername)
|
|
25
|
+
const bot = await BotMaker.makeBot(this.#bots, payload.botUsername)
|
|
26
|
+
assert.ok(bot)
|
|
27
|
+
assert.ok(bot.username)
|
|
28
|
+
this._logger.debug(
|
|
29
|
+
{ bot: bot.username, activity: activity.id },
|
|
30
|
+
'delivering to bot'
|
|
31
|
+
)
|
|
32
|
+
await this.#deliverTo(activity, bot)
|
|
33
|
+
this._logger.debug({ bot: bot.username, activity: activity.id }, 'done')
|
|
69
34
|
}
|
|
70
35
|
|
|
71
36
|
async #deliverTo (activity, bot) {
|
|
@@ -75,7 +40,7 @@ export class DeliveryWorker {
|
|
|
75
40
|
assert.strictEqual(typeof bot, 'object')
|
|
76
41
|
assert.strictEqual(typeof bot.username, 'string')
|
|
77
42
|
if (await this.#actorStorage.isInCollection(bot.username, 'inbox', activity)) {
|
|
78
|
-
this
|
|
43
|
+
this._logger.info(
|
|
79
44
|
{ activity: activity.id, username: bot.username },
|
|
80
45
|
'skipping redelivery for activity already in the inbox'
|
|
81
46
|
)
|
|
@@ -84,13 +49,13 @@ export class DeliveryWorker {
|
|
|
84
49
|
try {
|
|
85
50
|
await this.#activityHandler.handleActivity(bot, activity)
|
|
86
51
|
} catch (err) {
|
|
87
|
-
this
|
|
52
|
+
this._logger.warn(
|
|
88
53
|
{ err, activity: activity.id, bot: bot.username },
|
|
89
54
|
'handler failed for activity'
|
|
90
55
|
)
|
|
91
56
|
}
|
|
92
|
-
this
|
|
57
|
+
this._logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
|
|
93
58
|
await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
|
|
94
|
-
this
|
|
59
|
+
this._logger.debug(`Done adding ${activity.id} to ${bot.username} inbox`)
|
|
95
60
|
}
|
|
96
61
|
}
|
|
@@ -1,123 +1,87 @@
|
|
|
1
|
-
import
|
|
1
|
+
import assert from 'node:assert'
|
|
2
2
|
|
|
3
3
|
import as2 from './activitystreams.js'
|
|
4
|
+
import { Worker, RecoverableError } from './worker.js'
|
|
4
5
|
|
|
5
|
-
export class DistributionWorker {
|
|
6
|
-
static #QUEUE_ID = 'distribution'
|
|
6
|
+
export class DistributionWorker extends Worker {
|
|
7
7
|
static #MAX_ATTEMPTS = 21 // ~24 days
|
|
8
|
-
#jobQueue
|
|
9
8
|
#client
|
|
10
|
-
#logger
|
|
11
|
-
#running
|
|
12
|
-
#workerId
|
|
13
9
|
|
|
14
|
-
constructor (jobQueue,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
this.#
|
|
18
|
-
this.#logger = logger.child({
|
|
19
|
-
class: this.constructor.name,
|
|
20
|
-
workerId: this.#workerId
|
|
21
|
-
})
|
|
10
|
+
constructor (jobQueue, logger, options = {}) {
|
|
11
|
+
super(jobQueue, logger, options)
|
|
12
|
+
assert.ok(options.client)
|
|
13
|
+
this.#client = options.client
|
|
22
14
|
}
|
|
23
15
|
|
|
24
|
-
async
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
this.#logger.warn(
|
|
58
|
-
{ error, activity: activity.id, inbox },
|
|
59
|
-
'Could not deliver activity due to client error'
|
|
60
|
-
)
|
|
61
|
-
if ([408, 425, 429].includes(error.status)) {
|
|
62
|
-
this.#logger.debug(
|
|
63
|
-
{ error, activity: activity.id, inbox },
|
|
64
|
-
'Retrying on recoverable status'
|
|
65
|
-
)
|
|
66
|
-
const delay = this.#retryDelay(error.headers, attempts)
|
|
67
|
-
await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
|
|
68
|
-
} else {
|
|
69
|
-
await this.#jobQueue.fail(jobId, this.#workerId)
|
|
70
|
-
}
|
|
71
|
-
} else if (error.status >= 500 && error.status < 600) {
|
|
72
|
-
if ([501, 505, 508, 510].includes(error.status)) {
|
|
73
|
-
this.#logger.warn(
|
|
74
|
-
{ error, activity: activity.id, inbox, attempts },
|
|
75
|
-
'Could not deliver activity due to unrecoverable server error'
|
|
76
|
-
)
|
|
77
|
-
await this.#jobQueue.fail(jobId, this.#workerId)
|
|
78
|
-
} else if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
|
|
79
|
-
this.#logger.warn(
|
|
80
|
-
{ error, activity: activity.id, inbox, attempts },
|
|
81
|
-
'Could not deliver activity due to server error; no more attempts'
|
|
82
|
-
)
|
|
83
|
-
await this.#jobQueue.fail(jobId, this.#workerId)
|
|
84
|
-
} else {
|
|
85
|
-
const delay = this.#retryDelay(error.headers, attempts)
|
|
86
|
-
this.#logger.warn(
|
|
87
|
-
{ error, activity: activity.id, inbox, attempts, delay },
|
|
88
|
-
'Could not deliver activity due to server error; will retry'
|
|
89
|
-
)
|
|
90
|
-
await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
this.#logger.warn(
|
|
94
|
-
{ error, activity: activity.id, inbox },
|
|
95
|
-
'Could not deliver activity due to unexpected status range'
|
|
96
|
-
)
|
|
97
|
-
await this.#jobQueue.fail(jobId, this.#workerId)
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch (err) {
|
|
101
|
-
if (err?.name === 'AbortError') {
|
|
102
|
-
this.#logger.info('Worker received abort signal')
|
|
103
|
-
break
|
|
16
|
+
async doJob (payload, attempts) {
|
|
17
|
+
const { inbox, activity, username } = payload
|
|
18
|
+
const activityObj = await as2.import(activity)
|
|
19
|
+
try {
|
|
20
|
+
await this.#client.post(inbox, activityObj, username)
|
|
21
|
+
this._logger.info(`Delivered ${activity.id} to ${inbox}`)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (!error.status) {
|
|
24
|
+
this._logger.warn(
|
|
25
|
+
{ error, activity: activity.id, inbox },
|
|
26
|
+
'Could not deliver activity and no HTTP status available')
|
|
27
|
+
throw error
|
|
28
|
+
} else if (error.status >= 300 && error.status < 400) {
|
|
29
|
+
this._logger.warn(
|
|
30
|
+
{ error, activity: activity.id, inbox },
|
|
31
|
+
'Could not deliver activity and unexpected redirect code'
|
|
32
|
+
)
|
|
33
|
+
throw error
|
|
34
|
+
} else if (error.status >= 400 && error.status < 500) {
|
|
35
|
+
this._logger.warn(
|
|
36
|
+
{ error, activity: activity.id, inbox },
|
|
37
|
+
'Could not deliver activity due to client error'
|
|
38
|
+
)
|
|
39
|
+
if ([408, 425, 429].includes(error.status)) {
|
|
40
|
+
this._logger.debug(
|
|
41
|
+
{ error, activity: activity.id, inbox },
|
|
42
|
+
'Retrying on recoverable status'
|
|
43
|
+
)
|
|
44
|
+
const recoverable = new RecoverableError(error.message)
|
|
45
|
+
recoverable.delay = this.#retryDelay(error.headers, attempts)
|
|
46
|
+
throw recoverable
|
|
47
|
+
} else {
|
|
48
|
+
throw error
|
|
104
49
|
}
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
50
|
+
} else if (error.status >= 500 && error.status < 600) {
|
|
51
|
+
if ([501, 505, 508, 510].includes(error.status)) {
|
|
52
|
+
this._logger.warn(
|
|
53
|
+
{ error, activity: activity.id, inbox, attempts },
|
|
54
|
+
'Could not deliver activity due to unrecoverable server error'
|
|
55
|
+
)
|
|
56
|
+
throw error
|
|
57
|
+
} else if (attempts >= DistributionWorker.#MAX_ATTEMPTS) {
|
|
58
|
+
this._logger.warn(
|
|
59
|
+
{ error, activity: activity.id, inbox, attempts },
|
|
60
|
+
'Could not deliver activity due to server error; no more attempts'
|
|
61
|
+
)
|
|
62
|
+
throw error
|
|
63
|
+
} else {
|
|
64
|
+
const recoverable = new RecoverableError(error.message)
|
|
65
|
+
recoverable.delay = this.#retryDelay(error.headers, attempts)
|
|
66
|
+
this._logger.warn(
|
|
67
|
+
{ error, activity: activity.id, inbox, attempts, delay: recoverable.delay },
|
|
68
|
+
'Could not deliver activity due to server error; will retry'
|
|
111
69
|
)
|
|
112
|
-
|
|
70
|
+
throw recoverable
|
|
113
71
|
}
|
|
72
|
+
} else {
|
|
73
|
+
this._logger.warn(
|
|
74
|
+
{ error, activity: activity.id, inbox },
|
|
75
|
+
'Could not deliver activity due to unexpected status range'
|
|
76
|
+
)
|
|
77
|
+
throw error
|
|
114
78
|
}
|
|
115
79
|
}
|
|
116
80
|
}
|
|
117
81
|
|
|
118
82
|
#retryDelay (headers, attempts) {
|
|
119
83
|
if (headers?.['retry-after']) {
|
|
120
|
-
this
|
|
84
|
+
this._logger.debug('using retry-after header')
|
|
121
85
|
const retryAfter = headers['retry-after']
|
|
122
86
|
if (/^\d+$/.test(retryAfter)) {
|
|
123
87
|
return parseInt(retryAfter, 10) * 1000
|
|
@@ -125,11 +89,7 @@ export class DistributionWorker {
|
|
|
125
89
|
return new Date(retryAfter) - Date.now()
|
|
126
90
|
}
|
|
127
91
|
}
|
|
128
|
-
this
|
|
92
|
+
this._logger.debug('exponential backoff')
|
|
129
93
|
return Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
|
|
130
94
|
}
|
|
131
|
-
|
|
132
|
-
stop () {
|
|
133
|
-
this.#running = false
|
|
134
|
-
}
|
|
135
95
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import as2 from './activitystreams.js'
|
|
4
|
+
import { Worker } from './worker.js'
|
|
5
|
+
|
|
6
|
+
export class FanoutWorker extends Worker {
|
|
7
|
+
#distributor
|
|
8
|
+
|
|
9
|
+
constructor (jobQueue, logger, options = {}) {
|
|
10
|
+
super(jobQueue, logger, options)
|
|
11
|
+
assert.ok(options.distributor)
|
|
12
|
+
this.#distributor = options.distributor
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async doJob (payload, attempts) {
|
|
16
|
+
const activity = await as2.import(payload.activity)
|
|
17
|
+
const { username } = payload
|
|
18
|
+
await this.#distributor.fanout(activity, username)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
|
|
3
|
+
import as2 from './activitystreams.js'
|
|
4
|
+
import { Worker } from './worker.js'
|
|
5
|
+
|
|
6
|
+
export class IntakeWorker extends Worker {
|
|
7
|
+
#deliverer
|
|
8
|
+
#bots
|
|
9
|
+
constructor (jobQueue, logger, options = {}) {
|
|
10
|
+
super(jobQueue, logger, options)
|
|
11
|
+
assert.ok(options.deliverer)
|
|
12
|
+
assert.ok(options.bots)
|
|
13
|
+
this.#deliverer = options.deliverer
|
|
14
|
+
this.#bots = options.bots
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async doJob (payload, attempts) {
|
|
18
|
+
const raw = payload.activity
|
|
19
|
+
const activity = await as2.import(raw)
|
|
20
|
+
await this.#deliverer.deliverToAll(activity, this.#bots)
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/objectstorage.js
CHANGED
|
@@ -69,7 +69,7 @@ router.post('/shared/inbox', async (req, res, next) => {
|
|
|
69
69
|
|
|
70
70
|
logger.info(`Activity ${activity.id} received at shared inbox`)
|
|
71
71
|
|
|
72
|
-
await deliverer.
|
|
72
|
+
await deliverer.intake(activity, subject)
|
|
73
73
|
|
|
74
74
|
res.status(202)
|
|
75
75
|
res.type('text/plain')
|
package/lib/worker.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { nanoid } from 'nanoid'
|
|
3
|
+
|
|
4
|
+
export class RecoverableError extends Error {
|
|
5
|
+
delay = 1000
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Worker {
|
|
9
|
+
#jobQueue
|
|
10
|
+
#logger
|
|
11
|
+
#running
|
|
12
|
+
#workerId
|
|
13
|
+
|
|
14
|
+
constructor (jobQueue, logger, options = {}) {
|
|
15
|
+
this.#jobQueue = jobQueue
|
|
16
|
+
this.#workerId = nanoid()
|
|
17
|
+
this.#logger = logger.child({
|
|
18
|
+
class: this.constructor.name,
|
|
19
|
+
worker: this.#workerId
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get _logger () {
|
|
24
|
+
return this.#logger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get queueId () {
|
|
28
|
+
const name = this.constructor.name
|
|
29
|
+
return name.slice(0, name.length - 6).toLowerCase()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async run () {
|
|
33
|
+
this.#running = true
|
|
34
|
+
while (this.#running) {
|
|
35
|
+
let jobId
|
|
36
|
+
let payload
|
|
37
|
+
let attempts
|
|
38
|
+
try {
|
|
39
|
+
this.#logger.debug('dequeueing');
|
|
40
|
+
({ jobId, payload, attempts } = await this.#jobQueue.dequeue(
|
|
41
|
+
this.queueId,
|
|
42
|
+
this.#workerId
|
|
43
|
+
))
|
|
44
|
+
this.#logger.debug({ jobId, payload, attempts }, 'got a job')
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err?.name === 'AbortError') {
|
|
47
|
+
this.#logger.info('Worker received abort signal')
|
|
48
|
+
break
|
|
49
|
+
} else {
|
|
50
|
+
this.#logger.warn({ err, jobId }, 'error before job, retrying')
|
|
51
|
+
if (jobId) {
|
|
52
|
+
const delay = this.#retryDelay(attempts ?? 1)
|
|
53
|
+
await this.#jobQueue.retryAfter(jobId, this.#workerId, delay)
|
|
54
|
+
}
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
assert.ok(jobId)
|
|
60
|
+
assert.ok(payload)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await this.doJob(payload, attempts)
|
|
64
|
+
await this.#jobQueue.complete(jobId, this.#workerId)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof RecoverableError) {
|
|
67
|
+
await this.#jobQueue.retryAfter(jobId, this.#workerId, err.delay)
|
|
68
|
+
} else {
|
|
69
|
+
await this.#jobQueue.fail(jobId, this.#workerId)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async doJob (payload, attempts) {
|
|
76
|
+
throw new Error('Must implement doJob')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stop () {
|
|
80
|
+
this.#running = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#retryDelay (attempts) {
|
|
84
|
+
return Math.round((2 ** (attempts - 1) * 1000) * (0.5 + Math.random()))
|
|
85
|
+
}
|
|
86
|
+
}
|