@zooid/transport-matrix 0.7.4 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/transport-matrix",
3
- "version": "0.7.4",
3
+ "version": "0.9.0",
4
4
  "description": "Matrix Application Service transport for zooid. Routes inbound Matrix messages to ACP agents and posts replies plus approval custom events back to threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "marked": "^18.0.4",
29
29
  "sanitize-html": "^2.17.4",
30
30
  "yaml": "^2.5.0",
31
- "@zooid/core": "^0.7.4",
32
- "@zooid/acp-client": "^0.7.4"
31
+ "@zooid/core": "^0.9.0",
32
+ "@zooid/acp-client": "^0.9.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@hono/node-server": "^1.13.0",
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { writeAttachment } from './attachments.js'
6
+
7
+ describe('writeAttachment', () => {
8
+ it('writes under .zooid/attachments/<event-id>/<filename> and returns both path views', () => {
9
+ const ws = mkdtempSync(join(tmpdir(), 'zooid-att-'))
10
+ try {
11
+ const out = writeAttachment({
12
+ workspaceDir: ws,
13
+ agentWorkspacePath: '/workspace',
14
+ eventId: '$abc123:localhost',
15
+ filename: 'report.pdf',
16
+ data: Buffer.from('hello'),
17
+ })
18
+ expect(out.hostPath).toBe(join(ws, '.zooid', 'attachments', 'abc123localhost', 'report.pdf'))
19
+ expect(out.agentPath).toBe('/workspace/.zooid/attachments/abc123localhost/report.pdf')
20
+ expect(readFileSync(out.hostPath, 'utf8')).toBe('hello')
21
+ } finally {
22
+ rmSync(ws, { recursive: true, force: true })
23
+ }
24
+ })
25
+
26
+ it('sanitizes path-traversal and separator characters in filenames', () => {
27
+ const ws = mkdtempSync(join(tmpdir(), 'zooid-att-'))
28
+ try {
29
+ const out = writeAttachment({
30
+ workspaceDir: ws,
31
+ agentWorkspacePath: '/workspace',
32
+ eventId: '$e1',
33
+ filename: '../../etc/passwd',
34
+ data: Buffer.from('x'),
35
+ })
36
+ expect(out.hostPath.startsWith(join(ws, '.zooid', 'attachments'))).toBe(true)
37
+ expect(out.hostPath).not.toContain('..')
38
+ } finally {
39
+ rmSync(ws, { recursive: true, force: true })
40
+ }
41
+ })
42
+
43
+ it('falls back to a default filename when body is empty', () => {
44
+ const ws = mkdtempSync(join(tmpdir(), 'zooid-att-'))
45
+ try {
46
+ const out = writeAttachment({
47
+ workspaceDir: ws,
48
+ agentWorkspacePath: '/workspace',
49
+ eventId: '$e2',
50
+ filename: '',
51
+ data: Buffer.from('x'),
52
+ })
53
+ expect(out.agentPath).toMatch(/\/file$/)
54
+ } finally {
55
+ rmSync(ws, { recursive: true, force: true })
56
+ }
57
+ })
58
+ })
@@ -0,0 +1,30 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { posix } from 'node:path'
4
+
5
+ function sanitize(s: string, fallback: string): string {
6
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, '').replace(/^\.+/, '')
7
+ return cleaned || fallback
8
+ }
9
+
10
+ export interface WriteAttachmentInput {
11
+ workspaceDir: string
12
+ agentWorkspacePath: string
13
+ eventId: string
14
+ filename: string
15
+ data: Uint8Array
16
+ }
17
+
18
+ export function writeAttachment(input: WriteAttachmentInput): {
19
+ hostPath: string
20
+ agentPath: string
21
+ } {
22
+ const dir = sanitize(input.eventId, 'event')
23
+ const name = sanitize(input.filename, 'file')
24
+ const hostDir = join(input.workspaceDir, '.zooid', 'attachments', dir)
25
+ mkdirSync(hostDir, { recursive: true })
26
+ const hostPath = join(hostDir, name)
27
+ writeFileSync(hostPath, input.data)
28
+ const agentPath = posix.join(input.agentWorkspacePath, '.zooid', 'attachments', dir, name)
29
+ return { hostPath, agentPath }
30
+ }
package/src/bot-pool.ts CHANGED
@@ -48,7 +48,7 @@ export class BotPool {
48
48
  console.warn(`[matrix] setDisplayName(${a.userId}) failed: ${(err as Error).message}`)
49
49
  }
