@zooid/transport-matrix 0.9.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 +77 -1
- package/dist/index.js +269 -171
- 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 +7 -1
- 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 +23 -0
- package/src/matrix-client.ts +32 -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.ts +275 -241
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/transport-matrix",
|
|
3
|
-
"version": "0.9.
|
|
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/
|
|
32
|
-
"@zooid/
|
|
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
|
@@ -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,
|
|
@@ -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 {
|
|
@@ -478,6 +478,29 @@ describe('MatrixClient.leaveRoom', () => {
|
|
|
478
478
|
})
|
|
479
479
|
})
|
|
480
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
|
+
|
|
481
504
|
describe('MatrixClient.createRoom restricted', () => {
|
|
482
505
|
it('injects a restricted join rule referencing the space when restrictedToSpaceId is set', async () => {
|
|
483
506
|
const fetch = fakeFetch(async ({ url, init }) => {
|
package/src/matrix-client.ts
CHANGED
|
@@ -420,6 +420,38 @@ export class MatrixClient {
|
|
|
420
420
|
return (await r.json()) as { joined: Record<string, { display_name?: string }> }
|
|
421
421
|
}
|
|
422
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
|
+
|
|
423
455
|
async fetchRoomName(roomId: string, asUserId: string): Promise<string | null> {
|
|
424
456
|
const url =
|
|
425
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
|
+
}
|