@evanp/activitypub-bot 0.8.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/.github/dependabot.yml +11 -0
- package/.github/workflows/main-docker.yml +45 -0
- package/.github/workflows/tag-docker.yml +54 -0
- package/Dockerfile +23 -0
- package/LICENSE +661 -0
- package/README.md +82 -0
- package/bots/index.js +7 -0
- package/docs/activitypub.bot.drawio +110 -0
- package/index.js +23 -0
- package/lib/activitydistributor.js +263 -0
- package/lib/activityhandler.js +999 -0
- package/lib/activitypubclient.js +126 -0
- package/lib/activitystreams.js +41 -0
- package/lib/actorstorage.js +300 -0
- package/lib/app.js +173 -0
- package/lib/authorizer.js +133 -0
- package/lib/bot.js +44 -0
- package/lib/botcontext.js +520 -0
- package/lib/botdatastorage.js +87 -0
- package/lib/bots/donothing.js +11 -0
- package/lib/bots/ok.js +41 -0
- package/lib/digester.js +23 -0
- package/lib/httpsignature.js +195 -0
- package/lib/httpsignatureauthenticator.js +81 -0
- package/lib/keystorage.js +113 -0
- package/lib/microsyntax.js +140 -0
- package/lib/objectcache.js +48 -0
- package/lib/objectstorage.js +319 -0
- package/lib/remotekeystorage.js +116 -0
- package/lib/routes/collection.js +92 -0
- package/lib/routes/health.js +24 -0
- package/lib/routes/inbox.js +83 -0
- package/lib/routes/object.js +69 -0
- package/lib/routes/server.js +47 -0
- package/lib/routes/user.js +63 -0
- package/lib/routes/webfinger.js +36 -0
- package/lib/urlformatter.js +97 -0
- package/package.json +51 -0
- package/tests/activitydistributor.test.js +606 -0
- package/tests/activityhandler.test.js +2185 -0
- package/tests/activitypubclient.test.js +225 -0
- package/tests/actorstorage.test.js +261 -0
- package/tests/app.test.js +17 -0
- package/tests/authorizer.test.js +306 -0
- package/tests/bot.donothing.test.js +30 -0
- package/tests/bot.ok.test.js +101 -0
- package/tests/botcontext.test.js +674 -0
- package/tests/botdatastorage.test.js +87 -0
- package/tests/digester.test.js +56 -0
- package/tests/fixtures/bots.js +15 -0
- package/tests/httpsignature.test.js +200 -0
- package/tests/httpsignatureauthenticator.test.js +463 -0
- package/tests/keystorage.test.js +89 -0
- package/tests/microsyntax.test.js +122 -0
- package/tests/objectcache.test.js +133 -0
- package/tests/objectstorage.test.js +148 -0
- package/tests/remotekeystorage.test.js +76 -0
- package/tests/routes.actor.test.js +207 -0
- package/tests/routes.collection.test.js +434 -0
- package/tests/routes.health.test.js +41 -0
- package/tests/routes.inbox.test.js +135 -0
- package/tests/routes.object.test.js +519 -0
- package/tests/routes.server.test.js +69 -0
- package/tests/routes.webfinger.test.js +41 -0
- package/tests/urlformatter.test.js +164 -0
- package/tests/utils/digest.js +7 -0
- package/tests/utils/nock.js +276 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export class Authorizer {
|
|
2
|
+
#PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
|
|
3
|
+
#actorStorage = null
|
|
4
|
+
#formatter = null
|
|
5
|
+
#activityPubClient = null
|
|
6
|
+
constructor (actorStorage, formatter, activityPubClient) {
|
|
7
|
+
this.#actorStorage = actorStorage
|
|
8
|
+
this.#formatter = formatter
|
|
9
|
+
this.#activityPubClient = activityPubClient
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async canRead (actor, object) {
|
|
13
|
+
if (typeof object !== 'object') {
|
|
14
|
+
throw new Error('object must be an object')
|
|
15
|
+
}
|
|
16
|
+
if (!('id' in object)) {
|
|
17
|
+
throw new Error('object must have an id property')
|
|
18
|
+
}
|
|
19
|
+
if (typeof object.id !== 'string') {
|
|
20
|
+
throw new Error('object.id must be a string')
|
|
21
|
+
}
|
|
22
|
+
return (this.#formatter.isLocal(object.id))
|
|
23
|
+
? await this.#canReadLocal(actor, object)
|
|
24
|
+
: await this.#canReadRemote(actor, object)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async isOwner (actor, object) {
|
|
28
|
+
const owner = await this.#getOwner(object)
|
|
29
|
+
return actor.id === owner.id
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async sameOrigin (actor, object) {
|
|
33
|
+
const actorUrl = new URL(actor.id)
|
|
34
|
+
const objectUrl = new URL(object.id)
|
|
35
|
+
return actorUrl.origin === objectUrl.origin
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async #canReadLocal (actor, object) {
|
|
39
|
+
const recipients = this.#getRecipients(object)
|
|
40
|
+
if (!actor) {
|
|
41
|
+
return recipients.has(this.#PUBLIC)
|
|
42
|
+
}
|
|
43
|
+
const ownerId = (await this.#getOwner(object))?.id
|
|
44
|
+
if (!ownerId) {
|
|
45
|
+
throw new Error(`no owner for ${object.id}`)
|
|
46
|
+
}
|
|
47
|
+
if (actor.id === ownerId) {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
const owner = await this.#actorStorage.getActorById(ownerId)
|
|
51
|
+
if (!owner) {
|
|
52
|
+
throw new Error(`no actor for ${ownerId}`)
|
|
53
|
+
}
|
|
54
|
+
const ownerName = owner.get('preferredUsername')?.first
|
|
55
|
+
if (!ownerName) {
|
|
56
|
+
throw new Error(`no preferredUsername for ${owner.id}`)
|
|
57
|
+
}
|
|
58
|
+
if (await this.#actorStorage.isInCollection(ownerName, 'blocked', actor)) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
if (recipients.has(actor.id)) {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
if (recipients.has(this.#PUBLIC)) {
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
67
|
+
const followers = this.#formatter.format({ username: ownerName, collection: 'followers' })
|
|
68
|
+
if (recipients.has(followers) && await this.#actorStorage.isInCollection(ownerName, 'followers', actor)) {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async #canReadRemote (actor, object) {
|
|
75
|
+
const recipients = this.#getRecipients(object)
|
|
76
|
+
if (!actor) {
|
|
77
|
+
return recipients.has(this.#PUBLIC)
|
|
78
|
+
}
|
|
79
|
+
if (recipients.has(actor.id)) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
if (recipients.has(this.#PUBLIC)) {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
// TODO: check if it's to followers, actor is local, and actor
|
|
86
|
+
// is a follower
|
|
87
|
+
// TODO: check if it's to a collection, and actor is in the
|
|
88
|
+
// collection
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async #getOwner (object) {
|
|
93
|
+
if (object.attributedTo) {
|
|
94
|
+
return object.attributedTo.first
|
|
95
|
+
} else if (object.actor) {
|
|
96
|
+
return object.actor.first
|
|
97
|
+
} else if (object.owner) {
|
|
98
|
+
return object.owner.first
|
|
99
|
+
} else {
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#getRecipients (activity) {
|
|
105
|
+
const recipientIds = new Set()
|
|
106
|
+
if (activity.to) {
|
|
107
|
+
for (const to of activity.to) {
|
|
108
|
+
recipientIds.add(to.id)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (activity.cc) {
|
|
112
|
+
for (const cc of activity.cc) {
|
|
113
|
+
recipientIds.add(cc.id)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (activity.audience) {
|
|
117
|
+
for (const audience of activity.audience) {
|
|
118
|
+
recipientIds.add(audience.id)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (activity.bto) {
|
|
122
|
+
for (const bto of activity.bto) {
|
|
123
|
+
recipientIds.add(bto.id)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (activity.bcc) {
|
|
127
|
+
for (const bcc of activity.bcc) {
|
|
128
|
+
recipientIds.add(bcc.id)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return recipientIds
|
|
132
|
+
}
|
|
133
|
+
}
|
package/lib/bot.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default class Bot {
|
|
2
|
+
#context = null
|
|
3
|
+
#username = null
|
|
4
|
+
|
|
5
|
+
constructor (username) {
|
|
6
|
+
this.#username = username
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async initialize (context) {
|
|
10
|
+
this.#context = context
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get fullname () {
|
|
14
|
+
return 'Bot'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get description () {
|
|
18
|
+
return 'A default, do-nothing bot.'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get username () {
|
|
22
|
+
return this.#username
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get _context () {
|
|
26
|
+
return this.#context
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async onMention (object, activity) {
|
|
30
|
+
; // no-op
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async onFollow (actor, activity) {
|
|
34
|
+
; // no-op
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async onLike (object, activity) {
|
|
38
|
+
; // no-op
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async onAnnounce (object, activity) {
|
|
42
|
+
; // no-op
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import as2 from './activitystreams.js'
|
|
3
|
+
import { nanoid } from 'nanoid'
|
|
4
|
+
import fetch from 'node-fetch'
|
|
5
|
+
|
|
6
|
+
const AS2_TYPES = [
|
|
7
|
+
'application/activity+json',
|
|
8
|
+
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const WF_NS = 'https://purl.archive.org/socialweb/webfinger#'
|
|
12
|
+
|
|
13
|
+
const THREAD_PROP = 'https://purl.archive.org/socialweb/thread#thread'
|
|
14
|
+
const CONVERSATION_PROP = 'http://ostatus.org/schema/1.0/conversation'
|
|
15
|
+
|
|
16
|
+
export class BotContext {
|
|
17
|
+
#botId = null
|
|
18
|
+
#botDataStorage = null
|
|
19
|
+
#objectStorage = null
|
|
20
|
+
#actorStorage = null
|
|
21
|
+
#client = null
|
|
22
|
+
#distributor = null
|
|
23
|
+
#formatter = null
|
|
24
|
+
#transformer = null
|
|
25
|
+
#logger = null
|
|
26
|
+
get botId () {
|
|
27
|
+
return this.#botId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get logger () {
|
|
31
|
+
return this.#logger
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
constructor (
|
|
35
|
+
botId,
|
|
36
|
+
botDataStorage,
|
|
37
|
+
objectStorage,
|
|
38
|
+
actorStorage,
|
|
39
|
+
client,
|
|
40
|
+
distributor,
|
|
41
|
+
formatter,
|
|
42
|
+
transformer,
|
|
43
|
+
logger
|
|
44
|
+
) {
|
|
45
|
+
this.#botId = botId
|
|
46
|
+
this.#botDataStorage = botDataStorage
|
|
47
|
+
this.#objectStorage = objectStorage
|
|
48
|
+
this.#actorStorage = actorStorage
|
|
49
|
+
this.#client = client
|
|
50
|
+
this.#distributor = distributor
|
|
51
|
+
this.#formatter = formatter
|
|
52
|
+
this.#transformer = transformer
|
|
53
|
+
this.#logger = logger.child({ class: 'BotContext', botId })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async setData (key, value) {
|
|
57
|
+
await this.#botDataStorage.set(this.#botId, key, value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getData (key) {
|
|
61
|
+
return await this.#botDataStorage.get(this.#botId, key)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async deleteData (key) {
|
|
65
|
+
await this.#botDataStorage.delete(this.#botId, key)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async hasData (key) {
|
|
69
|
+
return await this.#botDataStorage.has(this.#botId, key)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getObject (id) {
|
|
73
|
+
assert.ok(id)
|
|
74
|
+
assert.equal(typeof id, 'string')
|
|
75
|
+
if (this.#formatter.isLocal(id)) {
|
|
76
|
+
return await this.#objectStorage.read(id)
|
|
77
|
+
} else {
|
|
78
|
+
return await this.#client.get(id, this.#botId)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async sendNote (content, { to, cc, bto, bcc, audience, inReplyTo, thread, context, conversation }) {
|
|
83
|
+
assert.ok(content)
|
|
84
|
+
assert.equal(typeof content, 'string')
|
|
85
|
+
assert.ok(to || cc || bto || bcc || audience)
|
|
86
|
+
const { html, tag } = await this.#transformer.transform(content)
|
|
87
|
+
const noteNanoid = nanoid()
|
|
88
|
+
const idProps = {
|
|
89
|
+
username: this.#botId,
|
|
90
|
+
type: 'note',
|
|
91
|
+
nanoid: noteNanoid
|
|
92
|
+
}
|
|
93
|
+
if (!inReplyTo) {
|
|
94
|
+
if (!thread) {
|
|
95
|
+
thread = this.#formatter.format({ ...idProps, collection: 'thread' })
|
|
96
|
+
}
|
|
97
|
+
if (!context) {
|
|
98
|
+
context = thread
|
|
99
|
+
}
|
|
100
|
+
if (!conversation) {
|
|
101
|
+
conversation = thread
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const note = await as2.import({
|
|
105
|
+
'@context': [
|
|
106
|
+
'https://www.w3.org/ns/activitystreams',
|
|
107
|
+
'https://purl.archive.org/socialweb/thread/1.0',
|
|
108
|
+
{ ostatus: 'http://ostatus.org/schema/1.0/' }
|
|
109
|
+
],
|
|
110
|
+
type: 'Note',
|
|
111
|
+
content: html,
|
|
112
|
+
tag,
|
|
113
|
+
to,
|
|
114
|
+
cc,
|
|
115
|
+
bto,
|
|
116
|
+
bcc,
|
|
117
|
+
audience,
|
|
118
|
+
inReplyTo,
|
|
119
|
+
id: this.#formatter.format(idProps),
|
|
120
|
+
replies: this.#formatter.format({ ...idProps, collection: 'replies' }),
|
|
121
|
+
shares: this.#formatter.format({ ...idProps, collection: 'shares' }),
|
|
122
|
+
likes: this.#formatter.format({ ...idProps, collection: 'likes' }),
|
|
123
|
+
context,
|
|
124
|
+
thread,
|
|
125
|
+
'ostatus:conversation': conversation,
|
|
126
|
+
published: new Date().toISOString(),
|
|
127
|
+
attributedTo: this.#formatter.format({ username: this.#botId })
|
|
128
|
+
})
|
|
129
|
+
await this.#objectStorage.create(note)
|
|
130
|
+
const activity = await as2.import({
|
|
131
|
+
type: 'Create',
|
|
132
|
+
id: this.#formatter.format({
|
|
133
|
+
username: this.#botId,
|
|
134
|
+
type: 'create',
|
|
135
|
+
nanoid: nanoid()
|
|
136
|
+
}),
|
|
137
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
138
|
+
to,
|
|
139
|
+
cc,
|
|
140
|
+
bto,
|
|
141
|
+
bcc,
|
|
142
|
+
audience,
|
|
143
|
+
object: note
|
|
144
|
+
})
|
|
145
|
+
await this.#objectStorage.create(activity)
|
|
146
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
147
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
148
|
+
await this.#distributor.distribute(activity, this.#botId)
|
|
149
|
+
return note
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async sendReply (content, object) {
|
|
153
|
+
const r = this.#getRecipients(object)
|
|
154
|
+
const attributedTo = object.attributedTo?.first?.id
|
|
155
|
+
if (r.to) {
|
|
156
|
+
r.to.push(attributedTo)
|
|
157
|
+
} else {
|
|
158
|
+
r.to = [attributedTo]
|
|
159
|
+
}
|
|
160
|
+
const full = this.#formatter.format({ username: this.#botId })
|
|
161
|
+
for (const prop in ['to', 'cc', 'bto', 'bcc', 'audience']) {
|
|
162
|
+
if (r[prop]) {
|
|
163
|
+
r[prop] = r[prop].filter(id => id !== full)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const opt = { inReplyTo: object.id, ...r }
|
|
167
|
+
|
|
168
|
+
const threadProp = object.get(THREAD_PROP)
|
|
169
|
+
if (threadProp) {
|
|
170
|
+
opt.thread = Array.from(threadProp)[0].id
|
|
171
|
+
}
|
|
172
|
+
const contextProp = object.get('context')
|
|
173
|
+
if (contextProp) {
|
|
174
|
+
opt.context = Array.from(contextProp)[0].id
|
|
175
|
+
}
|
|
176
|
+
const conversationProp = object.get(CONVERSATION_PROP)
|
|
177
|
+
if (conversationProp) {
|
|
178
|
+
opt.conversation = Array.from(conversationProp)[0]
|
|
179
|
+
}
|
|
180
|
+
return await this.sendNote(content, opt)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async likeObject (obj) {
|
|
184
|
+
assert.ok(obj)
|
|
185
|
+
assert.equal(typeof obj, 'object')
|
|
186
|
+
if (await this.#actorStorage.isInCollection(this.#botId, 'liked', obj)) {
|
|
187
|
+
throw new Error(`already liked: ${obj.id} by ${this.#botId}`)
|
|
188
|
+
}
|
|
189
|
+
const owners = obj.attributedTo
|
|
190
|
+
? Array.from(obj.attributedTo).map((owner) => owner.id)
|
|
191
|
+
: Array.from(obj.actor).map((owner) => owner.id)
|
|
192
|
+
const activity = await as2.import({
|
|
193
|
+
type: 'Like',
|
|
194
|
+
id: this.#formatter.format({
|
|
195
|
+
username: this.#botId,
|
|
196
|
+
type: 'like',
|
|
197
|
+
nanoid: nanoid()
|
|
198
|
+
}),
|
|
199
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
200
|
+
object: obj.id,
|
|
201
|
+
to: owners,
|
|
202
|
+
cc: 'https://www.w3.org/ns/activitystreams#Public'
|
|
203
|
+
})
|
|
204
|
+
await this.#objectStorage.create(activity)
|
|
205
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
206
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
207
|
+
await this.#actorStorage.addToCollection(this.#botId, 'liked', obj)
|
|
208
|
+
await this.#distributor.distribute(activity, this.#botId)
|
|
209
|
+
return activity
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async unlikeObject (obj) {
|
|
213
|
+
assert.ok(obj)
|
|
214
|
+
assert.equal(typeof obj, 'object')
|
|
215
|
+
const owners = obj.attributedTo
|
|
216
|
+
? Array.from(obj.attributedTo).map((owner) => owner.id)
|
|
217
|
+
: Array.from(obj.actor).map((owner) => owner.id)
|
|
218
|
+
if (!(await this.#actorStorage.isInCollection(this.#botId, 'liked', obj))) {
|
|
219
|
+
throw new Error(`not already liked: ${obj.id} by ${this.#botId}`)
|
|
220
|
+
}
|
|
221
|
+
const likeActivity = this.#findInOutbox('Like', obj)
|
|
222
|
+
if (!likeActivity) {
|
|
223
|
+
throw new Error('no like activity')
|
|
224
|
+
}
|
|
225
|
+
const undoActivity = await as2.import({
|
|
226
|
+
type: 'Undo',
|
|
227
|
+
id: this.#formatter.format({
|
|
228
|
+
username: this.#botId,
|
|
229
|
+
type: 'undo',
|
|
230
|
+
nanoid: nanoid()
|
|
231
|
+
}),
|
|
232
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
233
|
+
object: likeActivity,
|
|
234
|
+
to: owners,
|
|
235
|
+
cc: 'https://www.w3.org/ns/activitystreams#Public'
|
|
236
|
+
})
|
|
237
|
+
await this.#objectStorage.create(undoActivity)
|
|
238
|
+
await this.#actorStorage.addToCollection(
|
|
239
|
+
this.#botId,
|
|
240
|
+
'outbox',
|
|
241
|
+
undoActivity
|
|
242
|
+
)
|
|
243
|
+
await this.#actorStorage.addToCollection(
|
|
244
|
+
this.#botId,
|
|
245
|
+
'inbox',
|
|
246
|
+
undoActivity
|
|
247
|
+
)
|
|
248
|
+
await this.#actorStorage.removeFromCollection(this.#botId, 'liked', obj)
|
|
249
|
+
await this.#distributor.distribute(undoActivity, this.#botId)
|
|
250
|
+
return undoActivity
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async followActor (actor) {
|
|
254
|
+
assert.ok(actor)
|
|
255
|
+
assert.equal(typeof actor, 'object')
|
|
256
|
+
const activity = await as2.import({
|
|
257
|
+
type: 'Follow',
|
|
258
|
+
id: this.#formatter.format({
|
|
259
|
+
username: this.#botId,
|
|
260
|
+
type: 'follow',
|
|
261
|
+
nanoid: nanoid()
|
|
262
|
+
}),
|
|
263
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
264
|
+
object: actor.id,
|
|
265
|
+
to: actor.id
|
|
266
|
+
})
|
|
267
|
+
await this.#objectStorage.create(activity)
|
|
268
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
269
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
270
|
+
await this.#actorStorage.addToCollection(
|
|
271
|
+
this.#botId,
|
|
272
|
+
'pendingFollowing',
|
|
273
|
+
actor
|
|
274
|
+
)
|
|
275
|
+
await this.#distributor.distribute(activity, this.#botId)
|
|
276
|
+
return activity
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async unfollowActor (actor) {
|
|
280
|
+
assert.ok(actor)
|
|
281
|
+
assert.equal(typeof actor, 'object')
|
|
282
|
+
const followActivity = this.#findInOutbox('Follow', actor)
|
|
283
|
+
if (!followActivity) {
|
|
284
|
+
throw new Error('no follow activity')
|
|
285
|
+
}
|
|
286
|
+
const undoActivity = await as2.import({
|
|
287
|
+
type: 'Undo',
|
|
288
|
+
id: this.#formatter.format({
|
|
289
|
+
username: this.#botId,
|
|
290
|
+
type: 'undo',
|
|
291
|
+
nanoid: nanoid()
|
|
292
|
+
}),
|
|
293
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
294
|
+
object: followActivity,
|
|
295
|
+
to: actor.id
|
|
296
|
+
})
|
|
297
|
+
await this.#objectStorage.create(undoActivity)
|
|
298
|
+
await this.#actorStorage.addToCollection(
|
|
299
|
+
this.#botId,
|
|
300
|
+
'outbox',
|
|
301
|
+
undoActivity
|
|
302
|
+
)
|
|
303
|
+
await this.#actorStorage.addToCollection(
|
|
304
|
+
this.#botId,
|
|
305
|
+
'inbox',
|
|
306
|
+
undoActivity
|
|
307
|
+
)
|
|
308
|
+
await this.#actorStorage.removeFromCollection(
|
|
309
|
+
this.#botId,
|
|
310
|
+
'pendingFollowing',
|
|
311
|
+
actor
|
|
312
|
+
)
|
|
313
|
+
await this.#actorStorage.removeFromCollection(
|
|
314
|
+
this.#botId,
|
|
315
|
+
'following',
|
|
316
|
+
actor
|
|
317
|
+
)
|
|
318
|
+
await this.#distributor.distribute(undoActivity, this.#botId)
|
|
319
|
+
return undoActivity
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async blockActor (actor) {
|
|
323
|
+
assert.ok(actor)
|
|
324
|
+
assert.equal(typeof actor, 'object')
|
|
325
|
+
const activity = await as2.import({
|
|
326
|
+
type: 'Block',
|
|
327
|
+
id: this.#formatter.format({
|
|
328
|
+
username: this.#botId,
|
|
329
|
+
type: 'block',
|
|
330
|
+
nanoid: nanoid()
|
|
331
|
+
}),
|
|
332
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
333
|
+
object: actor.id
|
|
334
|
+
})
|
|
335
|
+
await this.#objectStorage.create(activity)
|
|
336
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
337
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
338
|
+
await this.#actorStorage.addToCollection(this.#botId, 'blocked', actor)
|
|
339
|
+
for (const coll of [
|
|
340
|
+
'following',
|
|
341
|
+
'followers',
|
|
342
|
+
'pendingFollowing',
|
|
343
|
+
'pendingFollowers'
|
|
344
|
+
]) {
|
|
345
|
+
await this.#actorStorage.removeFromCollection(this.#botId, coll, actor)
|
|
346
|
+
}
|
|
347
|
+
// Do not distribute!
|
|
348
|
+
return activity
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async unblockActor (actor) {
|
|
352
|
+
assert.ok(actor)
|
|
353
|
+
assert.equal(typeof actor, 'object')
|
|
354
|
+
const blockActivity = this.#findInOutbox('Block', actor)
|
|
355
|
+
if (!blockActivity) {
|
|
356
|
+
throw new Error('no block activity')
|
|
357
|
+
}
|
|
358
|
+
const undoActivity = await as2.import({
|
|
359
|
+
type: 'Undo',
|
|
360
|
+
id: this.#formatter.format({
|
|
361
|
+
username: this.#botId,
|
|
362
|
+
type: 'undo',
|
|
363
|
+
nanoid: nanoid()
|
|
364
|
+
}),
|
|
365
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
366
|
+
object: blockActivity
|
|
367
|
+
})
|
|
368
|
+
await this.#objectStorage.create(undoActivity)
|
|
369
|
+
await this.#actorStorage.addToCollection(
|
|
370
|
+
this.#botId,
|
|
371
|
+
'outbox',
|
|
372
|
+
undoActivity
|
|
373
|
+
)
|
|
374
|
+
await this.#actorStorage.addToCollection(
|
|
375
|
+
this.#botId,
|
|
376
|
+
'inbox',
|
|
377
|
+
undoActivity
|
|
378
|
+
)
|
|
379
|
+
await this.#actorStorage.removeFromCollection(
|
|
380
|
+
this.#botId,
|
|
381
|
+
'blocked',
|
|
382
|
+
actor
|
|
383
|
+
)
|
|
384
|
+
// Do not distribute!
|
|
385
|
+
return undoActivity
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async updateNote (note, content) {
|
|
389
|
+
assert.ok(note)
|
|
390
|
+
assert.equal(typeof note, 'object')
|
|
391
|
+
assert.ok(content)
|
|
392
|
+
assert.equal(typeof content, 'string')
|
|
393
|
+
const exported = await note.export({ useOriginalContext: true })
|
|
394
|
+
exported.content = content
|
|
395
|
+
const updated = await as2.import(exported)
|
|
396
|
+
const { to, cc, bto, bcc, audience } = this.#getRecipients(note)
|
|
397
|
+
const activity = await as2.import({
|
|
398
|
+
type: 'Update',
|
|
399
|
+
id: this.#formatter.format({
|
|
400
|
+
username: this.#botId,
|
|
401
|
+
type: 'update',
|
|
402
|
+
nanoid: nanoid()
|
|
403
|
+
}),
|
|
404
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
405
|
+
object: updated,
|
|
406
|
+
to,
|
|
407
|
+
cc,
|
|
408
|
+
bto,
|
|
409
|
+
bcc,
|
|
410
|
+
audience
|
|
411
|
+
})
|
|
412
|
+
await this.#objectStorage.update(updated)
|
|
413
|
+
await this.#objectStorage.create(activity)
|
|
414
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
415
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
416
|
+
await this.#distributor.distribute(activity, this.#botId)
|
|
417
|
+
return updated
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async deleteNote (note) {
|
|
421
|
+
assert.ok(note)
|
|
422
|
+
assert.equal(typeof note, 'object')
|
|
423
|
+
const { to, cc, bto, bcc, audience } = this.#getRecipients(note)
|
|
424
|
+
const tombstone = await as2.import({
|
|
425
|
+
type: 'Tombstone',
|
|
426
|
+
id: note.id,
|
|
427
|
+
attributedTo: this.#formatter.format({ username: this.#botId }),
|
|
428
|
+
formerType: 'Note',
|
|
429
|
+
deleted: new Date().toISOString(),
|
|
430
|
+
to,
|
|
431
|
+
cc,
|
|
432
|
+
bto,
|
|
433
|
+
bcc,
|
|
434
|
+
audience
|
|
435
|
+
})
|
|
436
|
+
const activity = await as2.import({
|
|
437
|
+
type: 'Delete',
|
|
438
|
+
id: this.#formatter.format({
|
|
439
|
+
username: this.#botId,
|
|
440
|
+
type: 'delete',
|
|
441
|
+
nanoid: nanoid()
|
|
442
|
+
}),
|
|
443
|
+
actor: this.#formatter.format({ username: this.#botId }),
|
|
444
|
+
object: tombstone,
|
|
445
|
+
to,
|
|
446
|
+
cc,
|
|
447
|
+
bto,
|
|
448
|
+
bcc,
|
|
449
|
+
audience
|
|
450
|
+
})
|
|
451
|
+
await this.#objectStorage.update(tombstone)
|
|
452
|
+
await this.#objectStorage.create(activity)
|
|
453
|
+
await this.#actorStorage.addToCollection(this.#botId, 'outbox', activity)
|
|
454
|
+
await this.#actorStorage.addToCollection(this.#botId, 'inbox', activity)
|
|
455
|
+
await this.#distributor.distribute(activity, this.#botId)
|
|
456
|
+
return activity
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async toActorId (webfinger) {
|
|
460
|
+
const [, domain] = webfinger.split('@')
|
|
461
|
+
const url = `https://${domain}/.well-known/webfinger` +
|
|
462
|
+
`?resource=acct:${webfinger}`
|
|
463
|
+
const res = await fetch(url)
|
|
464
|
+
if (res.status !== 200) {
|
|
465
|
+
throw new Error(`Status ${res.status} fetching ${url}`)
|
|
466
|
+
}
|
|
467
|
+
const json = await res.json()
|
|
468
|
+
const link = json?.links?.find(
|
|
469
|
+
(l) => l.rel === 'self' && AS2_TYPES.includes(l.type)
|
|
470
|
+
)
|
|
471
|
+
return link ? link.href : null
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async toWebfinger (actorId) {
|
|
475
|
+
const actor = await this.#client.get(actorId)
|
|
476
|
+
if (!actor) {
|
|
477
|
+
return null
|
|
478
|
+
}
|
|
479
|
+
const wf = actor.get(WF_NS + 'webfinger')
|
|
480
|
+
if (wf) {
|
|
481
|
+
return wf.value()
|
|
482
|
+
}
|
|
483
|
+
const username = actor.get('preferredUsername')?.first
|
|
484
|
+
if (!username) {
|
|
485
|
+
return null
|
|
486
|
+
}
|
|
487
|
+
const actorUrl = new URL(actor.id)
|
|
488
|
+
return `${username}@${actorUrl.hostname}`
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async #findInOutbox (type, obj) {
|
|
492
|
+
const full = `https://www.w3.org/ns/activitystreams#${type}`
|
|
493
|
+
let found = null
|
|
494
|
+
for await (const activity of this.#actorStorage.items(
|
|
495
|
+
this.#botId,
|
|
496
|
+
'outbox'
|
|
497
|
+
)) {
|
|
498
|
+
if (activity.type === full && activity.object.id === obj.id) {
|
|
499
|
+
found = activity
|
|
500
|
+
break
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return found
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#getRecipients (obj) {
|
|
507
|
+
const to = obj.to ? Array.from(obj.to).map((to) => to.id) : null
|
|
508
|
+
const cc = obj.cc ? Array.from(obj.cc).map((cc) => cc.id) : null
|
|
509
|
+
const bto = obj.bto ? Array.from(obj.bto).map((bto) => bto.id) : null
|
|
510
|
+
const bcc = obj.bcc ? Array.from(obj.bcc).map((bcc) => bcc.id) : null
|
|
511
|
+
const audience = obj.audience
|
|
512
|
+
? Array.from(obj.audience).map((audience) => audience.id)
|
|
513
|
+
: null
|
|
514
|
+
return { to, cc, bto, bcc, audience }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async onIdle () {
|
|
518
|
+
await this.#distributor.onIdle()
|
|
519
|
+
}
|
|
520
|
+
}
|