50
50
  // Make each agent a member of the workforce space. That covers two
51
- // things at once: the eco.zoon.workforce roster is now backed by
51
+ // things at once: the dev.zooid.workforce roster is now backed by
52
52
  // actual space membership (so the Zoon client's member autocomplete
53
53
  // works across rooms), and every restricted child room's allow rule
54
54
  // is satisfied without per-room invites.
@@ -314,4 +314,37 @@ describe('MatrixContextProvider', () => {
314
314
  const info = await provider.getChannelInfo('!room:hs')
315
315
  expect(info.name).toBe('!room:hs')
316
316
  })
317
+
318
+ it('renders media events as placeholders instead of skipping them', async () => {
319
+ const client = fakeClient({
320
+ fetchRoomMessages: vi.fn().mockResolvedValue({
321
+ chunk: [
322
+ {
323
+ event_id: '$img',
324
+ sender: '@alice:hs',
325
+ origin_server_ts: 1000,
326
+ type: 'm.room.message',
327
+ content: { msgtype: 'm.image', body: 'dog.jpg', url: 'mxc://hs/a' },
328
+ },
329
+ {
330
+ event_id: '$file',
331
+ sender: '@alice:hs',
332
+ origin_server_ts: 2000,
333
+ type: 'm.room.message',
334
+ content: { msgtype: 'm.file', body: 'report.pdf', url: 'mxc://hs/b' },
335
+ },
336
+ ],
337
+ end: undefined,
338
+ }),
339
+ } as unknown as Partial<MatrixClient>)
340
+ const provider = new MatrixContextProvider({
341
+ client,
342
+ asUserId: '@_zooid:hs',
343
+ agentBots: new Map(),
344
+ })
345
+ const page = await provider.getRoomHistory('!room:hs', {})
346
+ const byId = new Map(page.messages.map((m) => [m.id, m]))
347
+ expect(byId.get('$img')?.text).toBe('[image: dog.jpg]')
348
+ expect(byId.get('$file')?.text).toBe('[file: report.pdf]')
349
+ })
317
350
  })
