@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
@@ -10,30 +10,6 @@ export class RemoteKeyStorage {
10
10
  this.#logger = logger.child({ class: this.constructor.name })
11
11
  }
12
12
 
13
- async initialize () {
14
- await this.#connection.query(
15
- `CREATE TABLE IF NOT EXISTS new_remotekeys (
16
- id VARCHAR(512) PRIMARY KEY,
17
- owner VARCHAR(512) NOT NULL,
18
- publicKeyPem TEXT NOT NULL,
19
- createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
20
- updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
21
- )`
22
- )
23
- try {
24
- await this.#connection.query(
25
- `INSERT OR IGNORE INTO new_remotekeys (id, owner, publicKeyPem)
26
- SELECT id, owner, publicKeyPem
27
- FROM remotekeys`
28
- )
29
- } catch (error) {
30
- this.#logger.debug(
31
- { error, method: 'RemoteKeyStorage.initialize' },
32
- 'failed to copy remotekeys to new_remotekeys table'
33
- )
34
- }
35
- }
36
-
37
13
  async getPublicKey (id, useCache = true) {
38
14
  this.debug(`getPublicKey(${id})`)
39
15
  if (useCache) {
@@ -1,6 +1,7 @@
1
1
  import express from 'express'
2
2
  import as2 from '../activitystreams.js'
3
3
  import createHttpError from 'http-errors'
4
+ import BotMaker from '../botmaker.js'
4
5
 
5
6
  const router = express.Router()
6
7
 
@@ -18,7 +19,8 @@ async function filterAsync (array, asyncPredicate) {
18
19
  router.get('/user/:username/:collection', async (req, res, next) => {
19
20
  const { username, collection } = req.params
20
21
  const { actorStorage, bots } = req.app.locals
21
- if (!(username in bots)) {
22
+ const bot = await BotMaker.makeBot(bots, username)
23
+ if (!bot) {
22
24
  return next(createHttpError(404, `User ${username} not found`))
23
25
  }
24
26
  if (collection === 'inbox') {
@@ -37,10 +39,12 @@ router.get('/user/:username/:collection', async (req, res, next) => {
37
39
  router.get('/user/:username/:collection/:n(\\d+)', async (req, res, next) => {
38
40
  const { username, collection, n } = req.params
39
41
  const { actorStorage, bots, authorizer, objectStorage, formatter, client } = req.app.locals
42
+ const bot = await BotMaker.makeBot(bots, username)
40
43
 
41
- if (!(username in bots)) {
44
+ if (!bot) {
42
45
  return next(createHttpError(404, `User ${username} not found`))
43
46
  }
47
+
44
48
  if (collection === 'inbox') {
45
49
  return next(createHttpError(403, `No access to ${collection} collection`))
46
50
  }
@@ -1,26 +1,19 @@
1
1
  import express from 'express'
2
2
  import as2 from '../activitystreams.js'
3
3
  import createHttpError from 'http-errors'
4
+ import BotMaker from '../botmaker.js'
4
5
 
5
6
  const router = express.Router()
6
7
 
7
- function isActivity (object) {
8
- return true
9
- }
10
-
11
- function getActor (activity) {
12
- return activity.actor?.first
13
- }
14
-
15
8
  router.post('/user/:username/inbox', async (req, res, next) => {
16
9
  const { username } = req.params
17
- const { bots, actorStorage, activityHandler } = req.app.locals
10
+ const { bots, deliverer, actorStorage } = req.app.locals
18
11
  const { subject } = req.auth
19
12
  const { logger } = req.app.locals
20
13
 
21
- const bot = bots[username]
14
+ const bot = await BotMaker.makeBot(bots, username)
22
15
  if (!bot) {
23
- return next(createHttpError(404, 'Not Found'))
16
+ return next(createHttpError(404, `User ${username} not found`))
24
17
  }
25
18
 
26
19
  if (!subject) {
@@ -41,11 +34,11 @@ router.post('/user/:username/inbox', async (req, res, next) => {
41
34
  return next(createHttpError(400, 'Invalid request body'))
42
35
  }
43
36
 
44
- if (!isActivity(activity)) {
37
+ if (!deliverer.isActivity(activity)) {
45
38
  return next(createHttpError(400, 'Request body is not an activity'))
46
39
  }
47
40
 
48
- const actor = getActor(activity)
41
+ const actor = deliverer.getActor(activity)
49
42
 
50
43
  if (!actor) {
51
44
  return next(createHttpError(400, 'No actor found in activity'))
@@ -67,13 +60,7 @@ router.post('/user/:username/inbox', async (req, res, next) => {
67
60
  return next(createHttpError(400, 'Activity already delivered'))
68
61
  }
69
62
 
70
- try {
71
- await activityHandler.handleActivity(bot, activity)
72
- } catch (err) {
73
- return next(err)
74
- }
75
-
76
- await actorStorage.addToCollection(bot.username, 'inbox', activity)
63
+ await deliverer.deliverTo(activity, bot)
77
64
 
78
65
  res.status(200)
79
66
  res.type('text/plain')
@@ -0,0 +1,54 @@
1
+ import express from 'express'
2
+ import as2 from '../activitystreams.js'
3
+ import createHttpError from 'http-errors'
4
+
5
+ const router = express.Router()
6
+
7
+ router.post('/shared/inbox', async (req, res, next) => {
8
+ const { bots, deliverer, logger } = req.app.locals
9
+ const { subject } = req.auth
10
+
11
+ if (!subject) {
12
+ return next(createHttpError(401, 'Unauthorized'))
13
+ }
14
+
15
+ if (!req.body) {
16
+ return next(createHttpError(400, 'No request body provided'))
17
+ }
18
+
19
+ let activity
20
+
21
+ try {
22
+ activity = await as2.import(req.body)
23
+ } catch (err) {
24
+ logger.warn('Failed to import activity', err)
25
+ logger.debug('Request body', req.body)
26
+ return next(createHttpError(400, 'Invalid request body'))
27
+ }
28
+
29
+ if (!deliverer.isActivity(activity)) {
30
+ return next(createHttpError(400, 'Request body is not an activity'))
31
+ }
32
+
33
+ const actor = deliverer.getActor(activity)
34
+
35
+ if (!actor) {
36
+ return next(createHttpError(400, 'No actor found in activity'))
37
+ }
38
+
39
+ if (!actor.id) {
40
+ return next(createHttpError(400, 'No actor id found in activity'))
41
+ }
42
+
43
+ if (actor.id !== subject) {
44
+ return next(createHttpError(403, `${subject} is not the actor ${actor.id}`))
45
+ }
46
+
47
+ await deliverer.deliverToAll(activity, bots)
48
+
49
+ res.status(200)
50
+ res.type('text/plain')
51
+ res.send('OK')
52
+ })
53
+
54
+ export default router
@@ -1,13 +1,15 @@
1
1
  import express from 'express'
2
2
  import as2 from '../activitystreams.js'
3
3
  import createHttpError from 'http-errors'
4
+ import BotMaker from '../botmaker.js'
4
5
 
5
6
  const router = express.Router()
6
7
 
7
8
  router.get('/user/:username', async (req, res, next) => {
8
9
  const { username } = req.params
9
- const { actorStorage, keyStorage, formatter, bots } = req.app.locals
10
- if (!(username in bots)) {
10
+ const { actorStorage, keyStorage, formatter, bots, origin } = req.app.locals
11
+ const bot = await BotMaker.makeBot(bots, username)
12
+ if (!bot) {
11
13
  return next(createHttpError(404, `User ${username} not found`))
12
14
  }
13
15
  const publicKeyPem = await keyStorage.getPublicKey(username)
@@ -16,14 +18,17 @@ router.get('/user/:username', async (req, res, next) => {
16
18
  'https://www.w3.org/ns/activitystreams',
17
19
  'https://w3id.org/security/v1'
18
20
  ],
19
- name: bots[username].fullname,
20
- summary: bots[username].description,
21
+ name: bot.fullname,
22
+ summary: bot.description,
21
23
  publicKey: {
22
24
  publicKeyPem,
23
25
  id: formatter.format({ username, type: 'publickey' }),
24
26
  owner: formatter.format({ username }),
25
27
  type: 'CryptographicKey',
26
28
  to: 'as:Public'
29
+ },
30
+ endpoints: {
31
+ sharedInbox: `${origin}/shared/inbox`
27
32
  }
28
33
  })
29
34
  res.status(200)
@@ -37,7 +42,8 @@ router.get('/user/:username', async (req, res, next) => {
37
42
  router.get('/user/:username/publickey', async (req, res, next) => {
38
43
  const { username } = req.params
39
44
  const { formatter, keyStorage, bots } = req.app.locals
40
- if (!(username in bots)) {
45
+ const bot = await BotMaker.makeBot(bots, username)
46
+ if (!bot) {
41
47
  return next(createHttpError(404, `User ${username} not found`))
42
48
  }
43
49
  const publicKeyPem = await keyStorage.getPublicKey(username)
@@ -3,11 +3,14 @@ import createHttpError from 'http-errors'
3
3
 
4
4
  const router = Router()
5
5
 
6
- router.get('/.well-known/webfinger', (req, res, next) => {
6
+ router.get('/.well-known/webfinger', async (req, res, next) => {
7
7
  const { resource } = req.query
8
8
  if (!resource) {
9
9
  return next(createHttpError(400, 'resource parameter is required'))
10
10
  }
11
+ if (resource.substring(0, 5) !== 'acct:') {
12
+ return next(createHttpError(400, 'only acct: protocol supported'))
13
+ }
11
14
  const [username, domain] = resource.substring(5).split('@')
12
15
  if (!username || !domain) {
13
16
  return next(createHttpError(400, 'Invalid resource parameter'))
@@ -17,7 +20,13 @@ router.get('/.well-known/webfinger', (req, res, next) => {
17
20
  return next(createHttpError(400, 'Invalid domain in resource parameter'))
18
21
  }
19
22
  if (!(username in req.app.locals.bots)) {
20
- return next(createHttpError(404, 'Bot not found'))
23
+ if ('*' in req.app.locals.bots) {
24
+ if (!await req.app.locals.bots['*'].canCreate(username)) {
25
+ return next(createHttpError(404, 'Bot not found'))
26
+ }
27
+ } else {
28
+ return next(createHttpError(404, 'Bot not found'))
29
+ }
21
30
  }
22
31
  res.status(200)
23
32
  res.type('application/jrd+json')
@@ -94,4 +94,12 @@ export class UrlFormatter {
94
94
  }
95
95
  return parts
96
96
  }
97
+
98
+ isActor (url) {
99
+ if (!this.isLocal(url)) {
100
+ return false
101
+ }
102
+ const parts = this.unformat(url)
103
+ return (parts.username && !parts.type && !parts.collection)
104
+ }
97
105
  }
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@evanp/activitypub-bot",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "server-side ActivityPub bot framework",
5
5
  "type": "module",
6
- "main": "index.js",
6
+ "main": "activitypub-bot.js",
7
+ "bin": {
8
+ "activitypub-bot": "./activitypub-bot.js"
9
+ },
7
10
  "scripts": {
8
11
  "test": "NODE_ENV=test node --test",
9
- "start": "node index.js"
12
+ "start": "npx activitypub-bot"
10
13
  },
11
14
  "repository": {
12
15
  "type": "git",
@@ -25,7 +28,7 @@
25
28
  },
26
29
  "homepage": "https://github.com/evanp/activitypub-bot#readme",
27
30
  "dependencies": {
28
- "@isaacs/ttlcache": "^1.4.1",
31
+ "@isaacs/ttlcache": "^2.1.4",
29
32
  "activitystrea.ms": "3.2",
30
33
  "express": "^4.22.1",
31
34
  "http-errors": "^2.0.0",
@@ -33,8 +36,8 @@
33
36
  "lru-cache": "^11.1.0",
34
37
  "nanoid": "^5.1.5",
35
38
  "node-fetch": "^3.3.2",
36
- "p-queue": "^8.1.0",
37
- "pino": "^9.7.0",
39
+ "p-queue": "^9.1.0",
40
+ "pino": "^10.1.1",
38
41
  "pino-http": "^11.0.0",
39
42
  "sequelize": "^6.37.7"
40
43
  },
@@ -47,5 +50,9 @@
47
50
  "mysql2": "^3.9.1",
48
51
  "pg": "^8.16.3",
49
52
  "sqlite3": "^5.1.7"
50
- }
53
+ },
54
+ "engines": {
55
+ "node": "^20 || ^22 || ^24 || ^25"
56
+ },
57
+ "engineStrict": true
51
58
  }
@@ -11,6 +11,7 @@ import { ActivityDistributor } from '../lib/activitydistributor.js'
11
11
  import Logger from 'pino'
12
12
  import { HTTPSignature } from '../lib/httpsignature.js'
13
13
  import { Digester } from '../lib/digester.js'
14
+ import { runMigrations } from '../lib/migrations/index.js'
14
15
 
15
16
  const makeActor = (domain, username, shared = true) =>
16
17
  as2.import({
@@ -63,12 +64,11 @@ describe('ActivityDistributor', () => {
63
64
  before(async () => {
64
65
  logger = Logger({ level: 'silent' })
65
66
  formatter = new UrlFormatter('https://activitypubbot.example')
66
- connection = new Sequelize('sqlite::memory:', { logging: false })
67
+ connection = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
67
68
  await connection.authenticate()
69
+ await runMigrations(connection)
68
70
  actorStorage = new ActorStorage(connection, formatter)
69
- await actorStorage.initialize()
70
71
  keyStorage = new KeyStorage(connection, logger)
71
- await keyStorage.initialize()
72
72
  const signer = new HTTPSignature(logger)
73
73
  const digester = new Digester(logger)
74
74
  client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
@@ -17,6 +17,9 @@ import bots from './fixtures/bots.js'
17
17
  import { nockSetup, postInbox, makeActor, nockFormat } from './utils/nock.js'
18
18
  import { Digester } from '../lib/digester.js'
19
19
  import { HTTPSignature } from '../lib/httpsignature.js'
20
+ import { runMigrations } from '../lib/migrations/index.js'
21
+ import { BotContext } from '../lib/botcontext.js'
22
+ import { Transformer } from '../lib/microsyntax.js'
20
23
 
21
24
  describe('ActivityHandler', () => {
22
25
  const domain = 'activitypubbot.example'
@@ -36,27 +39,46 @@ describe('ActivityHandler', () => {
36
39
  let botId = null
37
40
  const botName = 'ok'
38
41
  let bot = null
42
+ const loggerBotName = 'logging'
43
+ let lb = null
44
+ let lbId = null
45
+ let transformer = null
39
46
  before(async () => {
40
47
  logger = Logger({ level: 'silent' })
41
48
  formatter = new UrlFormatter(origin)
42
- connection = new Sequelize('sqlite::memory:', { logging: false })
49
+ connection = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
43
50
  await connection.authenticate()
51
+ await runMigrations(connection)
44
52
  botDataStorage = new BotDataStorage(connection)
45
- await botDataStorage.initialize()
46
53
  objectStorage = new ObjectStorage(connection)
47
- await objectStorage.initialize()
48
54
  keyStorage = new KeyStorage(connection, logger)
49
- await keyStorage.initialize()
50
55
  actorStorage = new ActorStorage(connection, formatter)
51
- await actorStorage.initialize()
52
56
  const signer = new HTTPSignature(logger)
53
57
  const digester = new Digester(logger)
54
58
  client = new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
55
59
  distributor = new ActivityDistributor(client, formatter, actorStorage, logger)
56
60
  authz = new Authorizer(actorStorage, formatter, client)
57
61
  cache = new ObjectCache({ longTTL: 3600 * 1000, shortTTL: 300 * 1000, maxItems: 1000 })
62
+ transformer = new Transformer(`${origin}/tag/`, client)
63
+ await Promise.all(
64
+ Object.values(bots).map(bot => bot.initialize(
65
+ new BotContext(
66
+ bot.username,
67
+ botDataStorage,
68
+ objectStorage,
69
+ actorStorage,
70
+ client,
71
+ distributor,
72
+ formatter,
73
+ transformer,
74
+ logger
75
+ )
76
+ ))
77
+ )
58
78
  botId = formatter.format({ username: botName })
79
+ lbId = formatter.format({ username: loggerBotName })
59
80
  bot = bots[botName]
81
+ lb = bots[loggerBotName]
60
82
  await objectStorage.create(await as2.import({
61
83
  id: formatter.format({ username: 'test1', type: 'object', nanoid: '_pEWsKke-7lACTdM3J_qd' }),
62
84
  type: 'Object',
@@ -299,6 +321,19 @@ describe('ActivityHandler', () => {
299
321
  await handler.onIdle()
300
322
  assert.ok(!postInbox.follower3)
301
323
  })
324
+ it('notifies the bot of a follow activity', async () => {
325
+ const actor = await makeActor('follower4')
326
+ const activity = await as2.import({
327
+ type: 'Follow',
328
+ id: 'https://social.example/user/follower4/follow/1',
329
+ actor: actor.id,
330
+ object: lbId,
331
+ to: lbId
332
+ })
333
+ await handler.handleActivity(lb, activity)
334
+ assert.ok(lb.follows.has(activity.id))
335
+ await handler.onIdle()
336
+ })
302
337
  it('can handle an accept activity', async () => {
303
338
  const actor = await makeActor('accepter1')
304
339
  const followActivity = await as2.import({
@@ -811,6 +846,34 @@ describe('ActivityHandler', () => {
811
846
  await objectStorage.isInCollection(note.id, 'likes', activity2)
812
847
  )
813
848
  })
849
+ it('notifies the bot of a like activity', async () => {
850
+ const actor = await makeActor('liker9')
851
+ const note = await as2.import({
852
+ attributedTo: lbId,
853
+ id: formatter.format({
854
+ username: loggerBotName,
855
+ type: 'note',
856
+ nanoid: 'IGeAucyHD-s3Ywg9X9sCo'
857
+ }),
858
+ type: 'Note',
859
+ content: 'Hello, world!',
860
+ to: 'as:Public'
861
+ })
862
+ await objectStorage.create(note)
863
+ const activity = await as2.import({
864
+ type: 'Like',
865
+ actor: actor.id,
866
+ id: nockFormat({
867
+ username: 'liker9',
868
+ type: 'Like',
869
+ nanoid: '3fKK6LcMtqrAp1Ekn471u'
870
+ }),
871
+ object: note.id,
872
+ to: [lbId, 'as:Public']
873
+ })
874
+ await handler.handleActivity(lb, activity)
875
+ assert.ok(lb.likes.has(activity.id))
876
+ })
814
877
  it('can handle an announce activity', async () => {
815
878
  const actor = await makeActor('announcer1')
816
879
  const note = await as2.import({
@@ -1017,6 +1080,34 @@ describe('ActivityHandler', () => {
1017
1080
  await objectStorage.isInCollection(note.id, 'shares', activity2)
1018
1081
  )
1019
1082
  })
1083
+ it('notifies the bot on an announce activity', async () => {
1084
+ const actor = await makeActor('announcer9')
1085
+ const note = await as2.import({
1086
+ attributedTo: lbId,
1087
+ id: formatter.format({
1088
+ username: loggerBotName,
1089
+ type: 'note',
1090
+ nanoid: 'LNCVgovrjpA6oSKnGDax2'
1091
+ }),
1092
+ type: 'Note',
1093
+ content: 'Hello, world!',
1094
+ to: 'as:Public'
1095
+ })
1096
+ await objectStorage.create(note)
1097
+ const activity = await as2.import({
1098
+ type: 'Announce',
1099
+ actor: actor.id,
1100
+ id: nockFormat({
1101
+ username: 'announcer9',
1102
+ type: 'Announce',
1103
+ nanoid: 'LmVvlEBHNf2X6nfgzMe6F'
1104
+ }),
1105
+ object: note.id,
1106
+ to: [lbId, 'as:Public']
1107
+ })
1108
+ await handler.handleActivity(lb, activity)
1109
+ assert.ok(lb.shares.has(activity.id))
1110
+ })
1020
1111
  it('can handle a block activity', async () => {
1021
1112
  const actor = await makeActor('blocker1')
1022
1113
  await actorStorage.addToCollection(botName, 'followers', actor)