@zooid/transport-matrix 0.8.0 → 0.9.1
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 +84 -1
- package/dist/index.js +366 -194
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +7 -1
- package/src/bot-pool.ts +8 -2
- package/src/context-provider.ts +1 -1
- package/src/event-encoders.test.ts +22 -0
- package/src/event-encoders.ts +13 -0
- package/src/identity.test.ts +52 -0
- package/src/identity.ts +27 -0
- package/src/index.ts +11 -0
- package/src/matrix-client.test.ts +58 -2
- package/src/matrix-client.ts +51 -0
- package/src/registration.test.ts +20 -0
- package/src/sync-loop.test.ts +113 -0
- package/src/sync-loop.ts +74 -0
- package/src/transport.test.ts +161 -32
- package/src/transport.ts +423 -280
- 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.1",
|
|
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/acp-client": "^0.
|
|
32
|
-
"@zooid/core": "^0.
|
|
31
|
+
"@zooid/acp-client": "^0.9.1",
|
|
32
|
+
"@zooid/core": "^0.9.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@hono/node-server": "^1.13.0",
|
package/src/bot-pool.test.ts
CHANGED
|
@@ -248,8 +248,14 @@ describe('BotPool.bootstrap workforce-space attachment', () => {
|
|
|
248
248
|
],
|
|
249
249
|
)
|
|
250
250
|
await pool.bootstrap({ spaceRoomId: '!space:zoon.local', asUserId: '@zooid:zoon.local' })
|
|
251
|
+
// Created BY the AS sender bot (room owner/admin), never by the agent —
|
|
252
|
+
// agents join as plain members, never room admins.
|
|
251
253
|
expect(createRoom).toHaveBeenCalledWith(
|
|
252
|
-
expect.objectContaining({
|
|
254
|
+
expect.objectContaining({
|
|
255
|
+
roomAliasName: 'design',
|
|
256
|
+
restrictedToSpaceId: '!space:zoon.local',
|
|
257
|
+
senderUserId: '@zooid:zoon.local',
|
|
258
|
+
}),
|
|
253
259
|
)
|
|
254
260
|
})
|
|
255
261
|
|
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.
|
|
@@ -82,7 +82,13 @@ export class BotPool {
|
|
|
82
82
|
} else {
|
|
83
83
|
const colon = room.indexOf(':')
|
|
84
84
|
const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1)
|
|
85
|
-
|
|
85
|
+
// Workstation-created rooms are owned by the AS sender bot (PL
|
|
86
|
+
// 100, already seeded in the power override below). Agents join
|
|
87
|
+
// as plain members and must never be room admins. The human
|
|
88
|
+
// operator is invited + seeded at PL 100, but can't be the
|
|
89
|
+
// creator under a workstation-scoped namespace — the AS token
|
|
90
|
+
// can't impersonate a non-namespaced human.
|
|
91
|
+
const sender = opts.asUserId ?? opts.adminUserId ?? a.userId
|
|
86
92
|
const userPowerLevels = buildUserPowerLevels(
|
|
87
93
|
opts.asUserId,
|
|
88
94
|
opts.adminUserIds,
|
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({
|
|
@@ -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',
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isValidWorkstation,
|
|
4
|
+
isValidAgentKey,
|
|
5
|
+
agentMxid,
|
|
6
|
+
splitAgentLocalpart,
|
|
7
|
+
workstationUserNamespace,
|
|
8
|
+
} from './identity.js'
|
|
9
|
+
|
|
10
|
+
describe('workstation / agent grammar', () => {
|
|
11
|
+
it('accepts dot-free lowercase-alnum-hyphen', () => {
|
|
12
|
+
expect(isValidWorkstation('laptop')).toBe(true)
|
|
13
|
+
expect(isValidWorkstation('ec2-prod')).toBe(true)
|
|
14
|
+
expect(isValidAgentKey('docs')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
it('rejects dots (the workstation/agent separator) and bad chars', () => {
|
|
17
|
+
expect(isValidWorkstation('lap.top')).toBe(false) // dot is the boundary, never inside
|
|
18
|
+
expect(isValidWorkstation('Laptop')).toBe(false) // strict ASCII lowercase
|
|
19
|
+
expect(isValidWorkstation('lap_top')).toBe(false)
|
|
20
|
+
expect(isValidWorkstation('')).toBe(false)
|
|
21
|
+
expect(isValidAgentKey('a.b')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('agentMxid', () => {
|
|
26
|
+
it('builds @{workstation}.{agent}:server', () => {
|
|
27
|
+
expect(agentMxid('laptop', 'assistant', 'zoon.eco')).toBe('@laptop.assistant:zoon.eco')
|
|
28
|
+
})
|
|
29
|
+
it('throws on invalid parts', () => {
|
|
30
|
+
expect(() => agentMxid('lap.top', 'assistant', 'zoon.eco')).toThrow()
|
|
31
|
+
expect(() => agentMxid('laptop', 'a.b', 'zoon.eco')).toThrow()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('splitAgentLocalpart — split on FIRST dot', () => {
|
|
36
|
+
it('recovers (workstation, agent)', () => {
|
|
37
|
+
expect(splitAgentLocalpart('laptop.assistant')).toEqual({
|
|
38
|
+
workstation: 'laptop',
|
|
39
|
+
agent: 'assistant',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
it('throws when there is no separator', () => {
|
|
43
|
+
expect(() => splitAgentLocalpart('zooid')).toThrow()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('workstationUserNamespace', () => {
|
|
48
|
+
it('emits an escaped-dot exclusive regex for the workstation', () => {
|
|
49
|
+
// The literal dot must be escaped so @{workstation}.* does not also match @{workstation}X*
|
|
50
|
+
expect(workstationUserNamespace('laptop', 'zoon.eco')).toBe('@laptop\\..*:zoon.eco')
|
|
51
|
+
})
|
|
52
|
+
})
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// A workstation id (and an agent key) must be "slug-shaped": kebab-case,
|
|
2
|
+
// MXID/alias-safe. SLUG_RE names the *format*; the domain noun is workstation.
|
|
3
|
+
export const SLUG_RE = /^[a-z0-9-]+$/
|
|
4
|
+
export const AGENT_KEY_RE = /^[a-z0-9-]+$/
|
|
5
|
+
|
|
6
|
+
export const isValidWorkstation = (s: string): boolean => SLUG_RE.test(s)
|
|
7
|
+
export const isValidAgentKey = (s: string): boolean => AGENT_KEY_RE.test(s)
|
|
8
|
+
|
|
9
|
+
export function agentMxid(workstation: string, agent: string, serverName: string): string {
|
|
10
|
+
if (!isValidWorkstation(workstation))
|
|
11
|
+
throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
|
|
12
|
+
if (!isValidAgentKey(agent)) throw new Error(`invalid agent key: ${JSON.stringify(agent)}`)
|
|
13
|
+
return `@${workstation}.${agent}:${serverName}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function splitAgentLocalpart(localpart: string): { workstation: string; agent: string } {
|
|
17
|
+
const i = localpart.indexOf('.') // first dot is the boundary; workstation/agent are dot-free
|
|
18
|
+
if (i <= 0 || i === localpart.length - 1)
|
|
19
|
+
throw new Error(`not a workstation.agent localpart: ${localpart}`)
|
|
20
|
+
return { workstation: localpart.slice(0, i), agent: localpart.slice(i + 1) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function workstationUserNamespace(workstation: string, serverName: string): string {
|
|
24
|
+
if (!isValidWorkstation(workstation))
|
|
25
|
+
throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`)
|
|
26
|
+
return `@${workstation}\\..*:${serverName}` // escaped dot — exclusive to this workstation's agents
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,15 @@ export { MatrixContextProvider } from './context-provider.js'
|
|
|
4
4
|
export type { MatrixContextProviderOpts } from './context-provider.js'
|
|
5
5
|
export { renderRegistration } from './registration.js'
|
|
6
6
|
export type { MatrixTransportConfig } from './registration.js'
|
|
7
|
+
export {
|
|
8
|
+
isValidWorkstation,
|
|
9
|
+
isValidAgentKey,
|
|
10
|
+
agentMxid,
|
|
11
|
+
splitAgentLocalpart,
|
|
12
|
+
workstationUserNamespace,
|
|
13
|
+
SLUG_RE,
|
|
14
|
+
AGENT_KEY_RE,
|
|
15
|
+
} from './identity.js'
|
|
7
16
|
export { extractMentions } from './mentions.js'
|
|
8
17
|
export type { MaybeMessage } from './mentions.js'
|
|
9
18
|
export { route, isMediaMsgtype, MEDIA_MSGTYPES } from './router.js'
|
|
@@ -11,6 +20,8 @@ export type { AgentBinding, RouteMatch } from './router.js'
|
|
|
11
20
|
export { BotPool } from './bot-pool.js'
|
|
12
21
|
export { createMatrixTransport } from './transport.js'
|
|
13
22
|
export type { CreateMatrixTransportOptions, MediaClientLike } from './transport.js'
|
|
23
|
+
export { SyncLoop } from './sync-loop.js'
|
|
24
|
+
export type { SyncLoopOptions, SyncResponse, SyncClient } from './sync-loop.js'
|
|
14
25
|
export { ensureDefaultChannel, ensureWorkforceSpace, serverNameFromMxid } from './space-provisioner.js'
|
|
15
26
|
export type { EnsureDefaultChannelOpts, EnsureSpaceOpts } from './space-provisioner.js'
|
|
16
27
|
export {
|
|
@@ -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,62 @@ 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
|
+
|
|
481
|
+
describe('MatrixClient.sync (impersonated)', () => {
|
|
482
|
+
it('GETs /sync with ?user_id=, as_token bearer, since + timeout', async () => {
|
|
483
|
+
const fetch = vi.fn(async () =>
|
|
484
|
+
new Response(JSON.stringify({ next_batch: 's2', rooms: { join: {} } }), { status: 200 }),
|
|
485
|
+
)
|
|
486
|
+
const c = new MatrixClient({
|
|
487
|
+
homeserver: 'https://zoon.eco',
|
|
488
|
+
asToken: 'as-tok',
|
|
489
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
490
|
+
})
|
|
491
|
+
const out = await c.sync({ asUserId: '@laptop.docs:zoon.eco', since: 's1', timeoutMs: 30000 })
|
|
492
|
+
|
|
493
|
+
expect(out.next_batch).toBe('s2')
|
|
494
|
+
const url = new URL(fetch.mock.calls[0]![0] as string)
|
|
495
|
+
expect(url.pathname).toBe('/_matrix/client/v3/sync')
|
|
496
|
+
expect(url.searchParams.get('user_id')).toBe('@laptop.docs:zoon.eco')
|
|
497
|
+
expect(url.searchParams.get('since')).toBe('s1')
|
|
498
|
+
expect(url.searchParams.get('timeout')).toBe('30000')
|
|
499
|
+
const headers = (fetch.mock.calls[0]![1] as RequestInit).headers as Record<string, string>
|
|
500
|
+
expect(headers.Authorization).toBe('Bearer as-tok')
|
|
501
|
+
})
|
|
502
|
+
})
|
|
503
|
+
|
|
448
504
|
describe('MatrixClient.createRoom restricted', () => {
|
|
449
505
|
it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
|
|
450
506
|
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)}` +
|
|
@@ -401,6 +420,38 @@ export class MatrixClient {
|
|
|
401
420
|
return (await r.json()) as { joined: Record<string, { display_name?: string }> }
|
|
402
421
|
}
|
|
403
422
|
|
|
423
|
+
async sync(opts: {
|
|
424
|
+
asUserId: string
|
|
425
|
+
since?: string | null
|
|
426
|
+
timeoutMs?: number
|
|
427
|
+
}): Promise<{
|
|
428
|
+
next_batch: string
|
|
429
|
+
rooms: {
|
|
430
|
+
join: Record<string, {
|
|
431
|
+
timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
|
|
432
|
+
}>
|
|
433
|
+
}
|
|
434
|
+
}> {
|
|
435
|
+
const params = new URLSearchParams({
|
|
436
|
+
user_id: opts.asUserId,
|
|
437
|
+
timeout: String(opts.timeoutMs ?? 30_000),
|
|
438
|
+
})
|
|
439
|
+
if (opts.since) params.set('since', opts.since)
|
|
440
|
+
const url = `${this.homeserver}/_matrix/client/v3/sync?${params.toString()}`
|
|
441
|
+
const r = await this.fetch(url, {
|
|
442
|
+
headers: { Authorization: `Bearer ${this.asToken}` },
|
|
443
|
+
})
|
|
444
|
+
if (!r.ok) throw new Error(`sync(${opts.asUserId}) failed: ${r.status}`)
|
|
445
|
+
return r.json() as Promise<{
|
|
446
|
+
next_batch: string
|
|
447
|
+
rooms: {
|
|
448
|
+
join: Record<string, {
|
|
449
|
+
timeline: { events: Record<string, unknown>[]; prev_batch?: string; limited?: boolean }
|
|
450
|
+
}>
|
|
451
|
+
}
|
|
452
|
+
}>
|
|
453
|
+
}
|
|
454
|
+
|
|
404
455
|
async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
|
|
405
456
|
const url =
|
|
406
457
|
`${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}` +
|
package/src/registration.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parse } from 'yaml'
|
|
2
3
|
import { renderRegistration, type MatrixTransportConfig } from './registration.js'
|
|
3
4
|
|
|
4
5
|
const baseConfig: MatrixTransportConfig = {
|
|
@@ -39,3 +40,22 @@ describe('renderRegistration', () => {
|
|
|
39
40
|
expect(yaml).toContain('rate_limited: false')
|
|
40
41
|
})
|
|
41
42
|
})
|
|
43
|
+
|
|
44
|
+
it('renders a workstation-scoped EXCLUSIVE registration', () => {
|
|
45
|
+
const y = parse(
|
|
46
|
+
renderRegistration({
|
|
47
|
+
id: 'laptop',
|
|
48
|
+
url: 'http://10.0.1.5:9099',
|
|
49
|
+
homeserver: 'https://zoon.eco',
|
|
50
|
+
asToken: 'as-x',
|
|
51
|
+
hsToken: 'hs-x',
|
|
52
|
+
senderLocalpart: 'laptop',
|
|
53
|
+
userNamespace: '@laptop\\..*:zoon.eco',
|
|
54
|
+
exclusive: true,
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
expect(y.sender_localpart).toBe('laptop')
|
|
58
|
+
expect(y.url).toBe('http://10.0.1.5:9099')
|
|
59
|
+
expect(y.namespaces.users[0]).toEqual({ exclusive: true, regex: '@laptop\\..*:zoon.eco' })
|
|
60
|
+
expect(y.namespaces.rooms).toEqual([]) // never fence by room id
|
|
61
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { SyncLoop } from './sync-loop.js'
|
|
3
|
+
|
|
4
|
+
const evt = (id: string, roomId: string) => ({
|
|
5
|
+
type: 'm.room.message',
|
|
6
|
+
event_id: id,
|
|
7
|
+
sender: '@me:zoon.eco',
|
|
8
|
+
room_id: roomId,
|
|
9
|
+
content: { msgtype: 'm.text', body: 'hi' },
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
function fakeClient(pages: unknown[]) {
|
|
13
|
+
let i = 0
|
|
14
|
+
return {
|
|
15
|
+
sync: vi.fn(async () => pages[Math.min(i++, pages.length - 1)]),
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('SyncLoop', () => {
|
|
20
|
+
it('dispatches each timeline event once and persists next_batch', async () => {
|
|
21
|
+
const store = new Map<string, string>()
|
|
22
|
+
const dispatched: string[] = []
|
|
23
|
+
const client = fakeClient([
|
|
24
|
+
{ next_batch: 's1', rooms: { join: { '!r': { timeline: { events: [evt('a', '!r')] } } } } },
|
|
25
|
+
{ next_batch: 's2', rooms: { join: { '!r': { timeline: { events: [evt('b', '!r')] } } } } },
|
|
26
|
+
{ next_batch: 's2', rooms: { join: {} } },
|
|
27
|
+
])
|
|
28
|
+
const loop = new SyncLoop({
|
|
29
|
+
client: client as never,
|
|
30
|
+
asUserId: '@laptop.docs:zoon.eco',
|
|
31
|
+
loadSince: () => store.get('docs') ?? null,
|
|
32
|
+
saveSince: (s) => store.set('docs', s),
|
|
33
|
+
onEvent: (e) => dispatched.push(e.event_id as string),
|
|
34
|
+
})
|
|
35
|
+
await loop.tick()
|
|
36
|
+
await loop.tick()
|
|
37
|
+
expect(dispatched).toEqual(['a', 'b'])
|
|
38
|
+
expect(store.get('docs')).toBe('s2')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('resumes from the persisted since (no reprocessing across restart)', async () => {
|
|
42
|
+
const store = new Map<string, string>([['docs', 's-persisted']])
|
|
43
|
+
const client = fakeClient([{ next_batch: 's3', rooms: { join: {} } }])
|
|
44
|
+
const loop = new SyncLoop({
|
|
45
|
+
client: client as never,
|
|
46
|
+
asUserId: '@laptop.docs:zoon.eco',
|
|
47
|
+
loadSince: () => store.get('docs') ?? null,
|
|
48
|
+
saveSince: (s) => store.set('docs', s),
|
|
49
|
+
onEvent: () => {},
|
|
50
|
+
})
|
|
51
|
+
await loop.tick()
|
|
52
|
+
expect((client.sync as ReturnType<typeof vi.fn>).mock.calls[0][0].since).toBe('s-persisted')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('tolerates an idle sync with no `rooms` key (real homeservers omit it)', async () => {
|
|
56
|
+
const store = new Map<string, string>()
|
|
57
|
+
const dispatched: string[] = []
|
|
58
|
+
// Real Tuwunel returns just { next_batch } on an idle incremental sync.
|
|
59
|
+
const client = fakeClient([{ next_batch: 's-idle' }])
|
|
60
|
+
const loop = new SyncLoop({
|
|
61
|
+
client: client as never,
|
|
62
|
+
asUserId: '@laptop.docs:zoon.eco',
|
|
63
|
+
loadSince: () => store.get('docs') ?? null,
|
|
64
|
+
saveSince: (s) => store.set('docs', s),
|
|
65
|
+
onEvent: (e) => dispatched.push(e.event_id as string),
|
|
66
|
+
})
|
|
67
|
+
await expect(loop.tick()).resolves.toBeUndefined()
|
|
68
|
+
expect(dispatched).toEqual([])
|
|
69
|
+
expect(store.get('docs')).toBe('s-idle') // cursor still advances
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('tolerates a joined room with no `timeline` (state/ephemeral-only update)', async () => {
|
|
73
|
+
const store = new Map<string, string>()
|
|
74
|
+
const dispatched: string[] = []
|
|
75
|
+
// A room can appear in rooms.join carrying only state — no timeline key.
|
|
76
|
+
const client = fakeClient([
|
|
77
|
+
{ next_batch: 's-state', rooms: { join: { '!r': { state: { events: [] } } } } },
|
|
78
|
+
])
|
|
79
|
+
const loop = new SyncLoop({
|
|
80
|
+
client: client as never,
|
|
81
|
+
asUserId: '@laptop.docs:zoon.eco',
|
|
82
|
+
loadSince: () => store.get('docs') ?? null,
|
|
83
|
+
saveSince: (s) => store.set('docs', s),
|
|
84
|
+
onEvent: (e) => dispatched.push(e.event_id as string),
|
|
85
|
+
})
|
|
86
|
+
await expect(loop.tick()).resolves.toBeUndefined()
|
|
87
|
+
expect(dispatched).toEqual([])
|
|
88
|
+
expect(store.get('docs')).toBe('s-state')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('run() survives a throwing tick and keeps going (no daemon crash)', async () => {
|
|
92
|
+
let calls = 0
|
|
93
|
+
let loop!: SyncLoop
|
|
94
|
+
const client = {
|
|
95
|
+
sync: vi.fn(async () => {
|
|
96
|
+
calls++
|
|
97
|
+
if (calls === 1) throw new Error('transient /sync 502')
|
|
98
|
+
loop.stop() // recovered on the 2nd tick — end the loop deterministically
|
|
99
|
+
return { next_batch: 's-ok' }
|
|
100
|
+
}),
|
|
101
|
+
}
|
|
102
|
+
loop = new SyncLoop({
|
|
103
|
+
client: client as never,
|
|
104
|
+
asUserId: '@laptop.docs:zoon.eco',
|
|
105
|
+
loadSince: () => null,
|
|
106
|
+
saveSince: () => {},
|
|
107
|
+
onEvent: () => {},
|
|
108
|
+
retryDelayMs: 1,
|
|
109
|
+
})
|
|
110
|
+
await loop.run() // tick 1 throws → backoff → tick 2 recovers → stop → exits
|
|
111
|
+
expect(calls).toBe(2)
|
|
112
|
+
})
|
|
113
|
+
})
|
package/src/sync-loop.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface SyncResponse {
|
|
2
|
+
next_batch: string
|
|
3
|
+
// Real homeservers omit `rooms` (and `rooms.join`) entirely on an idle
|
|
4
|
+
// incremental sync — both are optional.
|
|
5
|
+
rooms?: {
|
|
6
|
+
join?: Record<string, {
|
|
7
|
+
// A joined room may carry only state/ephemeral/account_data on a given
|
|
8
|
+
// sync, with no `timeline` (or a timeline with no `events`).
|
|
9
|
+
timeline?: {
|
|
10
|
+
events?: Record<string, unknown>[]
|
|
11
|
+
prev_batch?: string
|
|
12
|
+
limited?: boolean
|
|
13
|
+
}
|
|
14
|
+
}>
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SyncClient {
|
|
19
|
+
sync(opts: { asUserId: string; since: string | null; timeoutMs: number }): Promise<SyncResponse>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncLoopOptions {
|
|
23
|
+
client: SyncClient
|
|
24
|
+
asUserId: string
|
|
25
|
+
loadSince: () => string | null
|
|
26
|
+
saveSince: (since: string) => void
|
|
27
|
+
onEvent: (evt: Record<string, unknown>) => void | Promise<void>
|
|
28
|
+
timeoutMs?: number
|
|
29
|
+
/** Backoff after a failed tick before retrying. Default 5000ms. */
|
|
30
|
+
retryDelayMs?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SyncLoop {
|
|
34
|
+
private readonly opts: SyncLoopOptions
|
|
35
|
+
private running = false
|
|
36
|
+
|
|
37
|
+
constructor(opts: SyncLoopOptions) {
|
|
38
|
+
this.opts = opts
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async tick(): Promise<void> {
|
|
42
|
+
const since = this.opts.loadSince()
|
|
43
|
+
const res = await this.opts.client.sync({
|
|
44
|
+
asUserId: this.opts.asUserId,
|
|
45
|
+
since,
|
|
46
|
+
timeoutMs: this.opts.timeoutMs ?? 30_000,
|
|
47
|
+
})
|
|
48
|
+
for (const [roomId, roomState] of Object.entries(res.rooms?.join ?? {})) {
|
|
49
|
+
for (const baseEvt of roomState.timeline?.events ?? []) {
|
|
50
|
+
await this.opts.onEvent({ ...(baseEvt as Record<string, unknown>), room_id: roomId })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.opts.saveSince(res.next_batch)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async run(): Promise<void> {
|
|
57
|
+
this.running = true
|
|
58
|
+
while (this.running) {
|
|
59
|
+
try {
|
|
60
|
+
await this.tick()
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// A transient /sync failure (network blip, sleep/wake, 5xx) must not
|
|
63
|
+
// kill the loop — log and back off, then resume from the same `since`.
|
|
64
|
+
if (!this.running) break
|
|
65
|
+
console.warn(`[sync-loop] ${this.opts.asUserId} tick failed, retrying:`, err)
|
|
66
|
+
await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5_000))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stop(): void {
|
|
72
|
+
this.running = false
|
|
73
|
+
}
|
|
74
|
+
}
|