@zooid/transport-matrix 0.9.0 → 0.10.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.
- package/dist/index.d.ts +85 -1
- package/dist/index.js +312 -197
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/bot-pool.test.ts +130 -1
- package/src/bot-pool.ts +27 -9
- 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 +66 -0
- package/src/matrix-client.ts +40 -1
- package/src/registration.test.ts +20 -0
- package/src/router.test.ts +117 -1
- package/src/router.ts +25 -7
- package/src/sync-loop.test.ts +113 -0
- package/src/sync-loop.ts +74 -0
- package/src/transport.test.ts +107 -1
- package/src/transport.ts +290 -251
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,269 @@ 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: [], callers: {} }
|
|
674
|
+
threadStates.set(promotedRoot, st)
|
|
682
675
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
+
const senderAgent = bindings.find((b) => b.userId === evt.sender)
|
|
678
|
+
for (const a of bindings) {
|
|
679
|
+
if (!msgMentions.has(a.userId)) continue
|
|
680
|
+
if (!st.rootMentions.includes(a.name)) st.rootMentions.push(a.name)
|
|
681
|
+
// Call edge: the (agent) sender is the caller of every agent it @mentions.
|
|
682
|
+
if (senderAgent && a.name !== senderAgent.name) st.callers[a.name] = senderAgent.name
|
|
723
683
|
}
|
|
724
684
|
}
|
|
685
|
+
for (const a of matches) {
|
|
686
|
+
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
687
|
+
void runTurn(a, evt)
|
|
688
|
+
.then(() => {
|
|
689
|
+
if (!promotedRoot) return
|
|
690
|
+
let st = threadStates.get(promotedRoot)
|
|
691
|
+
if (!st) {
|
|
692
|
+
st = { participants: [], rootMentions: [], callers: {} }
|
|
693
|
+
threadStates.set(promotedRoot, st)
|
|
694
|
+
}
|
|
695
|
+
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
696
|
+
})
|
|
697
|
+
.catch((err) => {
|
|
698
|
+
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
699
|
+
const c = classify(err)
|
|
700
|
+
const threadRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
701
|
+
if (!threadRoot || !evt.room_id) return
|
|
702
|
+
const body = toErrorBody(
|
|
703
|
+
{
|
|
704
|
+
kind: 'error',
|
|
705
|
+
agentId: a.name,
|
|
706
|
+
sessionId: null,
|
|
707
|
+
turnId: null,
|
|
708
|
+
code: c.code,
|
|
709
|
+
message: err instanceof Error ? err.message : String(err),
|
|
710
|
+
detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
|
|
711
|
+
transient: c.transient,
|
|
712
|
+
acp_error: c.acp_error,
|
|
713
|
+
},
|
|
714
|
+
threadRoot,
|
|
715
|
+
)
|
|
716
|
+
void client
|
|
717
|
+
.sendCustomEvent({
|
|
718
|
+
roomId: evt.room_id,
|
|
719
|
+
asUserId: a.userId,
|
|
720
|
+
eventType: 'dev.zooid.error',
|
|
721
|
+
content: body,
|
|
722
|
+
})
|
|
723
|
+
.catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const app = new Hono()
|
|
729
|
+
|
|
730
|
+
function authOk(authHeader: string | undefined): boolean {
|
|
731
|
+
const h = authHeader ?? ''
|
|
732
|
+
if (!h.startsWith('Bearer ')) return false
|
|
733
|
+
const got = h.slice(7)
|
|
734
|
+
if (got.length !== hsToken.length) return false
|
|
735
|
+
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
739
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
740
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
741
|
+
}
|
|
742
|
+
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
743
|
+
for (const evt of body.events ?? []) {
|
|
744
|
+
await handleInboundEvent(evt)
|
|
745
|
+
}
|
|
725
746
|
return c.json({})
|
|
726
747
|
})
|
|
727
748
|
|
|
@@ -864,8 +885,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
|
864
885
|
}
|
|
865
886
|
}
|
|
866
887
|
|
|
888
|
+
const syncLoops: SyncLoop[] | undefined =
|
|
889
|
+
mode === 'client'
|
|
890
|
+
? bindings.map(
|
|
891
|
+
(b) =>
|
|
892
|
+
new SyncLoop({
|
|
893
|
+
client: client as never,
|
|
894
|
+
asUserId: b.userId,
|
|
895
|
+
loadSince: () => opts.loadSince?.(b.userId) ?? null,
|
|
896
|
+
saveSince: (since) => opts.saveSince?.(b.userId, since),
|
|
897
|
+
onEvent: (evt) => handleInboundEvent(evt as MatrixEvent),
|
|
898
|
+
}),
|
|
899
|
+
)
|
|
900
|
+
: undefined
|
|
901
|
+
|
|
867
902
|
return {
|
|
868
903
|
app,
|
|
904
|
+
syncLoops,
|
|
869
905
|
bootstrap: async (
|
|
870
906
|
bootstrapOpts: {
|
|
871
907
|
spaceRoomId?: string
|
|
@@ -898,7 +934,7 @@ export async function rebuildThreadState(
|
|
|
898
934
|
rootEventId: string,
|
|
899
935
|
bindings: AgentBinding[],
|
|
900
936
|
): Promise<ThreadState> {
|
|
901
|
-
const state: ThreadState = { participants: [], rootMentions: [] }
|
|
937
|
+
const state: ThreadState = { participants: [], rootMentions: [], callers: {} }
|
|
902
938
|
// Impersonate an agent that's actually a member of this room (AS reads
|
|
903
939
|
// require room membership). Falling through to the first binding would
|
|
904
940
|
// 403 if that agent never joined the target room.
|
|
@@ -908,10 +944,12 @@ export async function rebuildThreadState(
|
|
|
908
944
|
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
909
945
|
if (root) {
|
|
910
946
|
const rootMentions = new Set(extractMentions(root as never))
|
|
947
|
+
const rootSender = (root as { sender?: string }).sender
|
|
948
|
+
const rootSenderAgent = rootSender ? bindings.find((b) => b.userId === rootSender) : undefined
|
|
911
949
|
for (const a of bindings) {
|
|
912
|
-
if (rootMentions.has(a.userId)
|
|
913
|
-
|
|
914
|
-
|
|
950
|
+
if (!rootMentions.has(a.userId)) continue
|
|
951
|
+
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
952
|
+
if (rootSenderAgent && a.name !== rootSenderAgent.name) state.callers[a.name] = rootSenderAgent.name
|
|
915
953
|
}
|
|
916
954
|
}
|
|
917
955
|
|
|
@@ -923,15 +961,16 @@ export async function rebuildThreadState(
|
|
|
923
961
|
// Also seed root-mentions from any subsequent agent @mentions in the thread.
|
|
924
962
|
for (const ev of thread) {
|
|
925
963
|
const mentions = new Set(extractMentions(ev as never))
|
|
964
|
+
const evSender = (ev as { sender?: string }).sender
|
|
965
|
+
const evSenderAgent = evSender ? bindings.find((b) => b.userId === evSender) : undefined
|
|
926
966
|
for (const a of bindings) {
|
|
927
|
-
if (mentions.has(a.userId)
|
|
928
|
-
|
|
929
|
-
|
|
967
|
+
if (!mentions.has(a.userId)) continue
|
|
968
|
+
if (!state.rootMentions.includes(a.name)) state.rootMentions.push(a.name)
|
|
969
|
+
if (evSenderAgent && a.name !== evSenderAgent.name) state.callers[a.name] = evSenderAgent.name
|
|
930
970
|
}
|
|
931
|
-
const sender = (ev as { sender?: string }).sender
|
|
932
971
|
const type = (ev as { type?: string }).type
|
|
933
|
-
if (type === 'm.room.message' &&
|
|
934
|
-
const a = bindings.find((b) => b.userId ===
|
|
972
|
+
if (type === 'm.room.message' && evSender) {
|
|
973
|
+
const a = bindings.find((b) => b.userId === evSender)
|
|
935
974
|
if (a && state.participants.at(-1) !== a.name) state.participants.push(a.name)
|
|
936
975
|
}
|
|
937
976
|
}
|