@evanp/activitypub-bot 0.8.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 +262 -12
  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 +18 -11
  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,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) {
@@ -13,6 +13,13 @@ const { version } = JSON.parse(
13
13
  fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
14
14
  )
15
15
 
16
+ const NS = 'https://www.w3.org/ns/activitystreams#'
17
+
18
+ const COLLECTION_TYPES = [
19
+ `${NS}Collection`,
20
+ `${NS}OrderedCollection`
21
+ ]
22
+
16
23
  export class ActivityPubClient {
17
24
  static #githubUrl = 'https://github.com/evanp/activitypub-bot'
18
25
  static #userAgent = `activitypub.bot/${version} (${ActivityPubClient.#githubUrl})`
@@ -63,16 +70,20 @@ export class ActivityPubClient {
63
70
  'user-agent': ActivityPubClient.#userAgent
64
71
  }
65
72
  const method = 'GET'
73
+ this.#logger.debug(`Signing GET request for ${url}`)
66
74
  if (sign) {
67
75
  headers.signature =
68
76
  await this.#sign({ username, url, method, headers })
69
77
  }
70
- return await fetch(url,
78
+ this.#logger.debug(`Fetching ${url} with GET`)
79
+ const result = await fetch(url,
71
80
  {
72
81
  method,
73
82
  headers
74
83
  }
75
84
  )
85
+ this.#logger.debug(`Finished getting ${url}`)
86
+ return result
76
87
  }
77
88
 
78
89
  async #handleRes (res, url) {
@@ -100,7 +111,9 @@ export class ActivityPubClient {
100
111
  }
101
112
  const method = 'POST'
102
113
  assert.ok(headers)
114
+ this.#logger.debug(`Signing POST for ${url}`)
103
115
  headers.signature = await this.#sign({ username, url, method, headers })
116
+ this.#logger.debug(`Fetching POST for ${url}`)
104
117
  const res = await fetch(url,
105
118
  {
106
119
  method,
@@ -108,6 +121,7 @@ export class ActivityPubClient {
108
121
  body
109
122
  }
110
123
  )
124
+ this.#logger.debug(`Done fetching POST for ${url}`)
111
125
  if (res.status < 200 || res.status > 299) {
112
126
  throw createHttpError(res.status, await res.text())
113
127
  }
@@ -123,4 +137,41 @@ export class ActivityPubClient {
123
137
  : this.#urlFormatter.format({ server: true, type: 'publickey' })
124
138
  return this.#signer.sign({ privateKey, keyId, url, method, headers })
125
139
  }
140
+
141
+ #isCollection (obj) {
142
+ return (Array.isArray(obj.type))
143
+ ? obj.type.some(item => COLLECTION_TYPES.includes(item))
144
+ : COLLECTION_TYPES.includes(obj.type)
145
+ }
146
+
147
+ async * items (id, username = null) {
148
+ const coll = await this.get(id, username)
149
+
150
+ this.#logger.debug(`Got object ${id}`)
151
+
152
+ if (!this.#isCollection(coll)) {
153
+ throw new Error(`Can only iterate over a collection: ${id}`)
154
+ }
155
+
156
+ const items = (coll.items) ? coll.items : coll.orderedItems
157
+
158
+ if (items) {
159
+ for (const item of items) {
160
+ this.#logger.debug(`Yielding ${item.id}`)
161
+ yield item
162
+ }
163
+ } else if (coll.first) {
164
+ for (let page = coll.first; page; page = page.next) {
165
+ this.#logger.debug(`Getting page ${page.id}`)
166
+ page = await this.get(page.id)
167
+ const items = (page.items) ? page.items : page.orderedItems
168
+ if (items) {
169
+ for (const item of items) {
170
+ this.#logger.debug(`Yielding ${item.id}`)
171
+ yield item
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
126
177
  }
@@ -38,4 +38,35 @@ as2.registerContext('https://w3id.org/fep/5711', {
38
38
  }
39
39
  })
40
40
 
41
+ as2.registerContext('https://w3id.org/security/v1', {
42
+ '@context': {
43
+ sec: 'https://w3id.org/security#',
44
+ id: '@id',
45
+ type: '@type',
46
+ owner: {
47
+ '@id': 'sec:owner',
48
+ '@type': '@id'
49
+ },
50
+ publicKey: {
51
+ '@id': 'sec:publicKey',
52
+ '@type': '@id'
53
+ },
54
+ publicKeyPem: 'sec:publicKeyPem'
55
+ }
56
+ })
57
+
58
+ as2.registerContext('https://purl.archive.org/socialweb/thread/1.0', {
59
+ '@context': {
60
+ thr: 'https://purl.archive.org/socialweb/thread#',
61
+ thread: {
62
+ '@id': 'thr:thread',
63
+ '@type': '@id'
64
+ },
65
+ root: {
66
+ '@id': 'thr:root',
67
+ '@type': '@id'
68
+ }
69
+ }
70
+ })
71
+
41
72
  export default as2
@@ -10,34 +10,6 @@ export class ActorStorage {
10
10
  this.#formatter = formatter
11
11
  }
12
12
 
13
- async initialize () {
14
- await this.#connection.query(`
15
- CREATE TABLE IF NOT EXISTS actorcollection (
16
- username varchar(512) NOT NULL,
17
- property varchar(512) NOT NULL,
18
- first INTEGER NOT NULL,
19
- totalItems INTEGER NOT NULL DEFAULT 0,
20
- createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
21
- updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
22
- PRIMARY KEY (username, property)
23
- );`
24
- )
25
- await this.#connection.query(`
26
- CREATE TABLE IF NOT EXISTS actorcollectionpage (
27
- username varchar(512) NOT NULL,
28
- property varchar(512) NOT NULL,
29
- item varchar(512) NOT NULL,
30
- page INTEGER NOT NULL,
31
- createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
32
- PRIMARY KEY (username, property, item)
33
- );`
34
- )
35
- await this.#connection.query(
36
- `CREATE INDEX IF NOT EXISTS actorcollectionpage_username_property_page
37
- ON actorcollectionpage (username, property, page);`
38
- )
39
- }
40
-
41
13
  async getActor (username, props = {}) {
42
14
  assert.ok(username)
43
15
  assert.equal(typeof username, 'string')
@@ -266,6 +238,24 @@ export class ActorStorage {
266
238
  return page <= first
267
239
  }
268
240
 
241
+ async getUsernamesWith (property, object) {
242
+ assert.ok(property, 'property is required')
243
+ assert.equal(typeof property, 'string', 'property must be a string')
244
+ assert.ok(object, 'object is required')
245
+ assert.equal(typeof object, 'object', 'object must be an object')
246
+ const [result] = await this.#connection.query(
247
+ `SELECT username
248
+ FROM actorcollectionpage
249
+ WHERE property = ? AND item = ?`,
250
+ { replacements: [property, object.id] }
251
+ )
252
+ const usernames = []
253
+ for (const row of result) {
254
+ usernames.push(row.username)
255
+ }
256
+ return usernames
257
+ }
258
+
269
259
  async #getCollectionInfo (username, property) {
270
260
  const [result] = await this.#connection.query(
271
261
  `SELECT first, totalItems, createdAt, updatedAt
package/lib/app.js CHANGED
@@ -22,32 +22,33 @@ import collectionRouter from './routes/collection.js'
22
22
  import inboxRouter from './routes/inbox.js'
23
23
  import healthRouter from './routes/health.js'
24
24
  import webfingerRouter from './routes/webfinger.js'
25
+ import sharedInboxRouter from './routes/sharedinbox.js'
25
26
  import { BotContext } from './botcontext.js'
26
27
  import { Transformer } from './microsyntax.js'
27
28
  import { HTTPSignatureAuthenticator } from './httpsignatureauthenticator.js'
28
29
  import { Digester } from './digester.js'
30
+ import { runMigrations } from './migrations/index.js'
31
+ import { ActivityDeliverer } from './activitydeliverer.js'
29
32
 
30
33
  export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
31
34
  const logger = Logger({
32
35
  level: logLevel
33
36
  })
34
37
  logger.debug('Logger initialized')
35
- const connection = new Sequelize(databaseUrl, { logging: false })
38
+ const connection = databaseUrl === 'sqlite::memory:' || databaseUrl === 'sqlite::memory'
39
+ ? new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
40
+ : new Sequelize(databaseUrl, { logging: false })
41
+ await runMigrations(connection)
36
42
  const formatter = new UrlFormatter(origin)
37
43
  const signer = new HTTPSignature(logger)
38
44
  const digester = new Digester(logger)
39
45
  const actorStorage = new ActorStorage(connection, formatter)
40
- await actorStorage.initialize()
41
46
  const botDataStorage = new BotDataStorage(connection)
42
- await botDataStorage.initialize()
43
47
  const keyStorage = new KeyStorage(connection, logger)
44
- await keyStorage.initialize()
45
48
  const objectStorage = new ObjectStorage(connection)
46
- await objectStorage.initialize()
47
49
  const client =
48
50
  new ActivityPubClient(keyStorage, formatter, signer, digester, logger)
49
51
  const remoteKeyStorage = new RemoteKeyStorage(client, connection, logger)
50
- await remoteKeyStorage.initialize()
51
52
  const signature = new HTTPSignatureAuthenticator(remoteKeyStorage, signer, digester, logger)
52
53
  const distributor = new ActivityDistributor(
53
54
  client,
@@ -71,6 +72,14 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
71
72
  logger,
72
73
  client
73
74
  )
75
+ const deliverer = new ActivityDeliverer(
76
+ actorStorage,
77
+ activityHandler,
78
+ formatter,
79
+ logger,
80
+ client
81
+ )
82
+
74
83
  // TODO: Make an endpoint for tagged objects
75
84
  const transformer = new Transformer(origin + '/tag/', client)
76
85
  await Promise.all(
@@ -106,7 +115,8 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
106
115
  authorizer,
107
116
  bots,
108
117
  activityHandler,
109
- origin
118
+ origin,
119
+ deliverer
110
120
  }
111
121
 
112
122
  app.use(HTTPLogger({
@@ -134,6 +144,7 @@ export async function makeApp (databaseUrl, origin, bots, logLevel = 'silent') {
134
144
  app.use('/', inboxRouter)
135
145
  app.use('/', healthRouter)
136
146
  app.use('/', webfingerRouter)
147
+ app.use('/', sharedInboxRouter)
137
148
 
138
149
  app.use((err, req, res, next) => {
139
150
  const { logger } = req.app.locals
package/lib/bot.js CHANGED
@@ -7,6 +7,9 @@ export default class Bot {
7
7
  }
8
8
 
9
9
  async initialize (context) {
10
+ if (context.botId !== this.#username) {
11
+ throw new Error(`Mismatched context: ${context.botId} !== ${this.#username}`)
12
+ }
10
13
  this.#context = context
11
14
  }
12
15
 
@@ -41,4 +44,8 @@ export default class Bot {
41
44
  async onAnnounce (object, activity) {
42
45
  ; // no-op
43
46
  }
47
+
48
+ async onPublic (activity) {
49
+ ; // no-op
50
+ }
44
51
  }
package/lib/botcontext.js CHANGED
@@ -53,6 +53,22 @@ export class BotContext {
53
53
  this.#logger = logger.child({ class: 'BotContext', botId })
54
54
  }
55
55
 
56
+ // copy constructor
57
+
58
+ async duplicate (username) {
59
+ return new this.constructor(
60
+ username,
61
+ this.#botDataStorage,
62
+ this.#objectStorage,
63
+ this.#actorStorage,
64
+ this.#client,
65
+ this.#distributor,
66
+ this.#formatter,
67
+ this.#transformer,
68
+ this.#logger
69
+ )
70
+ }
71
+
56
72
  async setData (key, value) {
57
73
  await this.#botDataStorage.set(this.#botId, key, value)
58
74
  }
@@ -488,6 +504,40 @@ export class BotContext {
488
504
  return `${username}@${actorUrl.hostname}`
489
505
  }
490
506
 
507
+ async announceObject (obj) {
508
+ assert.ok(obj)
509
+ assert.equal(typeof obj, 'object')
510
+ const owners = obj.attributedTo
511
+ ? Array.from(obj.attributedTo).map((owner) => owner.id)
512
+ : Array.from(obj.actor).map((owner) => owner.id)
513
+ const activity = await as2.import({
514
+ type: 'Announce',
515
+ id: this.#formatter.format({
516
+ username: this.#botId,
517
+ type: 'Announce',
518
+ nanoid: nanoid()
519
+ }),
520
+ actor: this.#formatter.format({ username: this.#botId }),
521
+ summary: {
522
+ en: `${this.#botId} shared "${await this.#nameOf(obj)}"`
523
+ },
524
+ object: obj.id,
525
+ to: [
526
+ this.#formatter.format({
527
+ username: this.#botId,
528
+ collection: 'followers'
529
+ }),
530
+ 'https://www.w3.org/ns/activitystreams#Public'
531
+ ],
532
+ cc: owners
533
+ })
534
+ await this.#objectStorage.create(activity)
535
+ await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
536
+ await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
537
+ await this.#distributor.distribute(activity, this.#botId)
538
+ return activity
539
+ }
540
+
491
541
  async #findInOutbox (type, obj) {
492
542
  const full = `https://www.w3.org/ns/activitystreams#${type}`
493
543
  let found = null
@@ -514,6 +564,18 @@ export class BotContext {
514
564
  return { to, cc, bto, bcc, audience }
515
565
  }
516
566
 
567
+ #nameOf (obj) {
568
+ if (obj.name) {
569
+ return obj.name.valueOf()
570
+ } else if (obj.summary) {
571
+ return obj.summary.valueOf()
572
+ } else if (obj.type) {
573
+ return `a(n) ${obj.type.first}`
574
+ } else {
575
+ return 'an object'
576
+ }
577
+ }
578
+
517
579
  async onIdle () {
518
580
  await this.#distributor.onIdle()
519
581
  }
@@ -15,19 +15,6 @@ export class BotDataStorage {
15
15
  this.#connection = connection
16
16
  }
17
17
 
18
- async initialize () {
19
- await this.#connection.query(`
20
- CREATE TABLE IF NOT EXISTS botdata (
21
- username VARCHAR(512) not null,
22
- key VARCHAR(512) not null,
23
- value TEXT not null,
24
- createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
- updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
- PRIMARY KEY (username, key)
27
- )
28
- `)
29
- }
30
-
31
18
  async terminate () {
32
19
 
33
20
  }
@@ -0,0 +1,24 @@
1
+ export default class BotFactory {
2
+ #context
3
+
4
+ async initialize (context) {
5
+ this.#context = context
6
+ }
7
+
8
+ get _context () {
9
+ return this.#context
10
+ }
11
+
12
+ async canCreate (username) {
13
+ return false
14
+ }
15
+
16
+ async create (username) {
17
+ const name = this.constructor.name
18
+ throw new Error(`${name} class can't create bot named "${username}"`)
19
+ }
20
+
21
+ async onPublic (activity) {
22
+ ; // no-op
23
+ }
24
+ }