@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.
@@ -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
+ })
@@ -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
+ })