@zooid/transport-matrix 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type {
3
+ ToolCallEvent,
4
+ ToolCallUpdateEvent,
5
+ PlanEvent,
6
+ } from '@zooid/acp-client'
7
+ import {
8
+ toToolCallBody,
9
+ toUpdateBody,
10
+ toPlanBody,
11
+ } from './event-encoders.js'
12
+
13
+ describe('toToolCallBody', () => {
14
+ it('maps required + optional fields with snake_case keys', () => {
15
+ const evt: ToolCallEvent = {
16
+ type: 'tool_call',
17
+ sessionId: 'sess-1',
18
+ toolCallId: 'tc-1',
19
+ title: 'Run tests',
20
+ kind: 'execute',
21
+ status: 'pending',
22
+ }
23
+ expect(toToolCallBody(evt)).toEqual({
24
+ session_id: 'sess-1',
25
+ tool_call_id: 'tc-1',
26
+ title: 'Run tests',
27
+ kind: 'execute',
28
+ status: 'pending',
29
+ })
30
+ })
31
+
32
+ it('omits optional fields when undefined', () => {
33
+ const evt: ToolCallEvent = {
34
+ type: 'tool_call',
35
+ sessionId: 'sess-1',
36
+ toolCallId: 'tc-1',
37
+ title: 'Run tests',
38
+ }
39
+ expect(toToolCallBody(evt)).toEqual({
40
+ session_id: 'sess-1',
41
+ tool_call_id: 'tc-1',
42
+ title: 'Run tests',
43
+ })
44
+ })
45
+
46
+ it('forwards rawInput as raw_input (snake_case) and locations', () => {
47
+ const evt: ToolCallEvent = {
48
+ type: 'tool_call',
49
+ sessionId: 'sess-1',
50
+ toolCallId: 'tc-1',
51
+ title: 'Read file',
52
+ kind: 'read',
53
+ rawInput: { filepath: '/abs/path/notes.md' },
54
+ locations: [{ path: '/abs/path/notes.md' }],
55
+ }
56
+ expect(toToolCallBody(evt)).toEqual({
57
+ session_id: 'sess-1',
58
+ tool_call_id: 'tc-1',
59
+ title: 'Read file',
60
+ kind: 'read',
61
+ raw_input: { filepath: '/abs/path/notes.md' },
62
+ locations: [{ path: '/abs/path/notes.md' }],
63
+ })
64
+ })
65
+
66
+ it('truncates long string values inside rawInput', () => {
67
+ const longDiff = 'a'.repeat(500)
68
+ const evt: ToolCallEvent = {
69
+ type: 'tool_call',
70
+ sessionId: 'sess-1',
71
+ toolCallId: 'tc-1',
72
+ title: 'Edit file',
73
+ kind: 'edit',
74
+ rawInput: { filepath: '/abs/short.md', diff: longDiff },
75
+ }
76
+ const body = toToolCallBody(evt) as { raw_input: Record<string, unknown> }
77
+ expect(body.raw_input.filepath).toBe('/abs/short.md')
78
+ expect(body.raw_input.diff).toBe('a'.repeat(250) + '… [truncated]')
79
+ })
80
+ })
81
+
82
+ describe('toUpdateBody', () => {
83
+ it('passes through status/kind/content with snake_case keys', () => {
84
+ const evt: ToolCallUpdateEvent = {
85
+ type: 'tool_call_update',
86
+ sessionId: 'sess-1',
87
+ toolCallId: 'tc-1',
88
+ status: 'completed',
89
+ content: [{ type: 'content', content: { type: 'text', text: 'done' } }] as never,
90
+ }
91
+ expect(toUpdateBody(evt)).toEqual({
92
+ session_id: 'sess-1',
93
+ tool_call_id: 'tc-1',
94
+ status: 'completed',
95
+ content: evt.content,
96
+ })
97
+ })
98
+
99
+ it('omits absent optional fields', () => {
100
+ const evt: ToolCallUpdateEvent = {
101
+ type: 'tool_call_update',
102
+ sessionId: 'sess-1',
103
+ toolCallId: 'tc-1',
104
+ }
105
+ expect(toUpdateBody(evt)).toEqual({
106
+ session_id: 'sess-1',
107
+ tool_call_id: 'tc-1',
108
+ })
109
+ })
110
+ })
111
+
112
+ describe('toPlanBody', () => {
113
+ it('forwards entries verbatim under session_id', () => {
114
+ const evt: PlanEvent = {
115
+ type: 'plan',
116
+ sessionId: 'sess-1',
117
+ entries: [{ content: 'step a', priority: 'high', status: 'pending' }] as never,
118
+ }
119
+ expect(toPlanBody(evt)).toEqual({
120
+ session_id: 'sess-1',
121
+ entries: evt.entries,
122
+ })
123
+ })
124
+ })
@@ -0,0 +1,66 @@
1
+ import type {
2
+ PlanEvent,
3
+ ToolCallEvent,
4
+ ToolCallUpdateEvent,
5
+ } from '@zooid/acp-client'
6
+
7
+ /** Cap any single string in rawInput so big diffs / file contents don't bloat Matrix. */
8
+ const RAW_INPUT_STR_MAX = 250
9
+
10
+ export function toToolCallBody(evt: ToolCallEvent): Record<string, unknown> {
11
+ const out: Record<string, unknown> = {
12
+ session_id: evt.sessionId,
13
+ tool_call_id: evt.toolCallId,
14
+ title: evt.title,
15
+ }
16
+ if (evt.kind !== undefined) out.kind = evt.kind
17
+ if (evt.status !== undefined) out.status = evt.status
18
+ if (evt.rawInput !== undefined) out.raw_input = truncateStrings(evt.rawInput, RAW_INPUT_STR_MAX)
19
+ if (evt.locations !== undefined) out.locations = evt.locations
20
+ return out
21
+ }
22
+
23
+ /**
24
+ * Recursively truncates string values longer than `max` with a "… [truncated]"
25
+ * suffix. Non-string scalars and structure are preserved.
26
+ */
27
+ function truncateStrings(v: unknown, max: number): unknown {
28
+ if (typeof v === 'string') {
29
+ return v.length > max ? v.slice(0, max) + '… [truncated]' : v
30
+ }
31
+ if (Array.isArray(v)) {
32
+ return v.map((item) => truncateStrings(item, max))
33
+ }
34
+ if (v && typeof v === 'object') {
35
+ const out: Record<string, unknown> = {}
36
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
37
+ out[k] = truncateStrings(val, max)
38
+ }
39
+ return out
40
+ }
41
+ return v
42
+ }
43
+
44
+ export function toUpdateBody(evt: ToolCallUpdateEvent): Record<string, unknown> {
45
+ const out: Record<string, unknown> = {
46
+ session_id: evt.sessionId,
47
+ tool_call_id: evt.toolCallId,
48
+ }
49
+ if (evt.status !== undefined) out.status = evt.status
50
+ if (evt.kind !== undefined) out.kind = evt.kind
51
+ // content[] carries display-ready output (text/diff/terminal); rawOutput is
52
+ // intentionally NOT serialized — it's typically large and duplicates content.
53
+ if (evt.content !== undefined) out.content = evt.content
54
+ // Some ACP agents only set rawInput on a later update (not the initial
55
+ // tool_call). Truncate strings and forward.
56
+ if (evt.rawInput !== undefined) out.raw_input = truncateStrings(evt.rawInput, RAW_INPUT_STR_MAX)
57
+ if (evt.locations !== undefined) out.locations = evt.locations
58
+ return out
59
+ }
60
+
61
+ export function toPlanBody(evt: PlanEvent): Record<string, unknown> {
62
+ return {
63
+ session_id: evt.sessionId,
64
+ entries: evt.entries,
65
+ }
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { MatrixClient } from './matrix-client.js'
2
+ export type { MatrixClientOptions, SendMessageInput, SendCustomEventInput } from './matrix-client.js'
3
+ export { MatrixContextProvider } from './context-provider.js'
4
+ export type { MatrixContextProviderOpts } from './context-provider.js'
5
+ export { renderRegistration } from './registration.js'
6
+ export type { MatrixTransportConfig } from './registration.js'
7
+ export { extractMentions } from './mentions.js'
8
+ export type { MaybeMessage } from './mentions.js'
9
+ export { route } from './router.js'
10
+ export type { AgentBinding, RouteMatch } from './router.js'
11
+ export { BotPool } from './bot-pool.js'
12
+ export { createMatrixTransport } from './transport.js'
13
+ export type { CreateMatrixTransportOptions } from './transport.js'
14
+ export { ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
15
+ export type { EnsureSpaceOpts } from './space-provisioner.js'
16
+ export {
17
+ buildWorkforceRoster,
18
+ publishWorkforce,
19
+ startWorkforcePublisher,
20
+ } from './workforce-publisher.js'
21
+ export type {
22
+ WorkforceRoster,
23
+ PublishOpts,
24
+ PublisherHandle,
25
+ StartOpts as StartWorkforcePublisherOpts,
26
+ } from './workforce-publisher.js'
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { toMatrixHtml } from './markdown-to-matrix-html.js'
3
+
4
+ describe('toMatrixHtml', () => {
5
+ it('returns empty string for empty input', () => {
6
+ expect(toMatrixHtml('')).toBe('')
7
+ expect(toMatrixHtml(' \n\n ')).toBe('')
8
+ })
9
+
10
+ it('wraps plain prose in <p>', () => {
11
+ expect(toMatrixHtml('hello world')).toContain('<p>hello world</p>')
12
+ })
13
+
14
+ it('renders bold and italic', () => {
15
+ const out = toMatrixHtml('**bold** and _italic_')
16
+ expect(out).toContain('<strong>bold</strong>')
17
+ expect(out).toContain('<em>italic</em>')
18
+ })
19
+
20
+ it('renders inline code', () => {
21
+ expect(toMatrixHtml('use `foo()`')).toContain('<code>foo()</code>')
22
+ })
23
+
24
+ it('renders fenced code with language hint as class', () => {
25
+ const out = toMatrixHtml('```ts\nconst x = 1\n```')
26
+ expect(out).toContain('<pre>')
27
+ expect(out).toContain('<code class="language-ts">')
28
+ expect(out).toContain('const x = 1')
29
+ })
30
+
31
+ it('renders headings up to h6', () => {
32
+ expect(toMatrixHtml('# H1')).toContain('<h1>H1</h1>')
33
+ expect(toMatrixHtml('###### H6')).toContain('<h6>H6</h6>')
34
+ })
35
+
36
+ it('renders unordered and ordered lists', () => {
37
+ const ul = toMatrixHtml('- a\n- b')
38
+ expect(ul).toContain('<ul>')
39
+ expect(ul).toContain('<li>a</li>')
40
+ expect(ul).toContain('<li>b</li>')
41
+
42
+ const ol = toMatrixHtml('1. one\n2. two')
43
+ expect(ol).toContain('<ol>')
44
+ expect(ol).toContain('<li>one</li>')
45
+ })
46
+
47
+ it('renders GFM strikethrough as <del>', () => {
48
+ expect(toMatrixHtml('~~gone~~')).toContain('<del>gone</del>')
49
+ })
50
+
51
+ it('renders GFM tables', () => {
52
+ const md = `| a | b |\n|---|---|\n| 1 | 2 |`
53
+ const out = toMatrixHtml(md)
54
+ expect(out).toContain('<table>')
55
+ expect(out).toContain('<th>a</th>')
56
+ expect(out).toContain('<td>1</td>')
57
+ })
58
+
59
+ it('renders blockquotes', () => {
60
+ expect(toMatrixHtml('> quoted')).toContain('<blockquote>')
61
+ })
62
+
63
+ it('renders safe https links', () => {
64
+ const out = toMatrixHtml('[home](https://example.com)')
65
+ expect(out).toContain('<a href="https://example.com">home</a>')
66
+ })
67
+
68
+ it('strips javascript: URLs from links', () => {
69
+ const out = toMatrixHtml('[evil](javascript:alert(1))')
70
+ // sanitize-html drops the href; the anchor text is preserved
71
+ expect(out).not.toContain('javascript:')
72
+ expect(out).toContain('evil')
73
+ })
74
+
75
+ it('strips <script> tags entirely', () => {
76
+ const out = toMatrixHtml('<script>alert(1)</script>hello')
77
+ expect(out).not.toContain('<script')
78
+ expect(out).not.toContain('alert(1)')
79
+ expect(out).toContain('hello')
80
+ })
81
+
82
+ it('strips inline event handlers', () => {
83
+ const out = toMatrixHtml('<a href="https://x" onclick="bad()">x</a>')
84
+ expect(out).not.toContain('onclick')
85
+ })
86
+
87
+ it('strips <style> tags', () => {
88
+ const out = toMatrixHtml('<style>body{display:none}</style>safe')
89
+ expect(out).not.toContain('<style')
90
+ expect(out).toContain('safe')
91
+ })
92
+
93
+ it('preserves @user:server text passthrough for downstream mention rendering', () => {
94
+ // We do NOT convert mentions to anchors here — Zoon does that on render.
95
+ const out = toMatrixHtml('hello @alice:zoon.eco how are you')
96
+ expect(out).toContain('@alice:zoon.eco')
97
+ })
98
+
99
+ it('survives pathological input without throwing', () => {
100
+ expect(() => toMatrixHtml(' '.repeat(10))).not.toThrow()
101
+ })
102
+ })
@@ -0,0 +1,41 @@
1
+ import { marked } from 'marked'
2
+ import sanitizeHtml from 'sanitize-html'
3
+
4
+ // Matrix-permitted HTML tags per the m.room.message spec.
5
+ // https://spec.matrix.org/v1.11/client-server-api/#mroommessage-msgtypes
6
+ const ALLOWED_TAGS = [
7
+ 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a',
8
+ 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i', 'u', 'strong', 'em', 's',
9
+ 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th',
10
+ 'td', 'caption', 'pre', 'span', 'img', 'details', 'summary',
11
+ ]
12
+
13
+ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
14
+ a: ['href', 'name', 'target'],
15
+ img: ['width', 'height', 'alt', 'title', 'src'],
16
+ ol: ['start'],
17
+ code: ['class'],
18
+ span: ['data-mx-color', 'data-mx-bg-color', 'data-mx-spoiler'],
19
+ }
20
+
21
+ const ALLOWED_SCHEMES = ['https', 'http', 'ftp', 'mailto', 'magnet', 'matrix']
22
+
23
+ marked.setOptions({ gfm: true, breaks: false, async: false })
24
+
25
+ export function toMatrixHtml(markdown: string): string {
26
+ if (!markdown || !markdown.trim()) return ''
27
+ let rawHtml: string
28
+ try {
29
+ const out = marked.parse(markdown) as string | Promise<string>
30
+ if (typeof out !== 'string') return ''
31
+ rawHtml = out
32
+ } catch {
33
+ return ''
34
+ }
35
+ return sanitizeHtml(rawHtml, {
36
+ allowedTags: ALLOWED_TAGS,
37
+ allowedAttributes: ALLOWED_ATTRIBUTES,
38
+ allowedSchemes: ALLOWED_SCHEMES,
39
+ allowedSchemesByTag: { a: ALLOWED_SCHEMES },
40
+ })
41
+ }
@@ -0,0 +1,307 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { MatrixClient } from './matrix-client.js'
3
+
4
+ function fakeFetch(handler: (req: { url: string; init: RequestInit }) => Promise<Response>) {
5
+ return vi.fn(async (input: RequestInfo, init: RequestInit = {}) => {
6
+ return handler({ url: typeof input === 'string' ? input : input.toString(), init })
7
+ })
8
+ }
9
+
10
+ describe('MatrixClient', () => {
11
+ it('registers a bot using m.login.application_service and the AS token', async () => {
12
+ const fetch = fakeFetch(async ({ url, init }) => {
13
+ expect(url).toBe('https://hs.example.com/_matrix/client/v3/register')
14
+ expect(init.method).toBe('POST')
15
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer as-secret')
16
+ expect(JSON.parse(init.body as string)).toEqual({
17
+ type: 'm.login.application_service',
18
+ username: 'architect',
19
+ })
20
+ return new Response(
21
+ JSON.stringify({ user_id: '@architect:example.com', device_id: 'D1' }),
22
+ { status: 200 },
23
+ )
24
+ })
25
+ const client = new MatrixClient({
26
+ homeserver: 'https://hs.example.com',
27
+ asToken: 'as-secret',
28
+ fetch: fetch as unknown as typeof globalThis.fetch,
29
+ })
30
+ const user = await client.registerBot('architect')
31
+ expect(user).toEqual({ user_id: '@architect:example.com', device_id: 'D1' })
32
+ })
33
+
34
+ it('treats M_USER_IN_USE as a no-op (idempotent registration)', async () => {
35
+ const fetch = fakeFetch(async () =>
36
+ new Response(JSON.stringify({ errcode: 'M_USER_IN_USE' }), { status: 400 }),
37
+ )
38
+ const client = new MatrixClient({
39
+ homeserver: 'https://hs.example.com',
40
+ asToken: 'as-secret',
41
+ fetch: fetch as unknown as typeof globalThis.fetch,
42
+ })
43
+ await expect(client.registerBot('architect')).resolves.toBeUndefined()
44
+ })
45
+
46
+ it('joins a room with user_id query parameter for impersonation', async () => {
47
+ const fetch = fakeFetch(async ({ url, init }) => {
48
+ expect(url).toBe(
49
+ 'https://hs.example.com/_matrix/client/v3/join/!r%3Aexample.com?user_id=%40architect%3Aexample.com',
50
+ )
51
+ expect(init.method).toBe('POST')
52
+ return new Response(JSON.stringify({ room_id: '!r:example.com' }), { status: 200 })
53
+ })
54
+ const client = new MatrixClient({
55
+ homeserver: 'https://hs.example.com',
56
+ asToken: 'as-secret',
57
+ fetch: fetch as unknown as typeof globalThis.fetch,
58
+ })
59
+ await client.joinRoom('!r:example.com', '@architect:example.com')
60
+ })
61
+
62
+ it('sends m.room.message via PUT with a unique txnId and m.thread relation', async () => {
63
+ const fetch = fakeFetch(async ({ url, init }) => {
64
+ expect(url).toMatch(
65
+ /^https:\/\/hs\.example\.com\/_matrix\/client\/v3\/rooms\/!r%3Aexample\.com\/send\/m\.room\.message\/[0-9a-f-]+\?user_id=%40architect%3Aexample\.com$/,
66
+ )
67
+ expect(init.method).toBe('PUT')
68
+ const body = JSON.parse(init.body as string)
69
+ expect(body.msgtype).toBe('m.text')
70
+ expect(body.body).toBe('reply text')
71
+ expect(body['m.relates_to']).toEqual({
72
+ rel_type: 'm.thread',
73
+ event_id: '$root',
74
+ })
75
+ return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
76
+ })
77
+ const client = new MatrixClient({
78
+ homeserver: 'https://hs.example.com',
79
+ asToken: 'as-secret',
80
+ fetch: fetch as unknown as typeof globalThis.fetch,
81
+ })
82
+ await client.sendMessage({
83
+ roomId: '!r:example.com',
84
+ asUserId: '@architect:example.com',
85
+ content: { msgtype: 'm.text', body: 'reply text' },
86
+ threadRoot: '$root',
87
+ })
88
+ })
89
+
90
+ it('resolveAlias returns the room id when the homeserver knows the alias', async () => {
91
+ const fetch = fakeFetch(async ({ url }) => {
92
+ expect(url).toBe(
93
+ 'https://hs.example.com/_matrix/client/v3/directory/room/%23welcome%3Alocalhost',
94
+ )
95
+ return new Response(JSON.stringify({ room_id: '!abc:localhost' }), { status: 200 })
96
+ })
97
+ const client = new MatrixClient({
98
+ homeserver: 'https://hs.example.com',
99
+ asToken: 'as-secret',
100
+ fetch: fetch as unknown as typeof globalThis.fetch,
101
+ })
102
+ expect(await client.resolveAlias('#welcome:localhost')).toBe('!abc:localhost')
103
+ })
104
+
105
+ it('resolveAlias returns null on M_NOT_FOUND', async () => {
106
+ const fetch = fakeFetch(async () =>
107
+ new Response(JSON.stringify({ errcode: 'M_NOT_FOUND' }), { status: 404 }),
108
+ )
109
+ const client = new MatrixClient({
110
+ homeserver: 'https://hs.example.com',
111
+ asToken: 'as-secret',
112
+ fetch: fetch as unknown as typeof globalThis.fetch,
113
+ })
114
+ expect(await client.resolveAlias('#nope:localhost')).toBeNull()
115
+ })
116
+
117
+ it('createRoom POSTs /createRoom impersonating the sender user', async () => {
118
+ const fetch = fakeFetch(async ({ url, init }) => {
119
+ expect(url).toMatch(
120
+ /\/_matrix\/client\/v3\/createRoom\?user_id=%40admin%3Alocalhost$/,
121
+ )
122
+ expect(init.method).toBe('POST')
123
+ const body = JSON.parse(init.body as string)
124
+ expect(body).toMatchObject({
125
+ room_alias_name: 'welcome',
126
+ preset: 'public_chat',
127
+ invite: ['@admin:localhost'],
128
+ })
129
+ return new Response(JSON.stringify({ room_id: '!new:localhost' }), { status: 200 })
130
+ })
131
+ const client = new MatrixClient({
132
+ homeserver: 'https://hs.example.com',
133
+ asToken: 'as-secret',
134
+ fetch: fetch as unknown as typeof globalThis.fetch,
135
+ })
136
+ const id = await client.createRoom({
137
+ roomAliasName: 'welcome',
138
+ invite: ['@admin:localhost'],
139
+ senderUserId: '@admin:localhost',
140
+ })
141
+ expect(id).toBe('!new:localhost')
142
+ })
143
+
144
+ it('createRoom passes optional name as m.room.name when supplied', async () => {
145
+ const fetch = fakeFetch(async ({ init }) => {
146
+ const body = JSON.parse(init.body as string)
147
+ expect(body.name).toBe('Welcome Aboard')
148
+ return new Response(JSON.stringify({ room_id: '!new:localhost' }), { status: 200 })
149
+ })
150
+ const client = new MatrixClient({
151
+ homeserver: 'https://hs.example.com',
152
+ asToken: 'as-secret',
153
+ fetch: fetch as unknown as typeof globalThis.fetch,
154
+ })
155
+ await client.createRoom({
156
+ roomAliasName: 'welcome',
157
+ invite: [],
158
+ senderUserId: '@admin:localhost',
159
+ name: 'Welcome Aboard',
160
+ })
161
+ })
162
+
163
+ it('setDisplayName PUTs the displayname endpoint impersonating the user', async () => {
164
+ const fetch = fakeFetch(async ({ url, init }) => {
165
+ expect(url).toBe(
166
+ 'https://hs.example.com/_matrix/client/v3/profile/%40docs%3Alocalhost/displayname' +
167
+ '?user_id=%40docs%3Alocalhost',
168
+ )
169
+ expect(init.method).toBe('PUT')
170
+ expect(JSON.parse(init.body as string)).toEqual({ displayname: 'Docs Agent' })
171
+ return new Response('{}', { status: 200 })
172
+ })
173
+ const client = new MatrixClient({
174
+ homeserver: 'https://hs.example.com',
175
+ asToken: 'as-secret',
176
+ fetch: fetch as unknown as typeof globalThis.fetch,
177
+ })
178
+ await client.setDisplayName('@docs:localhost', 'Docs Agent')
179
+ })
180
+
181
+ describe('MatrixClient.setTyping', () => {
182
+ it('PUTs typing=true with timeout to the typing endpoint, impersonating the asUserId', async () => {
183
+ const fetch = fakeFetch(async ({ url, init }) => {
184
+ expect(url).toBe(
185
+ 'https://hs.example.com/_matrix/client/v3/rooms/!r%3Aexample.com/typing/%40architect%3Aexample.com' +
186
+ '?user_id=%40architect%3Aexample.com',
187
+ )
188
+ expect(init.method).toBe('PUT')
189
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer as-secret')
190
+ expect(JSON.parse(init.body as string)).toEqual({ typing: true, timeout: 30000 })
191
+ return new Response('{}', { status: 200 })
192
+ })
193
+ const client = new MatrixClient({
194
+ homeserver: 'https://hs.example.com',
195
+ asToken: 'as-secret',
196
+ fetch: fetch as unknown as typeof globalThis.fetch,
197
+ })
198
+ await client.setTyping({
199
+ roomId: '!r:example.com',
200
+ asUserId: '@architect:example.com',
201
+ typing: true,
202
+ timeoutMs: 30_000,
203
+ })
204
+ })
205
+
206
+ it('PUTs typing=false without timeout to clear', async () => {
207
+ const fetch = fakeFetch(async ({ init }) => {
208
+ expect(JSON.parse(init.body as string)).toEqual({ typing: false })
209
+ return new Response('{}', { status: 200 })
210
+ })
211
+ const client = new MatrixClient({
212
+ homeserver: 'https://hs.example.com',
213
+ asToken: 'as-secret',
214
+ fetch: fetch as unknown as typeof globalThis.fetch,
215
+ })
216
+ await client.setTyping({
217
+ roomId: '!r:example.com',
218
+ asUserId: '@architect:example.com',
219
+ typing: false,
220
+ })
221
+ })
222
+
223
+ it('throws on non-2xx', async () => {
224
+ const fetch = fakeFetch(async () => new Response('boom', { status: 500 }))
225
+ const client = new MatrixClient({
226
+ homeserver: 'https://hs.example.com',
227
+ asToken: 'as-secret',
228
+ fetch: fetch as unknown as typeof globalThis.fetch,
229
+ })
230
+ await expect(
231
+ client.setTyping({ roomId: '!r', asUserId: '@a:x', typing: true }),
232
+ ).rejects.toThrow(/setTyping/)
233
+ })
234
+ })
235
+
236
+ describe('MatrixClient.setPresence', () => {
237
+ it('PUTs presence to the presence/status endpoint, impersonating the asUserId', async () => {
238
+ const fetch = fakeFetch(async ({ url, init }) => {
239
+ expect(url).toBe(
240
+ 'https://hs.example.com/_matrix/client/v3/presence/%40architect%3Aexample.com/status' +
241
+ '?user_id=%40architect%3Aexample.com',
242
+ )
243
+ expect(init.method).toBe('PUT')
244
+ expect(JSON.parse(init.body as string)).toEqual({ presence: 'online' })
245
+ return new Response('{}', { status: 200 })
246
+ })
247
+ const client = new MatrixClient({
248
+ homeserver: 'https://hs.example.com',
249
+ asToken: 'as-secret',
250
+ fetch: fetch as unknown as typeof globalThis.fetch,
251
+ })
252
+ await client.setPresence({ asUserId: '@architect:example.com', presence: 'online' })
253
+ })
254
+
255
+ it('includes status_msg when provided', async () => {
256
+ const fetch = fakeFetch(async ({ init }) => {
257
+ expect(JSON.parse(init.body as string)).toEqual({
258
+ presence: 'unavailable',
259
+ status_msg: 'running prompt',
260
+ })
261
+ return new Response('{}', { status: 200 })
262
+ })
263
+ const client = new MatrixClient({
264
+ homeserver: 'https://hs.example.com',
265
+ asToken: 'as-secret',
266
+ fetch: fetch as unknown as typeof globalThis.fetch,
267
+ })
268
+ await client.setPresence({
269
+ asUserId: '@architect:example.com',
270
+ presence: 'unavailable',
271
+ statusMsg: 'running prompt',
272
+ })
273
+ })
274
+
275
+ it('throws on non-2xx', async () => {
276
+ const fetch = fakeFetch(async () => new Response('nope', { status: 403 }))
277
+ const client = new MatrixClient({
278
+ homeserver: 'https://hs.example.com',
279
+ asToken: 'as-secret',
280
+ fetch: fetch as unknown as typeof globalThis.fetch,
281
+ })
282
+ await expect(
283
+ client.setPresence({ asUserId: '@a:x', presence: 'online' }),
284
+ ).rejects.toThrow(/setPresence/)
285
+ })
286
+ })
287
+
288
+ it('sends a custom event type when content type is set', async () => {
289
+ const fetch = fakeFetch(async ({ url, init }) => {
290
+ expect(url).toMatch(/\/send\/eco\.zoon\.approval_request\//)
291
+ const body = JSON.parse(init.body as string)
292
+ expect(body.approval_id).toBe('a1')
293
+ return new Response(JSON.stringify({ event_id: '$x' }), { status: 200 })
294
+ })
295
+ const client = new MatrixClient({
296
+ homeserver: 'https://hs.example.com',
297
+ asToken: 'as-secret',
298
+ fetch: fetch as unknown as typeof globalThis.fetch,
299
+ })
300
+ await client.sendCustomEvent({
301
+ roomId: '!r:example.com',
302
+ asUserId: '@architect:example.com',
303
+ eventType: 'eco.zoon.approval_request',
304
+ content: { approval_id: 'a1', description: 'Run: git push' },
305
+ })
306
+ })
307
+ })