@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/dist/index.d.ts +116 -1
- package/dist/index.js +387 -51
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/attachments.test.ts +58 -0
- package/src/attachments.ts +30 -0
- package/src/bot-pool.ts +1 -1
- package/src/context-provider.test.ts +33 -0
- package/src/context-provider.ts +22 -4
- package/src/event-encoders.test.ts +22 -0
- package/src/event-encoders.ts +13 -0
- package/src/index.ts +8 -2
- package/src/matrix-client.test.ts +35 -2
- package/src/matrix-client.ts +19 -0
- package/src/media-client.test.ts +102 -0
- package/src/media-client.ts +69 -0
- package/src/pending-media.test.ts +51 -0
- package/src/pending-media.ts +37 -0
- package/src/router.test.ts +22 -1
- package/src/router.ts +11 -0
- package/src/space-provisioner.test.ts +26 -1
- package/src/space-provisioner.ts +15 -4
- package/src/transport.test.ts +401 -30
- package/src/transport.ts +402 -70
- package/src/workforce-publisher.test.ts +2 -2
- package/src/workforce-publisher.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@zooid/acp-client": "^0.
|
|
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
|
|
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
|
})
|
package/src/context-provider.ts
CHANGED
|
@@ -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, `
|
|
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
|
-
|
|
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:
|
|
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
|
|
package/src/event-encoders.ts
CHANGED
|
@@ -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\/
|
|
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: '
|
|
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 }) => {
|
package/src/matrix-client.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/router.test.ts
CHANGED
|
@@ -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
|
+
})
|