@evanp/activitypub-bot 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.github/workflows/main.yml +34 -0
  2. package/.github/workflows/{tag-docker.yml → tag.yml} +57 -5
  3. package/.nvmrc +1 -0
  4. package/Dockerfile +11 -16
  5. package/README.md +87 -13
  6. package/activitypub-bot.js +68 -0
  7. package/lib/activitydeliverer.js +260 -0
  8. package/lib/activityhandler.js +14 -0
  9. package/lib/activitypubclient.js +52 -1
  10. package/lib/activitystreams.js +31 -0
  11. package/lib/actorstorage.js +18 -28
  12. package/lib/app.js +18 -7
  13. package/lib/bot.js +7 -0
  14. package/lib/botcontext.js +62 -0
  15. package/lib/botdatastorage.js +0 -13
  16. package/lib/botfactory.js +24 -0
  17. package/lib/botmaker.js +23 -0
  18. package/lib/keystorage.js +7 -24
  19. package/lib/migrations/001-initial.js +107 -0
  20. package/lib/migrations/index.js +28 -0
  21. package/lib/objectcache.js +4 -1
  22. package/lib/objectstorage.js +0 -36
  23. package/lib/remotekeystorage.js +0 -24
  24. package/lib/routes/collection.js +6 -2
  25. package/lib/routes/inbox.js +7 -20
  26. package/lib/routes/sharedinbox.js +54 -0
  27. package/lib/routes/user.js +11 -5
  28. package/lib/routes/webfinger.js +11 -2
  29. package/lib/urlformatter.js +8 -0
  30. package/package.json +14 -7
  31. package/tests/activitydistributor.test.js +3 -3
  32. package/tests/activityhandler.test.js +96 -5
  33. package/tests/activitypubclient.test.js +115 -130
  34. package/tests/actorstorage.test.js +26 -4
  35. package/tests/authorizer.test.js +3 -8
  36. package/tests/botcontext.test.js +109 -63
  37. package/tests/botdatastorage.test.js +3 -2
  38. package/tests/botfactory.provincebotfactory.test.js +430 -0
  39. package/tests/fixtures/bots.js +13 -1
  40. package/tests/fixtures/eventloggingbot.js +57 -0
  41. package/tests/fixtures/provincebotfactory.js +53 -0
  42. package/tests/httpsignature.test.js +3 -4
  43. package/tests/httpsignatureauthenticator.test.js +3 -3
  44. package/tests/keystorage.test.js +37 -2
  45. package/tests/microsyntax.test.js +3 -2
  46. package/tests/objectstorage.test.js +4 -3
  47. package/tests/remotekeystorage.test.js +10 -8
  48. package/tests/routes.actor.test.js +7 -0
  49. package/tests/routes.collection.test.js +0 -1
  50. package/tests/routes.inbox.test.js +1 -0
  51. package/tests/routes.object.test.js +44 -38
  52. package/tests/routes.sharedinbox.test.js +473 -0
  53. package/tests/routes.webfinger.test.js +27 -0
  54. package/tests/utils/nock.js +250 -27
  55. package/.github/workflows/main-docker.yml +0 -45
  56. package/index.js +0 -23
@@ -0,0 +1,34 @@
1
+ name: Run tests on main
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ permissions:
10
+ contents: read
11
+ packages: write
12
+
13
+ jobs:
14
+
15
+ runTests:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ node-version: [20, 22, 24, 25]
20
+ steps:
21
+
22
+ - uses: actions/checkout@v4
23
+
24
+ - uses: actions/setup-node@v4
25
+ with:
26
+ node-version: ${{ matrix.node-version }}
27
+ cache: npm
28
+ cache-dependency-path: package-lock.json
29
+
30
+ - name: Install dependencies
31
+ run: npm ci
32
+
33
+ - name: Run tests
34
+ run: npm run test
@@ -5,8 +5,58 @@ on:
5
5
  tags:
6
6
  - 'v[0-9]+.[0-9]+.[0-9]+'
7
7
 
8
+ permissions:
9
+ contents: read
10
+ packages: write
11
+
8
12
  jobs:
