@zooid/transport-matrix 0.7.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/LICENSE +21 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +1076 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bot-pool.test.ts +307 -0
- package/src/bot-pool.ts +112 -0
- package/src/context-provider.test.ts +317 -0
- package/src/context-provider.ts +187 -0
- package/src/event-encoders.test.ts +124 -0
- package/src/event-encoders.ts +66 -0
- package/src/index.ts +26 -0
- package/src/markdown-to-matrix-html.test.ts +102 -0
- package/src/markdown-to-matrix-html.ts +41 -0
- package/src/matrix-client.test.ts +307 -0
- package/src/matrix-client.ts +361 -0
- package/src/mentions.test.ts +90 -0
- package/src/mentions.ts +38 -0
- package/src/registration.test.ts +41 -0
- package/src/registration.ts +44 -0
- package/src/router.test.ts +90 -0
- package/src/router.ts +72 -0
- package/src/space-provisioner.test.ts +89 -0
- package/src/space-provisioner.ts +34 -0
- package/src/transport.test.ts +1164 -0
- package/src/transport.ts +521 -0
- package/src/workforce-publisher.test.ts +76 -0
- package/src/workforce-publisher.ts +53 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export interface MatrixClientOptions {
|
|
4
|
+
homeserver: string
|
|
5
|
+
asToken: string
|
|
6
|
+
fetch?: typeof globalThis.fetch
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SendMessageInput {
|
|
10
|
+
roomId: string
|
|
11
|
+
asUserId: string
|
|
12
|
+
content: { msgtype: string; body: string; [k: string]: unknown }
|
|
13
|
+
threadRoot?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SendCustomEventInput {
|
|
17
|
+
roomId: string
|
|
18
|
+
asUserId: string
|
|
19
|
+
eventType: string
|
|
20
|
+
content: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SetTypingInput {
|
|
24
|
+
roomId: string
|
|
25
|
+
asUserId: string
|
|
26
|
+
typing: boolean
|
|
27
|
+
/** ms — homeserver expects re-PUTs before this expires. Ignored when typing=false. */
|
|
28
|
+
timeoutMs?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SetPresenceInput {
|
|
32
|
+
asUserId: string
|
|
33
|
+
presence: 'online' | 'unavailable' | 'offline'
|
|
34
|
+
statusMsg?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class MatrixClient {
|
|
38
|
+
private readonly homeserver: string
|
|
39
|
+
private readonly asToken: string
|
|
40
|
+
private readonly fetch: typeof globalThis.fetch
|
|
41
|
+
|
|
42
|
+
constructor(opts: MatrixClientOptions) {
|
|
43
|
+
this.homeserver = opts.homeserver.replace(/\/$/, '')
|
|
44
|
+
this.asToken = opts.asToken
|
|
45
|
+
this.fetch = opts.fetch ?? globalThis.fetch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async registerBot(
|
|
49
|
+
localpart: string,
|
|
50
|
+
): Promise<{ user_id: string; device_id: string } | undefined> {
|
|
51
|
+
const r = await this.fetch(`${this.homeserver}/_matrix/client/v3/register`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
54
|
+
body: JSON.stringify({ type: 'm.login.application_service', username: localpart }),
|
|
55
|
+
})
|
|
56
|
+
if (r.status === 200) return (await r.json()) as { user_id: string; device_id: string }
|
|
57
|
+
if (r.status === 400) {
|
|
58
|
+
const body = (await r.json()) as { errcode?: string }
|
|
59
|
+
if (body.errcode === 'M_USER_IN_USE') return undefined
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`registerBot(${localpart}) failed: ${r.status}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async resolveAlias(alias: string): Promise<string | null> {
|
|
65
|
+
const r = await this.fetch(
|
|
66
|
+
`${this.homeserver}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
|
67
|
+
{ headers: { Authorization: `Bearer ${this.asToken}` } },
|
|
68
|
+
)
|
|
69
|
+
if (r.status === 404) return null
|
|
70
|
+
if (!r.ok) throw new Error(`resolveAlias(${alias}) failed: ${r.status}`)
|
|
71
|
+
const j = (await r.json()) as { room_id: string }
|
|
72
|
+
return j.room_id
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async createRoom(opts: {
|
|
76
|
+
roomAliasName: string
|
|
77
|
+
invite: string[]
|
|
78
|
+
senderUserId: string
|
|
79
|
+
preset?: 'public_chat' | 'private_chat' | 'trusted_private_chat'
|
|
80
|
+
/** Optional `m.room.name`. When set, sent in the createRoom body so the
|
|
81
|
+
* room has a display name from the moment it exists. */
|
|
82
|
+
name?: string
|
|
83
|
+
}): Promise<string> {
|
|
84
|
+
const body: Record<string, unknown> = {
|
|
85
|
+
room_alias_name: opts.roomAliasName,
|
|
86
|
+
invite: opts.invite,
|
|
87
|
+
preset: opts.preset ?? 'public_chat',
|
|
88
|
+
}
|
|
89
|
+
if (opts.name !== undefined) body.name = opts.name
|
|
90
|
+
const r = await this.fetch(
|
|
91
|
+
`${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.senderUserId)}`,
|
|
92
|
+
{
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
96
|
+
'content-type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
},
|
|
100
|
+
)
|
|
101
|
+
if (!r.ok) {
|
|
102
|
+
const body = await r.text()
|
|
103
|
+
throw new Error(`createRoom(${opts.roomAliasName}) failed: ${r.status} ${body}`)
|
|
104
|
+
}
|
|
105
|
+
const j = (await r.json()) as { room_id: string }
|
|
106
|
+
return j.room_id
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async createRoomRaw(opts: {
|
|
110
|
+
asUserId: string
|
|
111
|
+
body: Record<string, unknown>
|
|
112
|
+
}): Promise<string> {
|
|
113
|
+
const url = `${this.homeserver}/_matrix/client/v3/createRoom?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
114
|
+
const r = await this.fetch(url, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: {
|
|
117
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
118
|
+
'content-type': 'application/json',
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(opts.body),
|
|
121
|
+
})
|
|
122
|
+
if (!r.ok) throw new Error(`createRoomRaw failed: ${r.status}`)
|
|
123
|
+
const j = (await r.json()) as { room_id: string }
|
|
124
|
+
return j.room_id
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async sendStateEvent(opts: {
|
|
128
|
+
roomId: string
|
|
129
|
+
asUserId: string
|
|
130
|
+
eventType: string
|
|
131
|
+
stateKey?: string
|
|
132
|
+
content: Record<string, unknown>
|
|
133
|
+
}): Promise<{ event_id: string }> {
|
|
134
|
+
const url =
|
|
135
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}` +
|
|
136
|
+
`/state/${encodeURIComponent(opts.eventType)}/${encodeURIComponent(opts.stateKey ?? '')}` +
|
|
137
|
+
`?user_id=${encodeURIComponent(opts.asUserId)}`
|
|
138
|
+
const r = await this.fetch(url, {
|
|
139
|
+
method: 'PUT',
|
|
140
|
+
headers: {
|
|
141
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
142
|
+
'content-type': 'application/json',
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify(opts.content),
|
|
145
|
+
})
|
|
146
|
+
if (!r.ok) throw new Error(`sendStateEvent ${opts.eventType} failed: ${r.status}`)
|
|
147
|
+
return (await r.json()) as { event_id: string }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
|
|
151
|
+
const url =
|
|
152
|
+
`${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
|
|
153
|
+
`?user_id=${encodeURIComponent(asUserId)}`
|
|
154
|
+
const r = await this.fetch(url, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
157
|
+
body: '{}',
|
|
158
|
+
})
|
|
159
|
+
if (!r.ok) throw new Error(`joinRoom(${roomIdOrAlias}) failed: ${r.status}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async sendMessage(input: SendMessageInput): Promise<{ event_id: string }> {
|
|
163
|
+
const content: Record<string, unknown> = { ...input.content }
|
|
164
|
+
if (input.threadRoot) {
|
|
165
|
+
content['m.relates_to'] = { rel_type: 'm.thread', event_id: input.threadRoot }
|
|
166
|
+
}
|
|
167
|
+
return this.sendEvent(input.roomId, input.asUserId, 'm.room.message', content)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async sendCustomEvent(input: SendCustomEventInput): Promise<{ event_id: string }> {
|
|
171
|
+
return this.sendEvent(input.roomId, input.asUserId, input.eventType, input.content)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async setTyping(input: SetTypingInput): Promise<void> {
|
|
175
|
+
const url =
|
|
176
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(input.roomId)}` +
|
|
177
|
+
`/typing/${encodeURIComponent(input.asUserId)}` +
|
|
178
|
+
`?user_id=${encodeURIComponent(input.asUserId)}`
|
|
179
|
+
const body: Record<string, unknown> = { typing: input.typing }
|
|
180
|
+
if (input.typing && input.timeoutMs !== undefined) body.timeout = input.timeoutMs
|
|
181
|
+
const r = await this.fetch(url, {
|
|
182
|
+
method: 'PUT',
|
|
183
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
})
|
|
186
|
+
if (!r.ok) throw new Error(`setTyping(${input.roomId}, ${input.asUserId}) failed: ${r.status}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async setDisplayName(asUserId: string, displayName: string): Promise<void> {
|
|
190
|
+
const url =
|
|
191
|
+
`${this.homeserver}/_matrix/client/v3/profile/${encodeURIComponent(asUserId)}/displayname` +
|
|
192
|
+
`?user_id=${encodeURIComponent(asUserId)}`
|
|
193
|
+
const r = await this.fetch(url, {
|
|
194
|
+
method: 'PUT',
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: `Bearer ${this.asToken}`,
|
|
197
|
+
'content-type': 'application/json',
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify({ displayname: displayName }),
|
|
200
|
+
})
|
|
201
|
+
if (!r.ok) throw new Error(`setDisplayName(${asUserId}) failed: ${r.status}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async setPresence(input: SetPresenceInput): Promise<void> {
|
|
205
|
+
const url =
|
|
206
|
+
`${this.homeserver}/_matrix/client/v3/presence/${encodeURIComponent(input.asUserId)}/status` +
|
|
207
|
+
`?user_id=${encodeURIComponent(input.asUserId)}`
|
|
208
|
+
const body: Record<string, unknown> = { presence: input.presence }
|
|
209
|
+
if (input.statusMsg !== undefined) body.status_msg = input.statusMsg
|
|
210
|
+
const r = await this.fetch(url, {
|
|
211
|
+
method: 'PUT',
|
|
212
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
})
|
|
215
|
+
if (!r.ok) throw new Error(`setPresence(${input.asUserId}) failed: ${r.status}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Fetch a single event from a room. Used to recover thread-root context
|
|
220
|
+
* after a daemon restart wipes the in-memory threadStates cache.
|
|
221
|
+
*/
|
|
222
|
+
async fetchEvent(
|
|
223
|
+
roomId: string,
|
|
224
|
+
eventId: string,
|
|
225
|
+
asUserId: string,
|
|
226
|
+
): Promise<Record<string, unknown> | null> {
|
|
227
|
+
const url =
|
|
228
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
229
|
+
`/event/${encodeURIComponent(eventId)}` +
|
|
230
|
+
`?user_id=${encodeURIComponent(asUserId)}`
|
|
231
|
+
const r = await this.fetch(url, {
|
|
232
|
+
method: 'GET',
|
|
233
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
234
|
+
})
|
|
235
|
+
if (r.status === 404) return null
|
|
236
|
+
if (!r.ok) throw new Error(`fetchEvent(${eventId}) failed: ${r.status}`)
|
|
237
|
+
return (await r.json()) as Record<string, unknown>
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Fetch replies to a thread root via the relations endpoint, oldest-first.
|
|
242
|
+
* Pass `limit` and `from` for pagination; `next_batch` echoes back when
|
|
243
|
+
* there are more replies. Returns `{ chunk: [] }` when the root is unknown.
|
|
244
|
+
*/
|
|
245
|
+
async fetchThreadRelations(opts: {
|
|
246
|
+
roomId: string
|
|
247
|
+
rootEventId: string
|
|
248
|
+
asUserId: string
|
|
249
|
+
limit?: number
|
|
250
|
+
from?: string
|
|
251
|
+
}): Promise<{ chunk: Array<Record<string, unknown>>; next_batch?: string }> {
|
|
252
|
+
const params = new URLSearchParams({
|
|
253
|
+
dir: 'f',
|
|
254
|
+
limit: String(opts.limit ?? 100),
|
|
255
|
+
user_id: opts.asUserId,
|
|
256
|
+
})
|
|
257
|
+
if (opts.from) params.set('from', opts.from)
|
|
258
|
+
const url =
|
|
259
|
+
`${this.homeserver}/_matrix/client/v1/rooms/${encodeURIComponent(opts.roomId)}` +
|
|
260
|
+
`/relations/${encodeURIComponent(opts.rootEventId)}/m.thread?${params.toString()}`
|
|
261
|
+
const r = await this.fetch(url, {
|
|
262
|
+
method: 'GET',
|
|
263
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
264
|
+
})
|
|
265
|
+
if (r.status === 404) return { chunk: [] }
|
|
266
|
+
if (!r.ok) throw new Error(`fetchThreadRelations(${opts.rootEventId}) failed: ${r.status}`)
|
|
267
|
+
const body = (await r.json()) as {
|
|
268
|
+
chunk?: Array<Record<string, unknown>>
|
|
269
|
+
next_batch?: string
|
|
270
|
+
}
|
|
271
|
+
return { chunk: body.chunk ?? [], next_batch: body.next_batch }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Paginate the room timeline. Returns events newest-first (dir=b) per Matrix
|
|
276
|
+
* spec. The caller is responsible for reversing if it wants oldest-first.
|
|
277
|
+
*/
|
|
278
|
+
async fetchRoomMessages(opts: {
|
|
279
|
+
roomId: string
|
|
280
|
+
asUserId: string
|
|
281
|
+
limit?: number
|
|
282
|
+
/** Opaque pagination token returned in `end` from a previous call. */
|
|
283
|
+
from?: string
|
|
284
|
+
/**
|
|
285
|
+
* Server-side RoomEventFilter. Common keys: `types` (whitelist event types),
|
|
286
|
+
* `not_types`, `not_rel_types` (e.g. `['m.thread']` to exclude thread
|
|
287
|
+
* replies). Encoded as JSON into the `filter` query param.
|
|
288
|
+
*/
|
|
289
|
+
filter?: {
|
|
290
|
+
types?: string[]
|
|
291
|
+
not_types?: string[]
|
|
292
|
+
rel_types?: string[]
|
|
293
|
+
not_rel_types?: string[]
|
|
294
|
+
}
|
|
295
|
+
}): Promise<{ chunk: Array<Record<string, unknown>>; end?: string }> {
|
|
296
|
+
const params = new URLSearchParams({
|
|
297
|
+
dir: 'b',
|
|
298
|
+
limit: String(opts.limit ?? 50),
|
|
299
|
+
user_id: opts.asUserId,
|
|
300
|
+
})
|
|
301
|
+
if (opts.from) params.set('from', opts.from)
|
|
302
|
+
if (opts.filter) params.set('filter', JSON.stringify(opts.filter))
|
|
303
|
+
const url =
|
|
304
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}` +
|
|
305
|
+
`/messages?${params.toString()}`
|
|
306
|
+
const r = await this.fetch(url, {
|
|
307
|
+
method: 'GET',
|
|
308
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
309
|
+
})
|
|
310
|
+
if (!r.ok) throw new Error(`fetchRoomMessages(${opts.roomId}) failed: ${r.status}`)
|
|
311
|
+
return (await r.json()) as { chunk: Array<Record<string, unknown>>; end?: string }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async getJoinedMembers(
|
|
315
|
+
roomId: string,
|
|
316
|
+
asUserId: string,
|
|
317
|
+
): Promise<{ joined: Record<string, { display_name?: string }> }> {
|
|
318
|
+
const url =
|
|
319
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
320
|
+
`/joined_members?user_id=${encodeURIComponent(asUserId)}`
|
|
321
|
+
const r = await this.fetch(url, {
|
|
322
|
+
method: 'GET',
|
|
323
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
324
|
+
})
|
|
325
|
+
if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`)
|
|
326
|
+
return (await r.json()) as { joined: Record<string, { display_name?: string }> }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
|
|
330
|
+
const url =
|
|
331
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
332
|
+
`/state/m.room.name/?user_id=${encodeURIComponent(asUserId)}`
|
|
333
|
+
const r = await this.fetch(url, {
|
|
334
|
+
method: 'GET',
|
|
335
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
336
|
+
})
|
|
337
|
+
if (r.status === 404) return null
|
|
338
|
+
if (!r.ok) throw new Error(`fetchRoomName(${roomId}) failed: ${r.status}`)
|
|
339
|
+
const body = (await r.json()) as { name?: string }
|
|
340
|
+
return body.name ?? null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private async sendEvent(
|
|
344
|
+
roomId: string,
|
|
345
|
+
asUserId: string,
|
|
346
|
+
eventType: string,
|
|
347
|
+
content: Record<string, unknown>,
|
|
348
|
+
): Promise<{ event_id: string }> {
|
|
349
|
+
const txn = randomUUID()
|
|
350
|
+
const url =
|
|
351
|
+
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
|
352
|
+
`/send/${eventType}/${txn}?user_id=${encodeURIComponent(asUserId)}`
|
|
353
|
+
const r = await this.fetch(url, {
|
|
354
|
+
method: 'PUT',
|
|
355
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
356
|
+
body: JSON.stringify(content),
|
|
357
|
+
})
|
|
358
|
+
if (!r.ok) throw new Error(`sendEvent(${eventType}) failed: ${r.status}`)
|
|
359
|
+
return (await r.json()) as { event_id: string }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { extractMentions, stripMention } from './mentions.js'
|
|
3
|
+
|
|
4
|
+
describe('extractMentions', () => {
|
|
5
|
+
it('reads m.mentions.user_ids when present', () => {
|
|
6
|
+
const event = {
|
|
7
|
+
type: 'm.room.message',
|
|
8
|
+
content: {
|
|
9
|
+
msgtype: 'm.text',
|
|
10
|
+
body: 'Hi @architect',
|
|
11
|
+
'm.mentions': { user_ids: ['@architect:example.com'] },
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
expect(extractMentions(event)).toEqual(['@architect:example.com'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to parsing formatted_body anchors', () => {
|
|
18
|
+
const event = {
|
|
19
|
+
type: 'm.room.message',
|
|
20
|
+
content: {
|
|
21
|
+
msgtype: 'm.text',
|
|
22
|
+
body: 'Hi architect',
|
|
23
|
+
format: 'org.matrix.custom.html',
|
|
24
|
+
formatted_body:
|
|
25
|
+
'Hi <a href="https://matrix.to/#/@architect:example.com">architect</a>',
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
expect(extractMentions(event)).toEqual(['@architect:example.com'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('falls back to scanning body for raw user IDs', () => {
|
|
32
|
+
const event = {
|
|
33
|
+
type: 'm.room.message',
|
|
34
|
+
content: { msgtype: 'm.text', body: 'cc @qa:example.com please' },
|
|
35
|
+
}
|
|
36
|
+
expect(extractMentions(event)).toEqual(['@qa:example.com'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns empty when no mention is present', () => {
|
|
40
|
+
const event = {
|
|
41
|
+
type: 'm.room.message',
|
|
42
|
+
content: { msgtype: 'm.text', body: 'just chatting' },
|
|
43
|
+
}
|
|
44
|
+
expect(extractMentions(event)).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('deduplicates IDs that appear in both m.mentions and the body', () => {
|
|
48
|
+
const event = {
|
|
49
|
+
type: 'm.room.message',
|
|
50
|
+
content: {
|
|
51
|
+
msgtype: 'm.text',
|
|
52
|
+
body: 'cc @qa:example.com',
|
|
53
|
+
'm.mentions': { user_ids: ['@qa:example.com'] },
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
expect(extractMentions(event)).toEqual(['@qa:example.com'])
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('stripMention', () => {
|
|
61
|
+
it('removes the bot user ID and trims surrounding whitespace', () => {
|
|
62
|
+
expect(stripMention('@docs:localhost just say hi', '@docs:localhost')).toBe('just say hi')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('removes a trailing mention', () => {
|
|
66
|
+
expect(stripMention('hey @docs:localhost', '@docs:localhost')).toBe('hey')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('removes a mid-sentence mention without doubling spaces', () => {
|
|
70
|
+
expect(stripMention('hey @docs:localhost can you help', '@docs:localhost')).toBe(
|
|
71
|
+
'hey can you help',
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('leaves other users’ mentions untouched', () => {
|
|
76
|
+
expect(stripMention('@docs:localhost cc @qa:example.com', '@docs:localhost')).toBe(
|
|
77
|
+
'cc @qa:example.com',
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('removes every occurrence of the bot mention', () => {
|
|
82
|
+
expect(stripMention('@docs:localhost ping @docs:localhost again', '@docs:localhost')).toBe(
|
|
83
|
+
'ping again',
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns empty string when the body is just the mention', () => {
|
|
88
|
+
expect(stripMention('@docs:localhost', '@docs:localhost')).toBe('')
|
|
89
|
+
})
|
|
90
|
+
})
|
package/src/mentions.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface MaybeMessage {
|
|
2
|
+
content?: {
|
|
3
|
+
'm.mentions'?: { user_ids?: string[] }
|
|
4
|
+
body?: string
|
|
5
|
+
formatted_body?: string
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MATRIX_TO_RE = /https:\/\/matrix\.to\/#\/(@[^"<>\s]+)/g
|
|
10
|
+
const RAW_USER_RE = /(@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+)/g
|
|
11
|
+
|
|
12
|
+
export function extractMentions(event: MaybeMessage): string[] {
|
|
13
|
+
const out = new Set<string>()
|
|
14
|
+
const c = event.content ?? {}
|
|
15
|
+
for (const id of c['m.mentions']?.user_ids ?? []) out.add(id)
|
|
16
|
+
if (c.formatted_body) {
|
|
17
|
+
for (const m of c.formatted_body.matchAll(MATRIX_TO_RE)) {
|
|
18
|
+
out.add(decodeURIComponent(m[1]))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (c.body && out.size === 0) {
|
|
22
|
+
for (const m of c.body.matchAll(RAW_USER_RE)) out.add(m[1])
|
|
23
|
+
}
|
|
24
|
+
return [...out]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remove a single user-id mention from a message body. Strips the raw
|
|
29
|
+
* `@local:server` form and collapses any whitespace that the strip leaves
|
|
30
|
+
* behind. Other users' mentions are preserved verbatim so the agent can
|
|
31
|
+
* reason about them.
|
|
32
|
+
*/
|
|
33
|
+
export function stripMention(body: string, userId: string): string {
|
|
34
|
+
const escaped = userId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
35
|
+
// Match optional surrounding whitespace so we don't leave double spaces.
|
|
36
|
+
const re = new RegExp(`\\s*${escaped}\\s*`, 'g')
|
|
37
|
+
return body.replace(re, ' ').replace(/\s+/g, ' ').trim()
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderRegistration, type MatrixTransportConfig } from './registration.js'
|
|
3
|
+
|
|
4
|
+
const baseConfig: MatrixTransportConfig = {
|
|
5
|
+
id: 'zooid',
|
|
6
|
+
url: 'http://daemon:8080',
|
|
7
|
+
homeserver: 'https://matrix.example.com',
|
|
8
|
+
asToken: 'as-secret',
|
|
9
|
+
hsToken: 'hs-secret',
|
|
10
|
+
senderLocalpart: 'zooid',
|
|
11
|
+
userNamespace: '@.*:example.com',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('renderRegistration', () => {
|
|
15
|
+
it('emits the six required AS fields', () => {
|
|
16
|
+
const yaml = renderRegistration(baseConfig)
|
|
17
|
+
expect(yaml).toContain('id: zooid')
|
|
18
|
+
expect(yaml).toContain('url: http://daemon:8080')
|
|
19
|
+
expect(yaml).toContain('as_token: as-secret')
|
|
20
|
+
expect(yaml).toContain('hs_token: hs-secret')
|
|
21
|
+
expect(yaml).toContain('sender_localpart: zooid')
|
|
22
|
+
expect(yaml).toMatch(/namespaces:\s/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('marks the user namespace exclusive', () => {
|
|
26
|
+
const yaml = renderRegistration(baseConfig)
|
|
27
|
+
expect(yaml).toMatch(/users:\s*\n\s*-\s*exclusive:\s*true/)
|
|
28
|
+
expect(yaml).toContain("regex: '@.*:example.com'")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('emits empty aliases and rooms namespaces', () => {
|
|
32
|
+
const yaml = renderRegistration(baseConfig)
|
|
33
|
+
expect(yaml).toMatch(/aliases:\s*\[\]/)
|
|
34
|
+
expect(yaml).toMatch(/rooms:\s*\[\]/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('disables rate limiting (AS calls bypass HS rate limits)', () => {
|
|
38
|
+
const yaml = renderRegistration(baseConfig)
|
|
39
|
+
expect(yaml).toContain('rate_limited: false')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { stringify } from 'yaml'
|
|
2
|
+
|
|
3
|
+
export interface MatrixTransportConfig {
|
|
4
|
+
id: string
|
|
5
|
+
url: string
|
|
6
|
+
homeserver: string
|
|
7
|
+
asToken: string
|
|
8
|
+
hsToken: string
|
|
9
|
+
senderLocalpart: string
|
|
10
|
+
/** Regex covering all bot users, e.g. `@.*:example.com` */
|
|
11
|
+
userNamespace: string
|
|
12
|
+
/**
|
|
13
|
+
* Optional regex covering aliases the AS may claim, e.g. `#.*:example.com`.
|
|
14
|
+
* Required when the AS calls `createRoom` with a `room_alias_name`.
|
|
15
|
+
*/
|
|
16
|
+
aliasNamespace?: string
|
|
17
|
+
/**
|
|
18
|
+
* Whether the AS exclusively owns the user_namespace. Default true.
|
|
19
|
+
* Set false when humans share the namespace (e.g., `zooid dev` registers
|
|
20
|
+
* a predefined admin under the same `@.*:localhost` regex).
|
|
21
|
+
*/
|
|
22
|
+
exclusive?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderRegistration(c: MatrixTransportConfig): string {
|
|
26
|
+
return stringify(
|
|
27
|
+
{
|
|
28
|
+
id: c.id,
|
|
29
|
+
url: c.url,
|
|
30
|
+
as_token: c.asToken,
|
|
31
|
+
hs_token: c.hsToken,
|
|
32
|
+
sender_localpart: c.senderLocalpart,
|
|
33
|
+
rate_limited: false,
|
|
34
|
+
namespaces: {
|
|
35
|
+
users: [{ exclusive: c.exclusive ?? true, regex: c.userNamespace }],
|
|
36
|
+
aliases: c.aliasNamespace
|
|
37
|
+
? [{ exclusive: c.exclusive ?? true, regex: c.aliasNamespace }]
|
|
38
|
+
: [],
|
|
39
|
+
rooms: [],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{ defaultStringType: 'PLAIN', defaultKeyType: 'PLAIN', singleQuote: true },
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { route, type AgentBinding } from './router.js'
|
|
3
|
+
|
|
4
|
+
const agents: AgentBinding[] = [
|
|
5
|
+
{
|
|
6
|
+
name: 'architect',
|
|
7
|
+
userId: '@architect:example.com',
|
|
8
|
+
rooms: ['!room1:example.com'],
|
|
9
|
+
trigger: 'mention',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: 'monitor',
|
|
13
|
+
userId: '@monitor:example.com',
|
|
14
|
+
rooms: ['!alerts:example.com'],
|
|
15
|
+
trigger: 'any',
|
|
16
|
+
},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function msg(
|
|
20
|
+
overrides: Partial<{ room: string; sender: string; body: string; mentions: string[] }> = {},
|
|
21
|
+
) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'm.room.message',
|
|
24
|
+
room_id: overrides.room ?? '!room1:example.com',
|
|
25
|
+
sender: overrides.sender ?? '@alice:example.com',
|
|
26
|
+
event_id: '$evt',
|
|
27
|
+
content: {
|
|
28
|
+
msgtype: 'm.text',
|
|
29
|
+
body: overrides.body ?? 'hello',
|
|
30
|
+
...(overrides.mentions ? { 'm.mentions': { user_ids: overrides.mentions } } : {}),
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('route', () => {
|
|
36
|
+
it('matches mention-triggered agents only when their user id is mentioned', () => {
|
|
37
|
+
const matches = route(msg({ mentions: ['@architect:example.com'] }), agents)
|
|
38
|
+
expect(matches.map((m) => m.name)).toEqual(['architect'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not match mention-triggered agents on a plain message', () => {
|
|
42
|
+
const matches = route(msg(), agents)
|
|
43
|
+
expect(matches).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('matches `any`-triggered agents on every message in their rooms', () => {
|
|
47
|
+
const matches = route(msg({ room: '!alerts:example.com' }), agents)
|
|
48
|
+
expect(matches.map((m) => m.name)).toEqual(['monitor'])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not match an `any` agent in a room it does not belong to', () => {
|
|
52
|
+
const matches = route(msg({ room: '!room1:example.com' }), agents)
|
|
53
|
+
expect(matches.map((m) => m.name)).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('skips events whose sender is the matched agent itself', () => {
|
|
57
|
+
const matches = route(
|
|
58
|
+
msg({ sender: '@architect:example.com', mentions: ['@architect:example.com'] }),
|
|
59
|
+
agents,
|
|
60
|
+
)
|
|
61
|
+
expect(matches).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns multiple bindings when multiple agents are mentioned in the same event', () => {
|
|
65
|
+
const both: AgentBinding[] = [
|
|
66
|
+
...agents,
|
|
67
|
+
{
|
|
68
|
+
name: 'qa',
|
|
69
|
+
userId: '@qa:example.com',
|
|
70
|
+
rooms: ['!room1:example.com'],
|
|
71
|
+
trigger: 'mention',
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
const matches = route(
|
|
75
|
+
msg({ mentions: ['@architect:example.com', '@qa:example.com'] }),
|
|
76
|
+
both,
|
|
77
|
+
)
|
|
78
|
+
expect(matches.map((m) => m.name).sort()).toEqual(['architect', 'qa'])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('ignores non-m.room.message events', () => {
|
|
82
|
+
const stateEvent = {
|
|
83
|
+
type: 'm.room.member',
|
|
84
|
+
room_id: '!room1:example.com',
|
|
85
|
+
sender: '@alice:example.com',
|
|
86
|
+
content: {},
|
|
87
|
+
}
|
|
88
|
+
expect(route(stateEvent as never, agents)).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
})
|