@@ -43,7 +43,7 @@ export class MatrixContextProvider implements TransportContextProvider {
43
43
 
44
44
  async getRoomHistory(channelId: string, hopts: HistoryOptions): Promise<HistoryPage> {
45
45
  // Server-side filter: only `m.room.message` events. Without this we'd
46
- // burn the page budget on reactions, `eco.zoon.*` custom events, typing
46
+ // burn the page budget on reactions, `dev.zooid.*` custom events, typing
47
47
  // notifications, etc., and routinely return empty pages with a stale
48
48
  // `has_more` cursor.
49
49
  const { chunk, end } = await this.opts.client.fetchRoomMessages({
@@ -147,15 +147,33 @@ export class MatrixContextProvider implements TransportContextProvider {
147
147
 
148
148
  private toMessage(ev: MatrixMessageEvent): Message | null {
149
149
  if (ev.type !== 'm.room.message') return null
150
- if (ev.content?.msgtype !== 'm.text' || typeof ev.content.body !== 'string') return null
150
+ const msgtype = ev.content?.msgtype
151
+ const body = ev.content?.body
151
152
  const agent = this.opts.agentBots.get(ev.sender)
152
- const relatesTo = ev.content['m.relates_to']
153
+ const relatesTo = ev.content?.['m.relates_to']
153
154
  const threadId =
154
155
  relatesTo?.rel_type === 'm.thread' && relatesTo.event_id ? relatesTo.event_id : undefined
156
+
157
+ // Media events render as context placeholders
158
+ if (msgtype === 'm.image' || msgtype === 'm.file' || msgtype === 'm.video' || msgtype === 'm.audio') {
159
+ const kind = msgtype.slice(2) // 'image', 'file', 'video', 'audio'
160
+ const name = typeof body === 'string' && body ? body : 'untitled'
161
+ return {
162
+ id: ev.event_id,
163
+ sender: ev.sender,
164
+ text: `[${kind}: ${name}]`,
165
+ timestamp: new Date(ev.origin_server_ts).toISOString(),
166
+ is_agent: agent !== undefined,
167
+ ...(agent !== undefined ? { agent_name: agent } : {}),
168
+ ...(threadId !== undefined ? { thread_id: threadId } : {}),
169
+ }
170
+ }
171
+
172
+ if (msgtype !== 'm.text' || typeof body !== 'string') return null
155
173
  return {
156
174
  id: ev.event_id,
157
175
  sender: ev.sender,
158
- text: ev.content.body,
176
+ text: body,
159
177
  timestamp: new Date(ev.origin_server_ts).toISOString(),
160
178
  is_agent: agent !== undefined,
161
179
  ...(agent !== undefined ? { agent_name: agent } : {}),
@@ -9,6 +9,7 @@ import {
9
9
  toUpdateBody,
10
10
  toPlanBody,
11
11
  toErrorBody,
12
+ toAvailableCommandsBody,
12
13
  } from './event-encoders.js'
13
14
 
14
15
  describe('toToolCallBody', () => {
@@ -124,6 +125,27 @@ describe('toPlanBody', () => {
124
125
  })
125
126
  })
126
127
 
128
+ describe('toAvailableCommandsBody', () => {
129
+ it('encodes available_commands into the body ZNC021 decodes', () => {
130
+ expect(
131
+ toAvailableCommandsBody({
132
+ type: 'available_commands',
133
+ sessionId: 's-1',
134
+ commands: [
135
+ { name: 'plan', description: 'Switch to plan mode' },
136
+ { name: 'compact', description: 'Compact the context' },
137
+ ],
138
+ }),
139
+ ).toEqual({
140
+ session_id: 's-1',
141
+ available_commands: [
142
+ { name: 'plan', description: 'Switch to plan mode' },
143
+ { name: 'compact', description: 'Compact the context' },
144
+ ],
145
+ })
146
+ })
147
+ })
148
+
127
149
  describe('toErrorBody', () => {
128
150
  const threadRoot = '$root-event-id'
129
151
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AvailableCommandsEvent,
2
3
  PlanEvent,
3
4
  TapEvent,
4
5
  ToolCallEvent,
@@ -66,6 +67,18 @@ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
66
67
  }
67
68
  }
68
69
 
70
+ export function toAvailableCommandsBody(
71
+ evt: AvailableCommandsEvent,
72
+ ): Record<string, unknown> {
73
+ return {
74
+ session_id: evt.sessionId,
75
+ available_commands: evt.commands.map((c) => ({
76
+ name: c.name,
77
+ description: c.description,
78
+ })),
79
+ }
80
+ }
81
+
69
82
  const RECOVERY_URLS: Partial<Record<string, string>> = {
70
83
  auth_missing: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
71
84
  auth_invalid: 'https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over',
package/src/index.ts CHANGED
@@ -6,11 +6,11 @@ export { renderRegistration } from './registration.js'
6
6
  export type { MatrixTransportConfig } from './registration.js'
7
7
  export { extractMentions } from './mentions.js'
8
8
  export type { MaybeMessage } from './mentions.js'
9
- export { route } from './router.js'
9
+ export { route, isMediaMsgtype, MEDIA_MSGTYPES } from './router.js'
10
10
  export type { AgentBinding, RouteMatch } from './router.js'
11
11
  export { BotPool } from './bot-pool.js'
12
12
  export { createMatrixTransport } from './transport.js'
13
- export type { CreateMatrixTransportOptions } from './transport.js'
13
+ export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
14
14
  export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
15
  export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
16
16
  export {
@@ -24,3 +24,9 @@ export type {
24
24
  PublisherHandle,
25
25
  StartOpts as StartWorkforcePublisherOpts,
26
26
  } from './workforce-publisher.js'
27
+ export { MediaClient, parseMxcUri, MAX_INLINE_IMAGE_BYTES, INLINE_IMAGE_MIMES, MAX_DOWNLOAD_BYTES } from './media-client.js'
28
+ export type { MediaClientOptions } from './media-client.js'
29
+ export { PendingMediaStore, MAX_MEDIA_PER_TURN } from './pending-media.js'
30
+ export type { PendingMediaItem } from './pending-media.js'
31
+ export { writeAttachment } from './attachments.js'
32
+ export type { WriteAttachmentInput } from './attachments.js'
@@ -287,7 +287,7 @@ describe('MatrixClient', () => {
287
287
 
288
288
  it('sends a custom event type when content type is set', async () => {
289
289
  const fetch = fakeFetch(async ({ url, init }) => {
290
- expect(url).toMatch(/\/send\/eco\.zoon\.approval_request\//)
290
+ expect(url).toMatch(/\/send\/dev\.zooid\.approval_request\//)
291
291
  const body = JSON.parse(init.body as string)
292
292
  expect(body.approval_id).toBe('a1')
293
293
  return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
@@ -300,7 +300,7 @@ describe('MatrixClient', () => {
300
300
  await client.sendCustomEvent({
301
301
  roomId: '!r:example.com',
302
302
  asUserId: '@architect:example.com',
303
- eventType: 'eco.zoon.approval_request',
303
+ eventType: 'dev.zooid.approval_request',
304
304
  content: { approval_id: 'a1', description: 'Run: git push' },
305
305
  })
306
306
  })
@@ -445,6 +445,39 @@ describe('MatrixClient.createRoom userPowerLevels', () => {
445
445
  })
446
446
  })
447
447
 
448
+ describe('MatrixClient.leaveRoom', () => {
449
+ it('leaves (rejects) a room as the impersonated user, with a reason', async () => {
450
+ const fetch = fakeFetch(async ({ url, init }) => {
451
+ expect(url).toBe(
452
+ 'https://hs.example.com/_matrix/client/v3/rooms/!r%3Aexample.com/leave' +
453
+ '?user_id=%40zooid%3Aexample.com',
454
+ )
455
+ expect(init.method).toBe('POST')
456
+ expect(JSON.parse(init.body as string)).toEqual({ reason: 'no thanks' })
457
+ return new Response('{}', { status: 200 })
458
+ })
459
+ const client = new MatrixClient({
460
+ homeserver: 'https://hs.example.com',
461
+ asToken: 'as-secret',
462
+ fetch: fetch as unknown as typeof globalThis.fetch,
463
+ })
464
+ await client.leaveRoom('!r:example.com', '@zooid:example.com', { reason: 'no thanks' })
465
+ })
466
+
467
+ it('sends an empty body when no reason is given', async () => {
468
+ const fetch = fakeFetch(async ({ init }) => {
469
+ expect(JSON.parse(init.body as string)).toEqual({})
470
+ return new Response('{}', { status: 200 })
471
+ })
472
+ const client = new MatrixClient({
473
+ homeserver: 'https://hs.example.com',
474
+ asToken: 'as-secret',
475
+ fetch: fetch as unknown as typeof globalThis.fetch,
476
+ })
477
+ await client.leaveRoom('!r:example.com', '@zooid:example.com')
478
+ })
479
+ })
480
+
448
481
  describe('MatrixClient.createRoom restricted', () => {
449
482
  it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
450
483
  const fetch = fakeFetch(async ({ url, init }) => {
@@ -222,6 +222,25 @@ export class MatrixClient {
222
222
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`)
223
223
  }
224
224
 
225
+ async leaveRoom(
226
+ roomId: string,
227
+ asUserId: string,
228
+ opts?: { reason?: string },
229
+ ): Promise<void> {
230
+ const url =
231
+ `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave` +
232
+ `?user_id=${encodeURIComponent(asUserId)}`
233
+ const r = await this.fetch(url, {
234
+ method: 'POST',
235
+ headers: {
236
+ Authorization: `Bearer ${this.asToken}`,
237
+ 'content-type': 'application/json',
238
+ },
239
+ body: JSON.stringify(opts?.reason ? { reason: opts.reason } : {}),
240
+ })
241
+ if (!r.ok) throw new Error(`leaveRoom(${roomId}, ${asUserId}) failed: ${r.status}`)
242
+ }
243
+
225
244
  async joinRoom(roomIdOrAlias: string, asUserId: string): Promise<void> {
226
245
  const url =
227
246
  `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}` +
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ MediaClient,
4
+ parseMxcUri,
5
+ MAX_INLINE_IMAGE_BYTES,
6
+ INLINE_IMAGE_MIMES,
7
+ } from './media-client.js'
8
+
9
+ const TINY_PNG = Buffer.from(
10
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
11
+ 'base64',
12
+ )
13
+
14
+ function fakeFetch(response: Partial<Response> & { body?: Uint8Array; json?: unknown }) {
15
+ return vi.fn(async () => ({
16
+ ok: response.ok ?? true,
17
+ status: response.status ?? 200,
18
+ headers: new Headers(response.headers ?? { 'content-type': 'image/png' }),
19
+ arrayBuffer: async () => {
20
+ const buf = response.body ?? TINY_PNG
21
+ // Use byteOffset/byteLength so the slice covers exactly the bytes the Buffer references.
22
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
23
+ },
24
+ json: async () => response.json ?? {},
25
+ text: async () => '',
26
+ })) as unknown as typeof fetch
27
+ }
28
+
29
+ describe('parseMxcUri', () => {
30
+ it('splits server name and media id', () => {
31
+ expect(parseMxcUri('mxc://localhost/AbCd1234')).toEqual({
32
+ serverName: 'localhost',
33
+ mediaId: 'AbCd1234',
34
+ })
35
+ })
36
+
37
+ it('returns null for non-mxc uris', () => {
38
+ expect(parseMxcUri('https://example.com/x.png')).toBeNull()
39
+ expect(parseMxcUri('mxc://missing-id')).toBeNull()
40
+ })
41
+ })
42
+
43
+ describe('MediaClient.download', () => {
44
+ it('GETs the authenticated v1 endpoint with AS token and user_id', async () => {
45
+ const f = fakeFetch({ body: TINY_PNG })
46
+ const media = new MediaClient({ homeserver: 'http://hs', asToken: 'tok', fetch: f })
47
+ const out = await media.download({ mxcUri: 'mxc://localhost/abc', asUserId: '@dev:localhost' })
48
+
49
+ const [url, init] = (f as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
50
+ expect(url).toBe(
51
+ 'http://hs/_matrix/client/v1/media/download/localhost/abc?user_id=%40dev%3Alocalhost',
52
+ )
53
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok')
54
+ expect(Buffer.from(out.data).equals(TINY_PNG)).toBe(true)
55
+ expect(out.contentType).toBe('image/png')
56
+ })
57
+
58
+ it('rejects bodies larger than maxBytes', async () => {
59
+ const big = new Uint8Array(64)
60
+ const f = fakeFetch({ body: big })
61
+ const media = new MediaClient({ homeserver: 'http://hs', asToken: 'tok', fetch: f })
62
+ await expect(
63
+ media.download({ mxcUri: 'mxc://localhost/abc', asUserId: '@dev:localhost', maxBytes: 16 }),
64
+ ).rejects.toThrow(/too large/i)
65
+ })
66
+
67
+ it('throws on non-OK responses with status in the message', async () => {
68
+ const f = fakeFetch({ ok: false, status: 404 })
69
+ const media = new MediaClient({ homeserver: 'http://hs', asToken: 'tok', fetch: f })
70
+ await expect(
71
+ media.download({ mxcUri: 'mxc://localhost/gone', asUserId: '@dev:localhost' }),
72
+ ).rejects.toThrow(/404/)
73
+ })
74
+ })
75
+
76
+ describe('MediaClient.upload', () => {
77
+ it('POSTs raw bytes to /_matrix/media/v3/upload with filename and user_id', async () => {
78
+ const f = fakeFetch({ json: { content_uri: 'mxc://localhost/up1' } })
79
+ const media = new MediaClient({ homeserver: 'http://hs', asToken: 'tok', fetch: f })
80
+ const out = await media.upload({
81
+ data: TINY_PNG,
82
+ contentType: 'image/png',
83
+ filename: 'shot.png',
84
+ asUserId: '@dev:localhost',
85
+ })
86
+
87
+ const [url, init] = (f as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]
88
+ expect(url).toBe('http://hs/_matrix/media/v3/upload?filename=shot.png&user_id=%40dev%3Alocalhost')
89
+ expect(init.method).toBe('POST')
90
+ expect((init.headers as Record<string, string>)['Content-Type']).toBe('image/png')
91
+ expect(out.content_uri).toBe('mxc://localhost/up1')
92
+ })
93
+ })
94
+
95
+ describe('inline routing constants', () => {
96
+ it('caps inline images at 0.5 MB and allowlists model-consumable mimes', () => {
97
+ expect(MAX_INLINE_IMAGE_BYTES).toBe(524_288)
98
+ expect(INLINE_IMAGE_MIMES).toContain('image/png')
99
+ expect(INLINE_IMAGE_MIMES).toContain('image/jpeg')
100
+ expect(INLINE_IMAGE_MIMES).not.toContain('image/svg+xml')
101
+ })
102
+ })
@@ -0,0 +1,69 @@
1
+ /** Limits are routing policy, not enforcement — see ZOD057 (enforcement lives
2
+ * in Tuwunel config + the Zoon composer). */
3
+ export const MAX_INLINE_IMAGE_BYTES = 524_288
4
+ export const INLINE_IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
5
+ export const MAX_DOWNLOAD_BYTES = 33_554_432
6
+
7
+ export interface MediaClientOptions {
8
+ homeserver: string
9
+ asToken: string
10
+ fetch?: typeof globalThis.fetch
11
+ }
12
+
13
+ export function parseMxcUri(uri: string): { serverName: string; mediaId: string } | null {
14
+ const m = /^mxc:\/\/([^/]+)\/(.+)$/.exec(uri)
15
+ return m ? { serverName: m[1], mediaId: m[2] } : null
16
+ }
17
+
18
+ export class MediaClient {
19
+ private readonly homeserver: string
20
+ private readonly asToken: string
21
+ private readonly fetch: typeof globalThis.fetch
22
+
23
+ constructor(opts: MediaClientOptions) {
24
+ this.homeserver = opts.homeserver.replace(/\/$/, '')
25
+ this.asToken = opts.asToken
26
+ this.fetch = opts.fetch ?? globalThis.fetch
27
+ }
28
+
29
+ async download(input: {
30
+ mxcUri: string
31
+ asUserId: string
32
+ maxBytes?: number
33
+ }): Promise<{ data: Uint8Array; contentType: string }> {
34
+ const parsed = parseMxcUri(input.mxcUri)
35
+ if (!parsed) throw new Error(`not an mxc uri: ${input.mxcUri}`)
36
+ const url =
37
+ `${this.homeserver}/_matrix/client/v1/media/download/` +
38
+ `${encodeURIComponent(parsed.serverName)}/${encodeURIComponent(parsed.mediaId)}` +
39
+ `?user_id=${encodeURIComponent(input.asUserId)}`
40
+ const r = await this.fetch(url, {
41
+ headers: { Authorization: `Bearer ${this.asToken}` },
42
+ })
43
+ if (!r.ok) throw new Error(`media download failed: ${r.status}`)
44
+ const buf = new Uint8Array(await r.arrayBuffer())
45
+ const max = input.maxBytes ?? MAX_DOWNLOAD_BYTES
46
+ if (buf.byteLength > max) {
47
+ throw new Error(`media too large: ${buf.byteLength} > ${max}`)
48
+ }
49
+ return { data: buf, contentType: r.headers.get('content-type') ?? 'application/octet-stream' }
50
+ }
51
+
52
+ async upload(input: {
53
+ data: Uint8Array
54
+ contentType: string
55
+ filename?: string
56
+ asUserId: string
57
+ }): Promise<{ content_uri: string }> {
58
+ const params = new URLSearchParams()
59
+ if (input.filename) params.set('filename', input.filename)
60
+ params.set('user_id', input.asUserId)
61
+ const r = await this.fetch(`${this.homeserver}/_matrix/media/v3/upload?${params}`, {
62
+ method: 'POST',
63
+ headers: { Authorization: `Bearer ${this.asToken}`, 'Content-Type': input.contentType },
64
+ body: input.data,
65
+ })
66
+ if (!r.ok) throw new Error(`media upload failed: ${r.status}`)
67
+ return (await r.json()) as { content_uri: string }
68
+ }
69
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { PendingMediaStore, MAX_MEDIA_PER_TURN } from './pending-media.js'
3
+
4
+ function item(over: Partial<{ eventId: string; sender: string; msgtype: string }> = {}) {
5
+ return {
6
+ eventId: over.eventId ?? '$m1',
7
+ sender: over.sender ?? '@alice:localhost',
8
+ msgtype: over.msgtype ?? 'm.image',
9
+ body: 'dog.jpg',
10
+ filename: 'dog.jpg',
11
+ url: 'mxc://localhost/abc',
12
+ info: { mimetype: 'image/jpeg', size: 1000 },
13
+ }
14
+ }
15
+
16
+ describe('PendingMediaStore', () => {
17
+ it('drains items for the same room+thread+sender and clears them', () => {
18
+ const s = new PendingMediaStore()
19
+ s.add('!r:hs', '$root', item({ eventId: '$m1' }))
20
+ s.add('!r:hs', '$root', item({ eventId: '$m2' }))
21
+ const drained = s.drain('!r:hs', '$root', '@alice:localhost')
22
+ expect(drained.map((i) => i.eventId)).toEqual(['$m1', '$m2'])
23
+ expect(s.drain('!r:hs', '$root', '@alice:localhost')).toEqual([])
24
+ })
25
+
26
+ it('does not leak across threads, rooms, or senders', () => {
27
+ const s = new PendingMediaStore()
28
+ s.add('!r:hs', '$rootA', item())
29
+ expect(s.drain('!r:hs', '$rootB', '@alice:localhost')).toEqual([])
30
+ expect(s.drain('!other:hs', '$rootA', '@alice:localhost')).toEqual([])
31
+ expect(s.drain('!r:hs', '$rootA', '@bob:localhost')).toEqual([])
32
+ // alice's item is still there after bob's miss
33
+ expect(s.drain('!r:hs', '$rootA', '@alice:localhost')).toHaveLength(1)
34
+ })
35
+
36
+ it('caps the queue at MAX_MEDIA_PER_TURN, dropping oldest first', () => {
37
+ const s = new PendingMediaStore()
38
+ for (let i = 0; i < MAX_MEDIA_PER_TURN + 3; i++) {
39
+ s.add('!r:hs', '$root', item({ eventId: `$m${i}` }))
40
+ }
41
+ const drained = s.drain('!r:hs', '$root', '@alice:localhost')
42
+ expect(drained).toHaveLength(MAX_MEDIA_PER_TURN)
43
+ expect(drained[0].eventId).toBe('$m3') // 0,1,2 dropped
44
+ })
45
+
46
+ it('uses a room-level key when the media event is unthreaded', () => {
47
+ const s = new PendingMediaStore()
48
+ s.add('!r:hs', undefined, item())
49
+ expect(s.drain('!r:hs', undefined, '@alice:localhost')).toHaveLength(1)
50
+ })
51
+ })
@@ -0,0 +1,37 @@
1
+ export const MAX_MEDIA_PER_TURN = 8
2
+
3
+ export interface PendingMediaItem {
4
+ eventId: string
5
+ sender: string
6
+ msgtype: string
7
+ body: string
8
+ filename?: string
9
+ url: string
10
+ info?: { mimetype?: string; size?: number; w?: number; h?: number }
11
+ }
12
+
13
+ export class PendingMediaStore {
14
+ private readonly queues = new Map<string, PendingMediaItem[]>()
15
+
16
+ private key(roomId: string, threadKey: string | undefined): string {
17
+ return `${roomId} ${threadKey ?? 'main'}`
18
+ }
19
+
20
+ add(roomId: string, threadKey: string | undefined, item: PendingMediaItem): void {
21
+ const k = this.key(roomId, threadKey)
22
+ const q = this.queues.get(k) ?? []
23
+ q.push(item)
24
+ while (q.length > MAX_MEDIA_PER_TURN) q.shift()
25
+ this.queues.set(k, q)
26
+ }
27
+
28
+ drain(roomId: string, threadKey: string | undefined, sender: string): PendingMediaItem[] {
29
+ const k = this.key(roomId, threadKey)
30
+ const q = this.queues.get(k) ?? []
31
+ const mine = q.filter((i) => i.sender === sender)
32
+ const rest = q.filter((i) => i.sender !== sender)
33
+ if (rest.length) this.queues.set(k, rest)
34
+ else this.queues.delete(k)
35
+ return mine
36
+ }
37
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { route, type AgentBinding } from './router.js'
2
+ import { route, isMediaMsgtype, type AgentBinding } from './router.js'
3
3
 
4
4
  const agents: AgentBinding[] = [
5
5
  {
@@ -88,3 +88,24 @@ describe('route', () => {
88
88
  expect(route(stateEvent as never, agents)).toEqual([])
89
89
  })
90
90
  })
91
+
92
+ describe('media events', () => {
93
+ it('classifies media msgtypes', () => {
94
+ for (const t of ['m.image', 'm.file', 'm.video', 'm.audio']) {
95
+ expect(isMediaMsgtype(t)).toBe(true)
96
+ }
97
+ expect(isMediaMsgtype('m.text')).toBe(false)
98
+ expect(isMediaMsgtype('m.notice')).toBe(false)
99
+ expect(isMediaMsgtype(undefined)).toBe(false)
100
+ })
101
+
102
+ it('never routes media events to agents, even trigger=any', () => {
103
+ const monitorRoom = msg({ room: '!alerts:example.com', body: 'dog.jpg' })
104
+ const mediaEvent = {
105
+ ...monitorRoom,
106
+ content: { msgtype: 'm.image', body: 'dog.jpg', url: 'mxc://localhost/abc' },
107
+ }
108
+ const matches = route(mediaEvent, agents)
109
+ expect(matches).toEqual([])
110
+ })
111
+ })