@zooid/transport-matrix 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +109 -1
- package/dist/index.js +271 -9
- 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/context-provider.test.ts +33 -0
- package/src/context-provider.ts +21 -3
- package/src/index.ts +8 -2
- 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 +242 -0
- package/src/transport.ts +227 -4
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -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 } : {}),
|
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'
|
|
@@ -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
|
+
})
|
package/src/router.ts
CHANGED
|
@@ -16,6 +16,16 @@ export interface AgentBinding {
|
|
|
16
16
|
*/
|
|
17
17
|
rooms: RoomBinding[]
|
|
18
18
|
trigger: 'mention' | 'any'
|
|
19
|
+
/** Host path of the agent's workspace (resolved agent.workdir). Media files land here. */
|
|
20
|
+
workspaceDir?: string
|
|
21
|
+
/** Path prefix as the agent sees it: '/workspace' for containers, = workspaceDir for local. */
|
|
22
|
+
agentWorkspacePath?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MEDIA_MSGTYPES = new Set(['m.image', 'm.file', 'm.video', 'm.audio'])
|
|
26
|
+
|
|
27
|
+
export function isMediaMsgtype(t: string | undefined): boolean {
|
|
28
|
+
return t !== undefined && MEDIA_MSGTYPES.has(t)
|
|
19
29
|
}
|
|
20
30
|
|
|
21
31
|
export interface ThreadState {
|
|
@@ -49,6 +59,7 @@ export function route(
|
|
|
49
59
|
): RouteMatch[] {
|
|
50
60
|
if (event.type !== 'm.room.message') return []
|
|
51
61
|
if (!event.content?.msgtype) return []
|
|
62
|
+
if (isMediaMsgtype(event.content.msgtype)) return []
|
|
52
63
|
const mentions = new Set(extractMentions(event as never))
|
|
53
64
|
const matches: RouteMatch[] = []
|
|
54
65
|
const threadRoot = inboundThreadRoot(event)
|
|
@@ -75,7 +75,7 @@ describe('ensureWorkforceSpace', () => {
|
|
|
75
75
|
})
|
|
76
76
|
|
|
77
77
|
describe('ensureWorkforceSpace privacy', () => {
|
|
78
|
-
it('creates the space invite-only (overriding any public preset)', async () => {
|
|
78
|
+
it('creates the space invite-only by default (overriding any public preset)', async () => {
|
|
79
79
|
const { client } = clientWithFetches(
|
|
80
80
|
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
81
81
|
(url, init) => {
|
|
@@ -99,6 +99,31 @@ describe('ensureWorkforceSpace privacy', () => {
|
|
|
99
99
|
})
|
|
100
100
|
expect(id).toBe('!space:hs.zoon.local')
|
|
101
101
|
})
|
|
102
|
+
|
|
103
|
+
it('creates a publicly-joinable space when joinRule is public (zooid dev)', async () => {
|
|
104
|
+
const { client } = clientWithFetches(
|
|
105
|
+
() => new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
|
|
106
|
+
(url, init) => {
|
|
107
|
+
expect(url).toContain('/_matrix/client/v3/createRoom')
|
|
108
|
+
const body = JSON.parse(init!.body as string)
|
|
109
|
+
expect(body.initial_state).toContainEqual({
|
|
110
|
+
type: 'm.room.join_rules',
|
|
111
|
+
state_key: '',
|
|
112
|
+
content: { join_rule: 'public' },
|
|
113
|
+
})
|
|
114
|
+
return new Response(JSON.stringify({ room_id: '!space:hs.zoon.local' }), { status: 200 })
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
const id = await ensureWorkforceSpace({
|
|
118
|
+
client,
|
|
119
|
+
asUserId: '@zooid:hs.zoon.local',
|
|
120
|
+
serverName: 'hs.zoon.local',
|
|
121
|
+
spaceLocalpart: 'dev',
|
|
122
|
+
preset: 'public_chat',
|
|
123
|
+
joinRule: 'public',
|
|
124
|
+
})
|
|
125
|
+
expect(id).toBe('!space:hs.zoon.local')
|
|
126
|
+
})
|
|
102
127
|
})
|
|
103
128
|
|
|
104
129
|
describe('ensureWorkforceSpace admins', () => {
|
package/src/space-provisioner.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface EnsureSpaceOpts {
|
|
|
13
13
|
* if the alias already resolves we return the existing room untouched.
|
|
14
14
|
*/
|
|
15
15
|
admins?: string[]
|
|
16
|
+
/**
|
|
17
|
+
* Join rule pinned on the space at creation. Defaults to `invite`: a
|
|
18
|
+
* workspace is joined by invitation, not self-service, so it can't be walked
|
|
19
|
+
* into (which would otherwise satisfy every restricted child room's `allow`).
|
|
20
|
+
* `zooid dev` passes `public` so a self-service-registered local account can
|
|
21
|
+
* join `#<space>` straight from the web client without an invite — acceptable
|
|
22
|
+
* because the dev homeserver is local-only and never deployed.
|
|
23
|
+
*/
|
|
24
|
+
joinRule?: 'invite' | 'public'
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<string> {
|
|
@@ -26,10 +35,12 @@ export async function ensureWorkforceSpace(opts: EnsureSpaceOpts): Promise<strin
|
|
|
26
35
|
name: display,
|
|
27
36
|
preset: opts.preset,
|
|
28
37
|
creation_content: { type: 'm.space' },
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
initial_state: [
|
|
38
|
+
// Pin the join rule regardless of preset. Defaults to invite so the space
|
|
39
|
+
// can't be walked into (which would otherwise satisfy every restricted
|
|
40
|
+
// child room's allow); `zooid dev` opts into `public` for local-only use.
|
|
41
|
+
initial_state: [
|
|
42
|
+
{ type: 'm.room.join_rules', state_key: '', content: { join_rule: opts.joinRule ?? 'invite' } },
|
|
43
|
+
],
|
|
33
44
|
}
|
|
34
45
|
if (opts.admins && opts.admins.length > 0) {
|
|
35
46
|
// Invite each admin so they actually become members — PL 100 alone does
|