13
+
14
+ runTests:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ matrix:
18
+ node-version: [20, 22, 24, 25]
19
+ steps:
20
+
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: ${{ matrix.node-version }}
26
+ cache: npm
27
+ cache-dependency-path: package-lock.json
28
+
29
+ - name: Install dependencies
30
+ run: npm ci
31
+
32
+ - name: Run tests
33
+ run: npm run test
34
+
35
+ publish-npm:
36
+ needs: runTests
37
+ runs-on: ubuntu-latest
38
+
39
+ permissions:
40
+ contents: read
41
+ id-token: write
42
+
43
+ steps:
44
+ - name: Checkout code
45
+ uses: actions/checkout@v4
46
+
47
+ - name: Set up Node.js
48
+ uses: actions/setup-node@v4
49
+ with:
50
+ node-version: 25
51
+ registry-url: https://registry.npmjs.org
52
+ cache: npm
53
+ cache-dependency-path: package-lock.json
54
+
55
+ - name: Publish to NPM
56
+ run: npm publish --access public --provenance
57
+
9
58
  build-and-push:
59
+ needs: publish-npm
10
60
  runs-on: ubuntu-latest
11
61
 
12
62
  permissions:
@@ -15,10 +65,10 @@ jobs:
15
65
 
16
66
  steps:
17
67
  - name: Checkout code
18
- uses: actions/checkout@v3
68
+ uses: actions/checkout@v4
19
69
 
20
70
  - name: Set up Docker Buildx
21
- uses: docker/setup-buildx-action@v2
71
+ uses: docker/setup-buildx-action@v3
22
72
 
23
73
  - name: Extract version components from tag
24
74
  id: get_version
@@ -33,22 +83,24 @@ jobs:
33
83
  echo "MAJOR_MINOR_VERSION=$MAJOR.$MINOR" >> $GITHUB_OUTPUT
34
84
 
35
85
  - name: Login to GitHub Container Registry
36
- uses: docker/login-action@v2
86
+ uses: docker/login-action@v3
37
87
  with:
38
88
  registry: ghcr.io
39
89
  username: ${{ github.actor }}
40
90
  password: ${{ secrets.GITHUB_TOKEN }}
41
91
 
42
92
  - name: Build and push Docker image
43
- uses: docker/build-push-action@v4
93
+ uses: docker/build-push-action@v5
44
94
  with:
45
95
  context: .
46
96
  push: true
47
97
  platforms: linux/amd64,linux/arm64
98
+ build-args: |
99
+ PACKAGE_VERSION=${{ steps.get_version.outputs.FULL_VERSION }}
48
100
  tags: |
49
101
  ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.FULL_VERSION }}
50
102
  ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.MAJOR_MINOR_VERSION }}
51
103
  ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.MAJOR_VERSION }}
52
104
  ghcr.io/${{ github.repository }}:${{ github.sha }}
53
105
  cache-from: type=gha
54
- cache-to: type=gha,mode=max
106
+ cache-to: type=gha,mode=max
package/.nvmrc ADDED
@@ -0,0 +1 @@
1
+ 24
package/Dockerfile CHANGED
@@ -1,23 +1,18 @@
1
- FROM node:20-alpine AS builder
1
+ FROM node:24-alpine
2
2
 
3
3
  WORKDIR /app
4
4
 
5
- RUN apk add --no-cache python3 make g++ py3-setuptools
6
-
7
- COPY package.json package-lock.json ./
8
- RUN npm ci
9
-
10
- COPY index.js .
11
- COPY lib lib
12
- COPY bots bots
13
- COPY README.md .
14
-
15
- FROM node:20-alpine
5
+ RUN apk add --no-cache libstdc++ sqlite sqlite-libs
16
6
 
17
- WORKDIR /app
7
+ ARG PACKAGE_VERSION
18
8
 
19
- RUN apk add --no-cache libstdc++ sqlite sqlite-libs
9
+ ENV DATABASE_URL \
10
+ ORIGIN \
11
+ PORT \
12
+ BOTS_CONFIG_FILE \
13
+ LOG_LEVEL
20
14
 
