@zooid/transport-matrix 0.7.3 → 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.
@@ -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
  })
@@ -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 } : {}),
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
+ }
@@ -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', () => {
@@ -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
- // A workspace is joined by invitation, not self-service. Pin the space's
30
- // join rule to invite regardless of preset so it can't be walked into
31
- // (which would otherwise satisfy every restricted child room's allow).
32
- initial_state: [{ type: 'm.room.join_rules', state_key: '', content: { join_rule: 'invite' } }],
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