From 2f57931f8a4d28f954041dcf2720e78c4505017b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 16:41:34 -0500 Subject: [PATCH] Matrix-js: fix user verification chat flow --- docs/channels/matrix-js.md | 7 +- .../src/matrix/monitor/events.test.ts | 39 +++-- .../matrix-js/src/matrix/monitor/events.ts | 153 +++++++++--------- .../matrix/sdk/verification-manager.test.ts | 65 +++++++- .../src/matrix/sdk/verification-manager.ts | 28 ++++ 5 files changed, 198 insertions(+), 94 deletions(-) diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md index 76fc875a2b9..5ae4a0174e8 100644 --- a/docs/channels/matrix-js.md +++ b/docs/channels/matrix-js.md @@ -198,16 +198,17 @@ openclaw matrix-js verify backup restore --verbose All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`. Use `--json` for full machine-readable output when scripting. -## Automatic verification routing +## Automatic verification notices -Matrix-js automatically routes verification lifecycle updates to the agent as normal inbound messages. +Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages. That includes: - verification request notices - verification start and completion notices - SAS details (emoji and decimal) when available -This means an agent can guide users through verification directly in chat without ad hoc harness scripts. +Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match" +in their Matrix client, verification completes without requiring a manual OpenClaw tool step. ## DM and room policy example diff --git a/extensions/matrix-js/src/matrix/monitor/events.test.ts b/extensions/matrix-js/src/matrix/monitor/events.test.ts index e686861caac..ac9cf4862c9 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.test.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.test.ts @@ -22,11 +22,13 @@ function createHarness(params?: { const listeners = new Map void>(); const onRoomMessage = vi.fn(async () => {}); const listVerifications = vi.fn(async () => params?.verifications ?? []); + const sendMessage = vi.fn(async () => "$notice"); const client = { on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { listeners.set(eventName, listener); return client; }), + sendMessage, crypto: { listVerifications, }, @@ -50,15 +52,20 @@ function createHarness(params?: { return { onRoomMessage, + sendMessage, roomEventListener, listVerifications, + roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, }; } describe("registerMatrixMonitorEvents verification routing", () => { - it("routes verification request events into synthetic room messages", async () => { - const { onRoomMessage, roomEventListener } = createHarness(); - roomEventListener("!room:example.org", { + it("posts verification request notices directly into the room", async () => { + const { onRoomMessage, sendMessage, roomMessageListener } = createHarness(); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + roomMessageListener("!room:example.org", { event_id: "$req1", sender: "@alice:example.org", type: EventType.RoomMessage, @@ -70,17 +77,15 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledTimes(1); }); - const routed = onRoomMessage.mock.calls[0]?.[1] as MatrixRawEvent | undefined; - expect(routed?.type).toBe(EventType.RoomMessage); - expect((routed?.content as { body?: string }).body).toContain( - "Matrix verification request received from @alice:example.org.", - ); + expect(onRoomMessage).not.toHaveBeenCalled(); + const body = (sendMessage.mock.calls[0]?.[1] as { body?: string } | undefined)?.body ?? ""; + expect(body).toContain("Matrix verification request received from @alice:example.org."); }); - it("routes SAS emoji/decimal details when verification summaries expose them", async () => { - const { onRoomMessage, roomEventListener } = createHarness({ + it("posts SAS emoji/decimal details when verification summaries expose them", async () => { + const { sendMessage, roomEventListener } = createHarness({ verifications: [ { id: "verification-1", @@ -110,8 +115,8 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await vi.waitFor(() => { - const bodies = onRoomMessage.mock.calls.map( - (call) => ((call[1] as MatrixRawEvent).content as { body?: string }).body ?? "", + const bodies = sendMessage.mock.calls.map((call) => + ((call[1] as { body?: string } | undefined)?.body ?? "").toString(), ); expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); @@ -119,7 +124,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); it("does not emit duplicate SAS notices for the same verification payload", async () => { - const { onRoomMessage, roomEventListener } = createHarness({ + const { sendMessage, roomEventListener } = createHarness({ verifications: [ { id: "verification-3", @@ -148,7 +153,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { }, }); await vi.waitFor(() => { - expect(onRoomMessage.mock.calls.length).toBeGreaterThan(0); + expect(sendMessage.mock.calls.length).toBeGreaterThan(0); }); roomEventListener("!room:example.org", { @@ -162,8 +167,8 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); await new Promise((resolve) => setTimeout(resolve, 20)); - const sasBodies = onRoomMessage.mock.calls - .map((call) => ((call[1] as MatrixRawEvent).content as { body?: string }).body ?? "") + const sasBodies = sendMessage.mock.calls + .map((call) => ((call[1] as { body?: string } | undefined)?.body ?? "").toString()) .filter((body) => body.includes("SAS emoji:")); expect(sasBodies).toHaveLength(1); }); diff --git a/extensions/matrix-js/src/matrix/monitor/events.ts b/extensions/matrix-js/src/matrix/monitor/events.ts index 07889811436..51c29adece8 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.ts @@ -120,29 +120,6 @@ function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): st return lines.join("\n"); } -function createSyntheticVerificationMessage(params: { - senderId: string; - sourceEventId: string | null; - body: string; - originServerTs?: number; -}): MatrixRawEvent { - const { senderId, sourceEventId, body, originServerTs } = params; - const safeEventId = sourceEventId?.replace(/[^A-Za-z0-9_.=-]/g, "_") ?? "unknown"; - return { - event_id: `mxjs-verification-${safeEventId}-${Date.now().toString(36)}`, - sender: senderId, - type: EventType.RoomMessage, - origin_server_ts: originServerTs ?? Date.now(), - content: { - msgtype: "m.notice", - body, - }, - unsigned: { - age: 0, - }, - }; -} - async function resolveVerificationSummaryByFlowId( client: MatrixClient, flowId: string | null, @@ -169,6 +146,28 @@ function trackBounded(set: Set, value: string): boolean { return true; } +async function sendVerificationNotice(params: { + client: MatrixClient; + roomId: string; + body: string; + logVerboseMessage: (message: string) => void; +}): Promise { + const roomId = trimMaybeString(params.roomId); + if (!roomId) { + return; + } + try { + await params.client.sendMessage(roomId, { + msgtype: "m.notice", + body: params.body, + }); + } catch (err) { + params.logVerboseMessage( + `matrix: failed sending verification notice room=${roomId}: ${String(err)}`, + ); + } +} + export function registerMatrixMonitorEvents(params: { client: MatrixClient; auth: MatrixAuth; @@ -192,7 +191,63 @@ export function registerMatrixMonitorEvents(params: { const routedVerificationEvents = new Set(); const routedVerificationSasFingerprints = new Set(); - client.on("room.message", onRoomMessage); + const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => { + const senderId = trimMaybeString(event?.sender); + if (!senderId) { + return false; + } + const signal = readVerificationSignal(event); + if (!signal) { + return false; + } + + void (async () => { + const flowId = signal.flowId; + const sourceEventId = trimMaybeString(event?.event_id); + const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; + if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { + return; + } + + const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); + const summary = await resolveVerificationSummaryByFlowId(client, flowId).catch(() => null); + const sasNotice = summary ? formatVerificationSasNotice(summary) : null; + + const notices: string[] = []; + if (stageNotice) { + notices.push(stageNotice); + } + if (summary && sasNotice) { + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + notices.push(sasNotice); + } + } + if (notices.length === 0) { + return; + } + + for (const body of notices) { + await sendVerificationNotice({ + client, + roomId, + body, + logVerboseMessage, + }); + } + })().catch((err) => { + logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); + }); + + return true; + }; + + client.on("room.message", (roomId: string, event: MatrixRawEvent) => { + if (routeVerificationEvent(roomId, event)) { + return; + } + void onRoomMessage(roomId, event); + }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { const eventId = event?.event_id ?? "unknown"; @@ -265,54 +320,6 @@ export function registerMatrixMonitorEvents(params: { ); } - const senderId = trimMaybeString(event?.sender); - if (!senderId) { - return; - } - const signal = readVerificationSignal(event); - if (!signal) { - return; - } - - void (async () => { - const flowId = signal.flowId; - const sourceEventId = trimMaybeString(event?.event_id); - const sourceFingerprint = sourceEventId ?? `${senderId}:${eventType}:${flowId ?? "none"}`; - if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { - return; - } - - const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); - const summary = await resolveVerificationSummaryByFlowId(client, flowId).catch(() => null); - const sasNotice = summary ? formatVerificationSasNotice(summary) : null; - - const notices: string[] = []; - if (stageNotice) { - notices.push(stageNotice); - } - if (summary && sasNotice) { - const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; - if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { - notices.push(sasNotice); - } - } - if (notices.length === 0) { - return; - } - - for (const body of notices) { - await onRoomMessage( - roomId, - createSyntheticVerificationMessage({ - senderId, - sourceEventId, - body, - originServerTs: event.origin_server_ts, - }), - ); - } - })().catch((err) => { - logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); - }); + routeVerificationEvent(roomId, event); }); } diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts index 883d43cbbd6..2944321ddc7 100644 --- a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts @@ -165,7 +165,7 @@ describe("MatrixVerificationManager", () => { expect(sas.emoji?.length).toBe(3); await manager.confirmVerificationSas(tracked.id); - expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledTimes(2); manager.mismatchVerificationSas(tracked.id); expect(mismatch).toHaveBeenCalledTimes(1); @@ -256,6 +256,69 @@ describe("MatrixVerificationManager", () => { expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]); }); + it("auto-confirms inbound SAS when callbacks are available", async () => { + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-auto-confirm", + initiatedByMe: false, + verifier, + }); + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await vi.waitFor(() => { + expect(confirm).toHaveBeenCalledTimes(1); + }); + }); + + it("does not auto-confirm SAS for verifications initiated by this device", async () => { + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "Cat"], + ["dog", "Dog"], + ["fox", "Fox"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-no-auto-confirm", + initiatedByMe: true, + verifier, + }); + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(confirm).not.toHaveBeenCalled(); + }); + it("prunes stale terminal sessions during list operations", () => { const now = new Date("2026-02-08T15:00:00.000Z").getTime(); const nowSpy = vi.spyOn(Date, "now"); diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.ts b/extensions/matrix-js/src/matrix/sdk/verification-manager.ts index 6638bb6fa7b..8cff8a07954 100644 --- a/extensions/matrix-js/src/matrix/sdk/verification-manager.ts +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.ts @@ -101,6 +101,7 @@ type MatrixVerificationSession = { verifyPromise?: Promise; verifyStarted: boolean; startRequested: boolean; + sasAutoConfirmStarted: boolean; sasCallbacks?: MatrixShowSasCallbacks; reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; }; @@ -304,6 +305,7 @@ export class MatrixVerificationManager { const maybeSas = verifier.getShowSasCallbacks(); if (maybeSas) { session.sasCallbacks = maybeSas; + this.maybeAutoConfirmSas(session); } const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); if (maybeReciprocateQr) { @@ -320,6 +322,7 @@ export class MatrixVerificationManager { verifier.on(VerifierEvent.ShowSas, (sas) => { session.sasCallbacks = sas as MatrixShowSasCallbacks; this.touchVerificationSession(session); + this.maybeAutoConfirmSas(session); }); verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => { session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks; @@ -332,6 +335,30 @@ export class MatrixVerificationManager { this.ensureVerificationStarted(session); } + private maybeAutoConfirmSas(session: MatrixVerificationSession): void { + if (session.sasAutoConfirmStarted) { + return; + } + if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) { + return; + } + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + return; + } + session.sasCallbacks = callbacks; + session.sasAutoConfirmStarted = true; + void callbacks + .confirm() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + private ensureVerificationStarted(session: MatrixVerificationSession): void { if (!session.activeVerifier || session.verifyStarted) { return; @@ -381,6 +408,7 @@ export class MatrixVerificationManager { updatedAtMs: now, verifyStarted: false, startRequested: false, + sasAutoConfirmStarted: false, }; this.verificationSessions.set(session.id, session); this.ensureVerificationRequestTracked(session);