21
- COPY --from=builder /app/ ./
15
+ RUN npm init -y \
16
+ && npm install --omit=dev @evanp/activitypub-bot@${PACKAGE_VERSION:-latest}
22
17
 
23
- CMD ["npm", "start"]
18
+ CMD ["npx", "activitypub-bot"]
package/README.md CHANGED
@@ -43,11 +43,25 @@ It's also an [npm](https://npmjs.org/) package, [@evanp/activitypub-bot](https:/
43
43
 
44
44
  The server works as an ActivityPub server; bots appear as ActivityPub "actors".
45
45
 
46
- ### Environment variables
46
+ You can run it using `npx activitypub-bot`.
47
47
 
48
- The package can be configured with the following environment variables.
48
+ ### Command-line arguments
49
49
 
50
- #### DATABASE_URL
50
+ The program can be configured with command-line arguments.
51
+
52
+ ```sh
53
+ Usage: activitypub-bot [options]
54
+
55
+ Options:
56
+ --database-url <url> Database connection URL
57
+ --origin <url> Public origin URL for the server
58
+ --port <number> Port to listen on
59
+ --bots-config-file <path> Path to bots config module
60
+ --log-level <level> Log level (e.g., info, debug)
61
+ -h, --help Show this help
62
+ ```
63
+
64
+ #### --database-url
51
65
 
