@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/src/transport.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
INLINE_IMAGE_MIMES,
|
|
20
20
|
} from './media-client.js'
|
|
21
21
|
import { writeAttachment } from './attachments.js'
|
|
22
|
+
import { SyncLoop } from './sync-loop.js'
|
|
22
23
|
|
|
23
24
|
export interface MediaClientLike {
|
|
24
25
|
download(input: {
|
|
@@ -56,6 +57,16 @@ export interface CreateMatrixTransportOptions {
|
|
|
56
57
|
* bindings this forms the set of "our bot users" whose ad-hoc invites are
|
|
57
58
|
* declined. */
|
|
58
59
|
botUserId?: string
|
|
60
|
+
/**
|
|
61
|
+
* Transport ingestion mode.
|
|
62
|
+
* - `'appservice'` (default): Tuwunel pushes events to the HTTP transaction endpoint.
|
|
63
|
+
* - `'client'`: daemon polls via impersonated `/sync` per agent (pull mode).
|
|
64
|
+
*/
|
|
65
|
+
mode?: 'appservice' | 'client'
|
|
66
|
+
/** Pull mode: load the persisted `since` cursor for an agent user ID. */
|
|
67
|
+
loadSince?: (agentUserId: string) => string | null
|
|
68
|
+
/** Pull mode: persist the `since` cursor after each sync poll. */
|
|
69
|
+
saveSince?: (agentUserId: string, since: string) => void
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
interface SessionContext {
|
|
@@ -223,7 +234,7 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
226
|
-
const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId } = opts
|
|
237
|
+
const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = 'appservice' } = opts
|
|
227
238
|
const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
|
|
228
239
|
const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
|
|
229
240
|
const mediaClient = opts.media
|
|
@@ -248,9 +259,13 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
248
259
|
const sendQueue = new Map<string, Promise<void>>()
|
|
249
260
|
// Thread participation index: keyed by thread root event_id.
|
|
250
261
|
const threadStates = new Map<string, ThreadState>()
|
|
251
|
-
// Drop events older than this —
|
|
252
|
-
// daemon was offline, and we don't want yesterday's
|
|
253
|
-
|
|
262
|
+
// Drop events older than this — in push (appservice) mode Tuwunel may replay
|
|
263
|
+
// a backlog after the daemon was offline, and we don't want yesterday's
|
|
264
|
+
// "@docs hi" to fire now. In pull (client) mode the persisted `since` cursor
|
|
265
|
+
// is the authoritative replay boundary (process everything after it — that's
|
|
266
|
+
// exactly the offline-resume feature), so the timestamp guard must NOT apply:
|
|
267
|
+
// the missed-while-offline mention is older than startup by design.
|
|
268
|
+
const cutoffTs = mode === 'client' ? Number.NEGATIVE_INFINITY : Date.now() - STARTUP_GRACE_MS
|
|
254
269
|
// Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
|
|
255
270
|
// the same event_id can arrive twice. Skip ones we've already taken.
|
|
256
271
|
const seenEventIds = new Set<string>()
|
|
@@ -465,263 +480,267 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
465
480
|
})
|
|
466
481
|
})
|
|
467
482
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (!h.startsWith('Bearer ')) return false
|
|
473
|
-
const got = h.slice(7)
|
|
474
|
-
if (got.length !== hsToken.length) return false
|
|
475
|
-
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
479
|
-
if (!authOk(c.req.header('authorization'))) {
|
|
480
|
-
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
481
|
-
}
|
|
482
|
-
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
483
|
-
for (const evt of body.events ?? []) {
|
|
484
|
-
if (evt.event_id) {
|
|
485
|
-
if (seenEventIds.has(evt.event_id)) {
|
|
486
|
-
continue
|
|
487
|
-
}
|
|
488
|
-
seenEventIds.add(evt.event_id)
|
|
489
|
-
if (seenEventIds.size > SEEN_EVENT_CAP) {
|
|
490
|
-
const first = seenEventIds.values().next().value
|
|
491
|
-
if (first !== undefined) seenEventIds.delete(first)
|
|
492
|
-
}
|
|
483
|
+
async function handleInboundEvent(evt: MatrixEvent): Promise<void> {
|
|
484
|
+
if (evt.event_id) {
|
|
485
|
+
if (seenEventIds.has(evt.event_id)) {
|
|
486
|
+
return
|
|
493
487
|
}
|
|
488
|
+
seenEventIds.add(evt.event_id)
|
|
489
|
+
if (seenEventIds.size > SEEN_EVENT_CAP) {
|
|
490
|
+
const first = seenEventIds.values().next().value
|
|
491
|
+
if (first !== undefined) seenEventIds.delete(first)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (
|
|
495
|
+
evt.origin_server_ts !== undefined &&
|
|
496
|
+
evt.origin_server_ts < cutoffTs &&
|
|
497
|
+
evt.type === 'm.room.message'
|
|
498
|
+
) {
|
|
499
|
+
console.log(
|
|
500
|
+
`[matrix] dropping stale message event ${evt.event_id} ` +
|
|
501
|
+
`(ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`,
|
|
502
|
+
)
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
if (evt.type === 'm.room.member' && evt.content?.membership === 'invite') {
|
|
506
|
+
const target = evt.state_key
|
|
507
|
+
const inviter = evt.sender
|
|
494
508
|
if (
|
|
495
|
-
|
|
496
|
-
evt.
|
|
497
|
-
|
|
509
|
+
target &&
|
|
510
|
+
evt.room_id &&
|
|
511
|
+
ourBotUserIds.has(target) &&
|
|
512
|
+
(!inviter || !ourBotUserIds.has(inviter))
|
|
498
513
|
) {
|
|
499
514
|
console.log(
|
|
500
|
-
`[matrix]
|
|
501
|
-
`
|
|
515
|
+
`[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
|
|
516
|
+
`from ${inviter ?? 'unknown'}`,
|
|
502
517
|
)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const inviter = evt.sender
|
|
508
|
-
if (
|
|
509
|
-
target &&
|
|
510
|
-
evt.room_id &&
|
|
511
|
-
ourBotUserIds.has(target) &&
|
|
512
|
-
(!inviter || !ourBotUserIds.has(inviter))
|
|
513
|
-
) {
|
|
514
|
-
console.log(
|
|
515
|
-
`[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
|
|
516
|
-
`from ${inviter ?? 'unknown'}`,
|
|
518
|
+
await client
|
|
519
|
+
.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON })
|
|
520
|
+
.catch((err) =>
|
|
521
|
+
console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err),
|
|
517
522
|
)
|
|
518
|
-
await client
|
|
519
|
-
.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON })
|
|
520
|
-
.catch((err) =>
|
|
521
|
-
console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err),
|
|
522
|
-
)
|
|
523
|
-
}
|
|
524
|
-
continue
|
|
525
523
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
for (const a of bindings) {
|
|
540
|
-
agents.endSession(a.name, threadRoot)
|
|
541
|
-
}
|
|
542
|
-
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
543
|
-
// session memory is wiped — thread-routing state (participants /
|
|
544
|
-
// root-mentions) must survive so the next bare reply still routes to
|
|
545
|
-
// the most-recently-posting agent under the same sessionKey.
|
|
546
|
-
continue
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
if (evt.type === 'dev.zooid.session_reset') {
|
|
527
|
+
// Spec § /clear: room-scope reset is unsupported. Only thread-scoped
|
|
528
|
+
// resets carry a thread relation; drop bare room-level resets silently.
|
|
529
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
530
|
+
| { rel_type?: string; event_id?: string }
|
|
531
|
+
| undefined
|
|
532
|
+
const threadRoot =
|
|
533
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
534
|
+
if (!threadRoot) {
|
|
535
|
+
console.log('[matrix] dropping dev.zooid.session_reset without thread relation')
|
|
536
|
+
return
|
|
547
537
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
})
|
|
538
|
+
console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
|
|
539
|
+
for (const a of bindings) {
|
|
540
|
+
agents.endSession(a.name, threadRoot)
|
|
541
|
+
}
|
|
542
|
+
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
543
|
+
// session memory is wiped — thread-routing state (participants /
|
|
544
|
+
// root-mentions) must survive so the next bare reply still routes to
|
|
545
|
+
// the most-recently-posting agent under the same sessionKey.
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
if (evt.type === 'dev.zooid.interrupt') {
|
|
549
|
+
const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
|
|
550
|
+
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
551
|
+
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
552
|
+
// session whose threadRoot matches.
|
|
553
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
554
|
+
| { rel_type?: string; event_id?: string }
|
|
555
|
+
| undefined
|
|
556
|
+
const threadRoot =
|
|
557
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
558
|
+
if (threadRoot) {
|
|
559
|
+
const targets: Array<{ sessionId: string; agent: string }> = []
|
|
560
|
+
for (const [sessionId, ctx] of sessions) {
|
|
561
|
+
if (ctx.threadRoot === threadRoot) {
|
|
562
|
+
targets.push({ sessionId, agent: ctx.agent.name })
|
|
573
563
|
}
|
|
574
|
-
continue
|
|
575
564
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
565
|
+
for (const t of targets) {
|
|
566
|
+
console.log(
|
|
567
|
+
`[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` +
|
|
568
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
569
|
+
)
|
|
570
|
+
await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
|
|
571
|
+
console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
|
|
572
|
+
})
|
|
584
573
|
}
|
|
585
|
-
|
|
586
|
-
`[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
|
|
587
|
-
(content.reason ? ` reason=${content.reason}` : ''),
|
|
588
|
-
)
|
|
589
|
-
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
590
|
-
console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
|
|
591
|
-
})
|
|
592
|
-
continue
|
|
574
|
+
return
|
|
593
575
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
decision?: string
|
|
599
|
-
option_id?: string
|
|
600
|
-
}
|
|
601
|
-
if (!content.session_id || !content.approval_id || !content.decision) continue
|
|
602
|
-
const decision = content.option_id
|
|
603
|
-
? { decision: content.decision, optionId: content.option_id }
|
|
604
|
-
: { decision: content.decision }
|
|
605
|
-
const ok = approvals.resolve(
|
|
606
|
-
content.session_id,
|
|
607
|
-
content.approval_id,
|
|
608
|
-
decision as never,
|
|
609
|
-
)
|
|
610
|
-
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
611
|
-
continue
|
|
576
|
+
// Legacy form: explicit session_id in content.
|
|
577
|
+
if (!content.session_id) {
|
|
578
|
+
console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`)
|
|
579
|
+
return
|
|
612
580
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (
|
|
617
|
-
evt.type === 'm.room.message' &&
|
|
618
|
-
isMediaMsgtype(evt.content?.msgtype) &&
|
|
619
|
-
evt.room_id &&
|
|
620
|
-
evt.event_id &&
|
|
621
|
-
evt.sender &&
|
|
622
|
-
evt.content?.url &&
|
|
623
|
-
!bindings.some((b) => b.userId === evt.sender)
|
|
624
|
-
) {
|
|
625
|
-
pendingMedia.add(evt.room_id, inboundThreadRoot(evt), {
|
|
626
|
-
eventId: evt.event_id,
|
|
627
|
-
sender: evt.sender,
|
|
628
|
-
msgtype: evt.content.msgtype as string,
|
|
629
|
-
body: (evt.content.body as string | undefined) ?? '',
|
|
630
|
-
filename: evt.content.filename as string | undefined,
|
|
631
|
-
url: evt.content.url as string,
|
|
632
|
-
info: evt.content.info as PendingMediaItem['info'],
|
|
633
|
-
})
|
|
634
|
-
continue
|
|
581
|
+
const ctx = sessions.get(content.session_id)
|
|
582
|
+
if (!ctx) {
|
|
583
|
+
return
|
|
635
584
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
|
|
652
|
-
threadStates.set(inboundRel, rebuilt)
|
|
653
|
-
console.log(
|
|
654
|
-
`[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
|
|
655
|
-
)
|
|
656
|
-
} catch (err) {
|
|
657
|
-
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
658
|
-
}
|
|
585
|
+
console.log(
|
|
586
|
+
`[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
|
|
587
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
588
|
+
)
|
|
589
|
+
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
590
|
+
console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
|
|
591
|
+
})
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
if (evt.type === 'dev.zooid.approval_response') {
|
|
595
|
+
const content = (evt.content ?? {}) as {
|
|
596
|
+
approval_id?: string
|
|
597
|
+
session_id?: string
|
|
598
|
+
decision?: string
|
|
599
|
+
option_id?: string
|
|
659
600
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
601
|
+
if (!content.session_id || !content.approval_id || !content.decision) return
|
|
602
|
+
const decision = content.option_id
|
|
603
|
+
? { decision: content.decision, optionId: content.option_id }
|
|
604
|
+
: { decision: content.decision }
|
|
605
|
+
const ok = approvals.resolve(
|
|
606
|
+
content.session_id,
|
|
607
|
+
content.approval_id,
|
|
608
|
+
decision as never,
|
|
609
|
+
)
|
|
610
|
+
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
logInbound(evt)
|
|
614
|
+
|
|
615
|
+
// Capture media events in the pending store; never route them to agents.
|
|
616
|
+
if (
|
|
617
|
+
evt.type === 'm.room.message' &&
|
|
618
|
+
isMediaMsgtype(evt.content?.msgtype) &&
|
|
619
|
+
evt.room_id &&
|
|
620
|
+
evt.event_id &&
|
|
621
|
+
evt.sender &&
|
|
622
|
+
evt.content?.url &&
|
|
623
|
+
!bindings.some((b) => b.userId === evt.sender)
|
|
624
|
+
) {
|
|
625
|
+
pendingMedia.add(evt.room_id, inboundThreadRoot(evt), {
|
|
626
|
+
eventId: evt.event_id,
|
|
627
|
+
sender: evt.sender,
|
|
628
|
+
msgtype: evt.content.msgtype as string,
|
|
629
|
+
body: (evt.content.body as string | undefined) ?? '',
|
|
630
|
+
filename: evt.content.filename as string | undefined,
|
|
631
|
+
url: evt.content.url as string,
|
|
632
|
+
info: evt.content.info as PendingMediaItem['info'],
|
|
633
|
+
})
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Agent-promotion: top-level inbound event becomes the thread root.
|
|
638
|
+
// For in-thread messages the existing root is preserved.
|
|
639
|
+
const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
640
|
+
// Self-heal: if this is a thread reply but we have no in-memory state
|
|
641
|
+
// for the root (e.g. daemon was just restarted), reconstruct it by
|
|
642
|
+
// fetching the thread root + relations from the server.
|
|
643
|
+
const inboundRel = inboundThreadRoot(evt)
|
|
644
|
+
if (
|
|
645
|
+
evt.type === 'm.room.message' &&
|
|
646
|
+
inboundRel &&
|
|
647
|
+
!threadStates.has(inboundRel) &&
|
|
648
|
+
evt.room_id
|
|
649
|
+
) {
|
|
650
|
+
try {
|
|
651
|
+
const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
|
|
652
|
+
threadStates.set(inboundRel, rebuilt)
|
|
653
|
+
console.log(
|
|
654
|
+
`[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
|
|
667
655
|
)
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
668
658
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
659
|
+
}
|
|
660
|
+
const matches = route(evt, bindings, threadStates)
|
|
661
|
+
// Suppress the no-match warning for events sent by our own bots.
|
|
662
|
+
const senderIsBot = bindings.some((b) => b.userId === evt.sender)
|
|
663
|
+
if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
|
|
664
|
+
console.warn(
|
|
665
|
+
`[matrix] no agent matched message in ${evt.room_id} from ${evt.sender}` +
|
|
666
|
+
` (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(', ')})`,
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
// Seed thread state for any agent mentions in this event.
|
|
670
|
+
if (matches.length > 0 && promotedRoot) {
|
|
671
|
+
let st = threadStates.get(promotedRoot)
|
|
672
|
+
if (!st) {
|
|
673
|
+
st = { participants: [], rootMentions: [] }
|
|
674
|
+
threadStates.set(promotedRoot, st)
|
|
682
675
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
.
|
|
687
|
-
|
|
688
|
-
let st = threadStates.get(promotedRoot)
|
|
689
|
-
if (!st) {
|
|
690
|
-
st = { participants: [], rootMentions: [] }
|
|
691
|
-
threadStates.set(promotedRoot, st)
|
|
692
|
-
}
|
|
693
|
-
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
694
|
-
})
|
|
695
|
-
.catch((err) => {
|
|
696
|
-
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
697
|
-
const c = classify(err)
|
|
698
|
-
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
699
|
-
if (!threadRoot || !evt.room_id) return
|
|
700
|
-
const body = toErrorBody(
|
|
701
|
-
{
|
|
702
|
-
kind: 'error',
|
|
703
|
-
agentId: a.name,
|
|
704
|
-
sessionId: null,
|
|
705
|
-
turnId: null,
|
|
706
|
-
code: c.code,
|
|
707
|
-
message: err instanceof Error ? err.message : String(err),
|
|
708
|
-
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
709
|
-
transient: c.transient,
|
|
710
|
-
acp_error: c.acp_error,
|
|
711
|
-
},
|
|
712
|
-
threadRoot,
|
|
713
|
-
)
|
|
714
|
-
void client
|
|
715
|
-
.sendCustomEvent({
|
|
716
|
-
roomId: evt.room_id,
|
|
717
|
-
asUserId: a.userId,
|
|
718
|
-
eventType: 'dev.zooid.error',
|
|
719
|
-
content: body,
|
|
720
|
-
})
|
|
721
|
-
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
722
|
-
})
|
|
676
|
+
const msgMentions = new Set(extractMentions(evt as never))
|
|
677
|
+
for (const a of bindings) {
|
|
678
|
+
if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
|
|
679
|
+
st.rootMentions.push(a.name)
|
|
680
|
+
}
|
|
723
681
|
}
|
|
724
682
|
}
|
|
683
|
+
for (const a of matches) {
|
|
684
|
+
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
685
|
+
void runTurn(a, evt)
|
|
686
|
+
.then(() => {
|
|
687
|
+
if (!promotedRoot) return
|
|
688
|
+
let st = threadStates.get(promotedRoot)
|
|
689
|
+
if (!st) {
|
|
690
|
+
st = { participants: [], rootMentions: [] }
|
|
691
|
+
threadStates.set(promotedRoot, st)
|
|
692
|
+
}
|
|
693
|
+
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
694
|
+
})
|
|
695
|
+
.catch((err) => {
|
|
696
|
+
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
697
|
+
const c = classify(err)
|
|
698
|
+
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
699
|
+
if (!threadRoot || !evt.room_id) return
|
|
700
|
+
const body = toErrorBody(
|
|
701
|
+
{
|
|
702
|
+
kind: 'error',
|
|
703
|
+
agentId: a.name,
|
|
704
|
+
sessionId: null,
|
|
705
|
+
turnId: null,
|
|
706
|
+
code: c.code,
|
|
707
|
+
message: err instanceof Error ? err.message : String(err),
|
|
708
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
709
|
+
transient: c.transient,
|
|
710
|
+
acp_error: c.acp_error,
|
|
711
|
+
},
|
|
712
|
+
threadRoot,
|
|
713
|
+
)
|
|
714
|
+
void client
|
|
715
|
+
.sendCustomEvent({
|
|
716
|
+
roomId: evt.room_id,
|
|
717
|
+
asUserId: a.userId,
|
|
718
|
+
eventType: 'dev.zooid.error',
|
|
719
|
+
content: body,
|
|
720
|
+
})
|
|
721
|
+
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const app = new Hono()
|
|
727
|
+
|
|
728
|
+
function authOk(authHeader: string | undefined): boolean {
|
|
729
|
+
const h = authHeader ?? ''
|
|
730
|
+
if (!h.startsWith('Bearer ')) return false
|
|
731
|
+
const got = h.slice(7)
|
|
732
|
+
if (got.length !== hsToken.length) return false
|
|
733
|
+
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
737
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
738
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
739
|
+
}
|
|
740
|
+
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
741
|
+
for (const evt of body.events ?? []) {
|
|
742
|
+
await handleInboundEvent(evt)
|
|
743
|
+
}
|
|
725
744
|
return c.json({})
|
|
726
745
|
})
|
|
727
746
|
|
|
@@ -864,8 +883,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
864
883
|
}
|
|
865
884
|
}
|
|
866
885
|
|
|
886
|
+
const syncLoops: SyncLoop[] | undefined =
|
|
887
|
+
mode === 'client'
|
|
888
|
+
? bindings.map(
|
|
889
|
+
(b) =>
|
|
890
|
+
new SyncLoop({
|
|
891
|
+
client: client as never,
|
|
892
|
+
asUserId: b.userId,
|
|
893
|
+
loadSince: () => opts.loadSince?.(b.userId) ?? null,
|
|
894
|
+
saveSince: (since) => opts.saveSince?.(b.userId, since),
|
|
895
|
+
onEvent: (evt) => handleInboundEvent(evt as MatrixEvent),
|
|
896
|
+
}),
|
|
897
|
+
)
|
|
898
|
+
: undefined
|
|
899
|
+
|
|
867
900
|
return {
|
|
868
901
|
app,
|
|
902
|
+
syncLoops,
|
|
869
903
|
bootstrap: async (
|
|
870
904
|
bootstrapOpts: {
|
|
871
905
|
spaceRoomId?: string
|