52
66
  A [sequelize](https://sequelize.org) database URI for storing the server data. The default is 'sqlite::memory', which will store data in memory and lose the data when the process shuts down; you probably don't want that. The server comes with Postgres, MySQL, and SQLite libraries; you might need to install libraries if you're using some other dialect.
53
67
 
@@ -55,33 +69,39 @@ The URI format varies by database backend; see [Postgres](https://www.postgresql
55
69
 
56
70
  The server creates and alters tables at runtime; whatever user you use to connect should have rights to do those things.
57
71
 
58
- #### ORIGIN
72
+ If unspecified, the server uses the environment variable `DATABASE_URL`.
73
+
74
+ #### --origin
59
75
 
60
76
  The [origin](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin) (protocol + hostname) for the server. This will only be used for formatting IDs, not for running the server. Use this if you're running the server
61
77
  behind a load balancer or inside a Kubernetes cluster.
62
78
 
63
- The default is 'https://activitypubbot.test', which doesn't work and probably isn't what you want.
79
+ Falls back to the `ORIGIN` environment variable. The default is 'https://activitypubbot.test', which doesn't work and probably isn't what you want.
64
80
 
65
- #### PORT
81
+ #### --port
66
82
 
67
- The [port](https://en.wikipedia.org/wiki/Port_(computer_networking)) number to listen on. This is only used for connection; URLs are created using [ORIGIN](#origin). Must be an integer number between 1 and 65535; the default is 9000.
83
+ The [port](https://en.wikipedia.org/wiki/Port_(computer_networking)) number to listen on. This is only used for connection; URLs are created using [--origin](#--origin). Must be an integer number between 1 and 65535; the default is 9000.
68
84
 
69
- #### LOG_LEVEL
85
+ Falls back to the `PORT` environment variable.
86
+
87
+ #### --log-level
70
88
 
71
89
  The minimum [pino](https://getpino.io) [log level](https://getpino.io/#/docs/api?id=logger-level) to output. This can be `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or `silent`. Whatever the log level is, messages with
72
90
  lower log levels won't appear in the logs. For example, if the log level is `info`, debug and trace log messages will be silently dropped. `silent` turns off logging altogether.
73
91
 
74
- The default is `info` (or `silent` when running unit tests).
92
+ Falls back to `LOG_LEVEL` environment variable. The default is `info` (or `silent` when running unit tests).
93
+
94
+ #### --bots-config-file
75
95
 
76
- #### BOTS_CONFIG_FILE
96
+ The path to the [config file](#config-file) for this server, which defines the usernames and code for the bots for this server.
77
97
 
78
- The path to the [config file](#config_file) for this server, which defines the usernames and code for the bots for this server. The default is to use the shipped default bot config file, which defines an [OKBot](#okbot) named `ok` and a [DoNothingBot](#donothingbot) named `null`.
98
+ Falls back to `BOTS_CONFIG_FILE` environment variable. The default is to use the shipped default bot config file, which defines an [OKBot](#okbot) named `ok` and a [DoNothingBot](#donothingbot) named `null`.
79
99
 
80
100
  ### Config file
81
101
 
82
102
  The config file defines the bots provided by this server.
83
103
 
84
- The config file is implemented as a JavaScript module. It should export a single object mapping a string name for the bot to an instance of a classes that implements the [Bot](#bot) interface.
104
+ The config file is implemented as a JavaScript module. It should export a single object mapping a string name for the bot to an instance of a class that implements the [Bot](#bot) or [BotFactory](#botfactory) interface.
85
105
 
86
106
  For example, the default config file declares two bot accounts: an [OKBot](#okbot) named `ok` and a [DoNothingBot](#donothingbot) named `null`.
87
107
 
@@ -95,6 +115,18 @@ export default {
95
115
  }
96
116
  ```
97
117
 
118
+ For another example, the `ProvinceBotFactory` defines bots for all the Canadian provinces and territories ('ab' for Alberta, 'bc' for British Columbia, and so on). The special `*` value is used for identifying a bot factory (only one allowed per server). Named bots will be identified first, and the bot factory will be used as a fallback.
119
+
120
+ ```js
121
+ import ProvinceBotFactory from './provincebotfactory.js'
122
+ import DoNothingBot from '../../lib/bots/donothing.js'
123
+
124
+ export default {
125
+ 'other', new DoNothingBot('other')
126
+ '*', new ProvinceBotFactory()
127
+ }
128
+ ```
129
+
98
130
  ### Pre-installed bot classes
99
131
 
100
132
  The following bot classes are pre-installed with the server.
@@ -109,7 +141,11 @@ A *DoNothingBot* instance will only do default stuff, like accepting follows.
109
141
 
110
142
  ## API
111
143
 
112
- New bot classes must implement the [Bot](#bot) interface, which is easiest if you inherit from the `Bot` class. Bots will receive a [BotContext](#botcontext) object at initialization. The BotContext is the main way to access data or execute activities.
144
+ Custom bots can implement the [Bot](#bot) interface, which is easiest if you inherit from the `Bot` class.
145
+
146
+ Alternatively, you can implement a whole class of bots with the [BotFactory](#botfactory) interface, inheriting from the `BotFactory` class.
147
+
148
+ Bots will receive a [BotContext](#botcontext) object at initialization. The BotContext is the main way to access data or execute activities.
113
149
 
114
150
  ### Bot
115
151
 
@@ -144,6 +180,40 @@ getter is needed to retrieve it.)
144
180
 
145
181
  Called when the bot is mentioned in an incoming object. Can be used to implement conversational interfaces. `object` is the object of a `Create` activity, like a `Note`, that mentions the bot; it's represented in [activitystrea.ms](#activitystreams) format. `activity` is the activity itself.
146
182
 
183
+ #### async onFollow (actor, activity)
184
+
185
+ Called when the bot is followed by another actor. The first argument is the actor, and the second is the `Follow` activity itself. This method is called after the acceptance has been sent and the new
186
+ follower has been added to the `followers` collection.
187
+
188
+ #### async onLike (object, activity)
189
+
190
+ Called when one of the bot's objects is liked by another actor. The first argument is the object, and the second is the `Like` activity itself. This method is called after the like has been added to the
191
+ `likes` collection.
192
+
193
+ #### async onPublic (activity)
194
+
195
+ Called when the server receives a public activity to its shared inbox. This can be used to skim through ActivityPub network content.
196
+
197
+ #### async onAnnounce (object, activity)
198
+
199
+ Called when one of the bot's objects is shared by another actor. The first argument is the object, and the second is the `Announce` activity itself. This method is called after the activity has been added to the `shares` collection.
200
+
201
+ ### BotFactory
202
+
203
+ The BotFactory interface lets you have a lot of bots that act in a similar way without declaring them explicitly in the bots config file.
204
+
205
+ #### async canCreate (username)
206
+
207
+ Can this factory create a bot with this username? Boolean response. For example, the `ProvinceBotFactory` checks that there is a matching 2-letter province code for the username.
208
+
209
+ #### async create (username)
210
+
211
+ Create a bot with this username and return it. The bot must implement the [Bot](#bot) interface.
212
+
213
+ #### async onPublic (activity)
214
+
215
+ When a public activity is received at the shared inbox of the server, this method is called for the bot factory.
216
+
147
217
  ### BotContext
148
218
 
149
219
  This is the bot's control panel for working with the rest of the server.
@@ -202,6 +272,10 @@ Sends a `Like` activity for the passed-in object in [activitystrea.ms](#activity
202
272
 
203
273
  Sends an `Undo`/`Like` activity for the passed-in object in [activitystrea.ms](#activitystreams) form which was previously liked.
204
274
 
275
+ #### async announceObject (obj)
276
+
277
+ Sends an `Announce` activity for the passed-in object in [activitystrea.ms](#activitystreams) form to followers.
278
+
205
279
  #### async followActor (actor)
206
280
 
207
281
  Sends a `Follow` activity for the passed-in actor in [activitystrea.ms](#activitystreams) form.
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'node:util'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { dirname, resolve } from 'node:path'
6
+ import { makeApp } from './lib/app.js'
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ 'database-url': { type: 'string' },
11
+ origin: { type: 'string' },
12
+ port: { type: 'string' },
13
+ 'bots-config-file': { type: 'string' },
14
+ 'log-level': { type: 'string' },
15
+ help: { type: 'boolean', short: 'h' }
16
+ },
17
+ allowPositionals: false
18
+ })
19
+
20
+ if (values.help) {
21
+ console.log(`Usage: activitypub-bot [options]
22
+
23
+ Options:
24
+ --database-url <url> Database connection URL
25
+ --origin <url> Public origin URL for the server
26
+ --port <number> Port to listen on
27
+ --bots-config-file <path> Path to bots config module
28
+ --log-level <level> Log level (e.g., info, debug)
29
+ -h, --help Show this help
30
+ `)
31
+ process.exit(0)
32
+ }
33
+
34
+ const normalize = (value) => (value === '' ? undefined : value)
35
+ const parsePort = (value) => {
36
+ if (!value) return undefined
37
+ const parsed = Number.parseInt(value, 10)
38
+ return Number.isNaN(parsed) ? undefined : parsed
39
+ }
40
+
41
+ const baseDir = dirname(fileURLToPath(import.meta.url))
42
+ const DEFAULT_BOTS_CONFIG_FILE = resolve(baseDir, 'bots', 'index.js')
43
+
44
+ const DATABASE_URL = normalize(values['database-url']) || process.env.DATABASE_URL || 'sqlite::memory:'
45
+ const ORIGIN = normalize(values.origin) || process.env.ORIGIN || 'https://activitypubbot.test'
46
+ const PORT = parsePort(normalize(values.port)) || parsePort(process.env.PORT) || 9000 // HAL
47
+ const BOTS_CONFIG_FILE =
48
+ normalize(values['bots-config-file']) || process.env.BOTS_CONFIG_FILE || DEFAULT_BOTS_CONFIG_FILE
49
+ const LOG_LEVEL =
50
+ normalize(values['log-level']) ||
51
+ process.env.LOG_LEVEL ||
52
+ (process.env.NODE_ENV === 'test' ? 'silent' : 'info')
53
+
54
+ const bots = (await import(BOTS_CONFIG_FILE)).default
55
+
56
+ const app = await makeApp(DATABASE_URL, ORIGIN, bots, LOG_LEVEL)
57
+
58
+ const server = app.listen(parseInt(PORT), () => {
59
+ app.locals.logger.info(`Listening on port ${PORT}`)
60
+ })
61
+
62
+ process.on('SIGTERM', () => {
63
+ console.log('Received SIGTERM')
64
+ server.close(async () => {
65
+ await app.cleanup()
66
+ process.exit(0)
67
+ })
68
+ })
@@ -0,0 +1,260 @@
1
+ import BotMaker from './botmaker.js'
2
+ import assert from 'node:assert'
3
+ import as2 from './activitystreams.js'
4
+
5
+ const NS = 'https://www.w3.org/ns/activitystreams#'
6
+
7
+ const PUBLIC = [
8
+ `${NS}Public`,
9
+ 'as:Public',
10
+ 'Public'
11
+ ]
12
+
13
+ const COLLECTION_TYPES = [
14
+ `${NS}Collection`,
15
+ `${NS}OrderedCollection`
16
+ ]
17
+
18
+ export class ActivityDeliverer {
19
+ #actorStorage
20
+ #activityHandler
21
+ #formatter
22
+ #logger
23
+ #client
24
+
25
+ constructor (actorStorage, activityHandler, formatter, logger, client) {
26
+ this.#actorStorage = actorStorage
27
+ this.#activityHandler = activityHandler
28
+ this.#formatter = formatter
29
+ this.#client = client
30
+ this.#logger = logger.child({ class: this.constructor.name })
31
+ }
32
+
33
+ isActivity (object) {
34
+ return true
35
+ }
36
+
37
+ getActor (activity) {
38
+ return activity.actor?.first
39
+ }
40
+
41
+ getRecipients (obj) {
42
+ let r = []
43
+ for (const prop of ['to', 'cc', 'audience']) {
44
+ const val = obj.get(prop)
45
+ if (val) {
46
+ r = r.concat(Array.from(val))
47
+ }
48
+ }
49
+ return r
50
+ }
51
+
52
+ async deliverTo (activity, bot) {
53
+ try {
54
+ await this.#activityHandler.handleActivity(bot, activity)
55
+ } catch (err) {
56
+ this.#logger.warn(err)
57
+ }
58
+ this.#logger.debug(`Adding ${activity.id} to ${bot.username} inbox`)
59
+ await this.#actorStorage.addToCollection(bot.username, 'inbox', activity)
60
+ }
61
+
62
+ async deliverToAll (activity, bots) {
63
+ const deliveredTo = new Set()
64
+ const actor = this.getActor(activity)
65
+ const recipients = this.getRecipients(activity)
66
+
67
+ for (const recipient of recipients) {
68
+ if (this.#isPublic(recipient)) {
69
+ await this.#deliverPublic(activity, bots)
70
+ } else if (this.#isLocal(recipient)) {
71
+ const parts = this.#formatter.unformat(recipient.id)
72
+ if (this.#isLocalActor(parts)) {
73
+ await this.#deliverLocalActor(activity, recipient, bots, deliveredTo)
74
+ } else if (this.#isLocalFollowersCollection(parts)) {
75
+ await this.#deliverLocalFollowersCollection(activity, parts.username, bots, deliveredTo)
76
+ } else if (this.#isLocalFollowingCollection(parts)) {
77
+ await this.#deliverLocalFollowingCollection(activity, parts.username, bots, deliveredTo)
78
+ } else {
79
+ this.#logger.warn(
80
+ `Unrecognized recipient for remote delivery: ${recipient.id}`
81
+ )
82
+ }
83
+ } else {
84
+ const fullActor = await this.#client.get(actor.id)
85
+ const fullRecipient = await this.#client.get(recipient.id)
86
+ if (await this.#isRemoteActor(fullRecipient)) {
87
+ this.#logger.warn(`Skipping remote actor ${recipient.id}`)
88
+ } else if (await this.#isRemoteFollowersCollection(fullActor, fullRecipient)) {
89
+ await this.#deliverRemoteFollowersCollection(activity, fullRecipient, fullActor, deliveredTo, bots)
90
+ } else if (await this.#isRemoteFollowingCollection(fullActor, fullRecipient)) {
91
+ await this.#deliverRemoteFollowingCollection(activity, fullRecipient, fullActor, deliveredTo, bots)
92
+ } else if (await this.#isRemoteCollection(fullRecipient)) {
93
+ await this.#deliverRemoteCollection(activity, fullRecipient, deliveredTo, bots)
94
+ } else {
95
+ this.#logger.warn(`Unrecognized recipient: ${recipient.id}`)
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ async #isRemoteFollowersCollection (actor, object) {
102
+ assert.strictEqual(typeof actor, 'object')
103
+ assert.strictEqual(typeof actor.id, 'string')
104
+ assert.strictEqual(typeof object, 'object')
105
+ assert.strictEqual(typeof object.id, 'string')
106
+
107
+ return (actor.followers?.first?.id === object.id)
108
+ }
109
+
110
+ async #isRemoteFollowingCollection (actor, object) {
111
+ assert.strictEqual(typeof actor, 'object')
112
+ assert.strictEqual(typeof actor.id, 'string')
113
+ assert.strictEqual(typeof object, 'object')
114
+ assert.strictEqual(typeof object.id, 'string')
115
+
116
+ return (actor.following?.first?.id === object.id)
117
+ }
118
+
119
+ async #getLocalFollowers (actor) {
120
+ return await this.#actorStorage.getUsernamesWith('following', actor)
121
+ }
122
+
123
+ async #getLocalFollowing (actor) {
124
+ return await this.#actorStorage.getUsernamesWith('followers', actor)
125
+ }
126
+
127
+ async #deliverLocalActor (activity, recipient, bots, deliveredTo) {
128
+ const { username } = this.#formatter.unformat(recipient.id)
129
+ if (!deliveredTo.has(username)) {
130
+ const bot = await BotMaker.makeBot(bots, username)
131
+ if (!bot) {
132
+ this.#logger.warn(`sharedInbox direct delivery for unknown bot ${username}`)
133
+ }
134
+ await this.deliverTo(activity, bot)
135
+ deliveredTo.add(username)
136
+ }
137
+ }
138
+
139
+ async #deliverRemoteFollowersCollection (activity, recipient, actor, deliveredTo, bots) {
140
+ const followers = await this.#getLocalFollowers(actor)
141
+ for (const username of followers) {
142
+ if (!deliveredTo.has(username)) {
143
+ const bot = await BotMaker.makeBot(bots, username)
144
+ if (!bot) {
145
+ this.#logger.warn(`sharedInbox direct delivery for unknown bot ${username}`)
146
+ continue
147
+ }
148
+ await this.deliverTo(activity, bot)
149
+ deliveredTo.add(username)
150
+ }
151
+ }
152
+ }
153
+
154
+ async #deliverRemoteFollowingCollection (activity, recipient, actor, deliveredTo, bots) {
155
+ const following = await this.#getLocalFollowing(actor)
156
+ for (const username of following) {
157
+ if (!deliveredTo.has(username)) {
158
+ const bot = await BotMaker.makeBot(bots, username)
159
+ if (!bot) {
160
+ this.#logger.warn(`sharedInbox direct delivery for unknown bot ${username}`)
161
+ continue
162
+ }
163
+ await this.deliverTo(activity, bot)
164
+ deliveredTo.add(username)
165
+ }
166
+ }
167
+ }
168
+
169
+ #isPublic (recipient) {
170
+ return PUBLIC.includes(recipient.id)
171
+ }
172
+
173
+ #isLocal (recipient) {
174
+ return this.#formatter.isLocal(recipient.id)
175
+ }
176
+
177
+ #isLocalActor (parts) {
178
+ return parts.username && !parts.collection && !parts.type
179
+ }
180
+
181
+ #isLocalFollowersCollection (parts) {
182
+ return parts.username && (parts.collection === 'followers')
183
+ }
184
+
185
+ #isLocalFollowingCollection (parts) {
186
+ return parts.username && (parts.collection === 'following')
187
+ }
188
+
189
+ async #isRemoteCollection (recipient) {
190
+ assert.strictEqual(typeof recipient, 'object')
191
+ assert.strictEqual(typeof recipient.id, 'string')
192
+
193
+ return (Array.isArray(recipient.type))
194
+ ? recipient.type.some(item => COLLECTION_TYPES.includes(item))
195
+ : COLLECTION_TYPES.includes(recipient.type)
196
+ }
197
+
198
+ async #isRemoteActor (recipient) {
199
+ assert.strictEqual(typeof recipient, 'object')
200
+ assert.strictEqual(typeof recipient.id, 'string')
201
+
202
+ return !!recipient.inbox?.first?.id
203
+ }
204
+
205
+ async #deliverPublic (activity, bots) {
206
+ await Promise.all(Object.values(bots).map(bot => bot.onPublic(activity)))
207
+ }
208
+
209
+ async #deliverLocalFollowersCollection (activity, username, bots, deliveredTo) {
210
+ const id = this.#formatter.format({ username })
211
+ const followed = await as2.import({ id })
212
+ const followers = await this.#actorStorage.getUsernamesWith('following', followed)
213
+ for (const follower of followers) {
214
+ if (!deliveredTo.has(follower)) {
215
+ const bot = await BotMaker.makeBot(bots, follower)
216
+ if (!bot) {
217
+ this.#logger.warn(`sharedInbox delivery for unknown bot ${follower}`)
218
+ continue
219
+ }
220
+ await this.deliverTo(activity, bot)
221
+ deliveredTo.add(follower)
222
+ }
223
+ }
224
+ }
225
+
226
+ async #deliverLocalFollowingCollection (activity, username, bots, deliveredTo) {
227
+ const id = this.#formatter.format({ username })
228
+ const following = await as2.import({ id })
229
+ const followeds = await this.#actorStorage.getUsernamesWith('followers', following)
230
+ for (const followed of followeds) {
231
+ if (!deliveredTo.has(followed)) {
232
+ const bot = await BotMaker.makeBot(bots, followed)
233
+ if (!bot) {
234
+ this.#logger.warn(`sharedInbox delivery for unknown bot ${followed}`)
235
+ continue
236
+ }
237
+ await this.deliverTo(activity, bot)
238
+ deliveredTo.add(followed)
239
+ }
240
+ }
241
+ }
242
+
243
+ async #deliverRemoteCollection (activity, recipient, deliveredTo, bots) {
244
+ for await (const item of this.#client.items(recipient.id)) {
245
+ this.#logger.debug(`item: ${JSON.stringify(item)}`)
246
+ if (this.#isLocal(item)) {
247
+ const parts = this.#formatter.unformat(item.id)
248
+ if (this.#isLocalActor(parts)) {
249
+ const bot = await BotMaker.makeBot(bots, parts.username)
250
+ if (!bot) {
251
+ this.#logger.warn(`sharedInbox delivery for unknown bot ${parts.username}`)
252
+ continue
253
+ }
254
+ await this.deliverTo(activity, bot)
255
+ deliveredTo.add(parts.username)
256
+ }
257
+ }
258
+ }
259
+ }
260
+ }
@@ -314,6 +314,11 @@ export class ActivityHandler {
314
314
  object: activity,
315
315
  to: actor
316
316
  }))
317
+ this.#logger.debug({
318
+ msg: 'Notifying bot of new follow',
319
+ actor: actor.id
320
+ })
321
+ await bot.onFollow(actor, activity)
317
322
  }
318
323
 
319
324
  async #handleAccept (bot, activity) {
@@ -516,6 +521,7 @@ export class ActivityHandler {
516
521
  }),
517
522
  ...recipients
518
523
  }))
524
+ await bot.onLike(object, activity)
519
525
  }
520
526
 
521
527
  async #handleAnnounce (bot, activity) {
@@ -592,6 +598,14 @@ export class ActivityHandler {
592
598
  }),
593
599
  ...recipients
594
600
  }))
601
+ try {
602
+ await bot.onAnnounce(object, activity)
603
+ } catch (err) {
604
+ this.#logger.warn(
605
+ `Error notifying bot of announce: ${err.message}`,
606
+ { err, username: bot.username, activity: activity.id }
607
+ )
608
+ }
595
609
  }
596
610
 
597
611
  async #handleBlock (bot, activity) {