diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md index 5ae4a0174e8..f943bcc3c1d 100644 --- a/docs/channels/matrix-js.md +++ b/docs/channels/matrix-js.md @@ -204,11 +204,13 @@ Matrix-js now posts verification lifecycle notices directly into the Matrix room That includes: - verification request notices +- verification ready notices (with explicit "Verify by emoji" guidance) - verification start and completion notices - SAS details (emoji and decimal) when available 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. +Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`. ## 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 ac9cf4862c9..45b4877806c 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.test.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.test.ts @@ -11,8 +11,11 @@ function createHarness(params?: { verifications?: Array<{ id: string; transactionId?: string; + roomId?: string; otherUserId: string; phaseName: string; + updatedAt?: string; + completed?: boolean; sas?: { decimal?: [number, number, number]; emoji?: Array<[string, string]>; @@ -82,6 +85,27 @@ describe("registerMatrixMonitorEvents verification routing", () => { 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."); + expect(body).toContain('Open "Verify by emoji"'); + }); + + it("posts ready-stage guidance for emoji verification", async () => { + const { sendMessage, roomEventListener } = createHarness(); + roomEventListener("!room:example.org", { + event_id: "$ready-1", + sender: "@alice:example.org", + type: "m.key.verification.ready", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-ready-1" }, + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = (sendMessage.mock.calls[0]?.[1] as { body?: string } | undefined)?.body ?? ""; + expect(body).toContain("Matrix verification is ready with @alice:example.org."); + expect(body).toContain('Choose "Verify by emoji"'); }); it("posts SAS emoji/decimal details when verification summaries expose them", async () => { @@ -89,7 +113,8 @@ describe("registerMatrixMonitorEvents verification routing", () => { verifications: [ { id: "verification-1", - transactionId: "$req2", + transactionId: "$different-flow-id", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), otherUserId: "@alice:example.org", phaseName: "started", sas: { diff --git a/extensions/matrix-js/src/matrix/monitor/events.ts b/extensions/matrix-js/src/matrix/monitor/events.ts index 51c29adece8..4a1f18486e2 100644 --- a/extensions/matrix-js/src/matrix/monitor/events.ts +++ b/extensions/matrix-js/src/matrix/monitor/events.ts @@ -3,16 +3,24 @@ import type { MatrixAuth } from "../client.js"; import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; +import { + isMatrixVerificationEventType, + isMatrixVerificationRequestMsgType, + matrixVerificationConstants, +} from "./verification-utils.js"; -const VERIFICATION_EVENT_PREFIX = "m.key.verification."; -const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request"; const MAX_TRACKED_VERIFICATION_EVENTS = 1024; +type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; + type MatrixVerificationSummaryLike = { id: string; transactionId?: string; + roomId?: string; otherUserId: string; phaseName: string; + updatedAt?: string; + completed?: boolean; sas?: { decimal?: [number, number, number]; emoji?: Array<[string, string]>; @@ -28,25 +36,32 @@ function trimMaybeString(input: unknown): string | null { } function readVerificationSignal(event: MatrixRawEvent): { - stage: "request" | "start" | "cancel" | "done" | "other"; + stage: MatrixVerificationStage; flowId: string | null; } | null { const type = trimMaybeString(event?.type) ?? ""; const content = event?.content ?? {}; const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; - if (type === EventType.RoomMessage && msgtype === VERIFICATION_REQUEST_MSGTYPE) { - return { - stage: "request", - flowId: trimMaybeString(event.event_id), - }; - } - if (!type.startsWith(VERIFICATION_EVENT_PREFIX)) { - return null; - } - - const flowId = trimMaybeString( + const relatedEventId = trimMaybeString( (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, ); + const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); + if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { + return { + stage: "request", + flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, + }; + } + if (!isMatrixVerificationEventType(type)) { + return null; + } + const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); + if (type === `${matrixVerificationConstants.eventPrefix}request`) { + return { stage: "request", flowId }; + } + if (type === `${matrixVerificationConstants.eventPrefix}ready`) { + return { stage: "ready", flowId }; + } if (type === "m.key.verification.start") { return { stage: "start", flowId }; } @@ -60,7 +75,7 @@ function readVerificationSignal(event: MatrixRawEvent): { } function formatVerificationStageNotice(params: { - stage: "request" | "start" | "cancel" | "done" | "other"; + stage: MatrixVerificationStage; senderId: string; event: MatrixRawEvent; }): string | null { @@ -68,7 +83,9 @@ function formatVerificationStageNotice(params: { const content = event.content as { code?: unknown; reason?: unknown }; switch (stage) { case "request": - return `Matrix verification request received from ${senderId}.`; + return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; + case "ready": + return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; case "start": return `Matrix verification started with ${senderId}.`; case "done": @@ -120,16 +137,65 @@ function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): st return lines.join("\n"); } -async function resolveVerificationSummaryByFlowId( +function resolveVerificationFlowCandidates(params: { + event: MatrixRawEvent; + flowId: string | null; +}): string[] { + const { event, flowId } = params; + const content = event.content as { + transaction_id?: unknown; + "m.relates_to"?: { event_id?: unknown }; + }; + const candidates = new Set(); + const add = (value: unknown) => { + const normalized = trimMaybeString(value); + if (normalized) { + candidates.add(normalized); + } + }; + add(flowId); + add(event.event_id); + add(content.transaction_id); + add(content["m.relates_to"]?.event_id); + return Array.from(candidates); +} + +function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { + const ts = Date.parse(summary.updatedAt ?? ""); + return Number.isFinite(ts) ? ts : 0; +} + +async function resolveVerificationSummaryForSignal( client: MatrixClient, - flowId: string | null, + params: { + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + }, ): Promise { - if (!flowId || !client.crypto) { + if (!client.crypto) { return null; } const list = await client.crypto.listVerifications(); - const summary = list.find((entry) => entry.transactionId === flowId); - return summary ?? null; + if (list.length === 0) { + return null; + } + const candidates = resolveVerificationFlowCandidates({ + event: params.event, + flowId: params.flowId, + }); + const byTransactionId = list.find((entry) => + candidates.some((candidate) => entry.transactionId === candidate), + ); + if (byTransactionId) { + return byTransactionId; + } + + // Fallback for flows where transaction IDs do not match room event IDs consistently. + const byUser = list + .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) + .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; + return byUser ?? null; } function trackBounded(set: Set, value: string): boolean { @@ -210,7 +276,11 @@ export function registerMatrixMonitorEvents(params: { } const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); - const summary = await resolveVerificationSummaryByFlowId(client, flowId).catch(() => null); + const summary = await resolveVerificationSummaryForSignal(client, { + event, + senderId, + flowId, + }).catch(() => null); const sasNotice = summary ? formatVerificationSasNotice(summary) : null; const notices: string[] = []; diff --git a/extensions/matrix-js/src/matrix/monitor/handler.ts b/extensions/matrix-js/src/matrix/monitor/handler.ts index 797d8eb0ae5..875018671df 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.ts +++ b/extensions/matrix-js/src/matrix/monitor/handler.ts @@ -34,6 +34,7 @@ import { resolveMatrixRoomConfig } from "./rooms.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; import { EventType, RelationType } from "./types.js"; +import { isMatrixVerificationRoomMessage } from "./verification-utils.js"; export type MatrixMonitorHandlerParams = { client: MatrixClient; @@ -179,6 +180,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } + if ( + eventType === EventType.RoomMessage && + isMatrixVerificationRoomMessage({ + msgtype: (content as { msgtype?: unknown }).msgtype, + body: content.body, + }) + ) { + logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`); + return; + } + const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ eventType, content: content as LocationMessageEventContent, diff --git a/extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts b/extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts new file mode 100644 index 00000000000..5093e73939d --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + isMatrixVerificationEventType, + isMatrixVerificationNoticeBody, + isMatrixVerificationRequestMsgType, + isMatrixVerificationRoomMessage, +} from "./verification-utils.js"; + +describe("matrix verification message classifiers", () => { + it("recognizes verification event types", () => { + expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true); + expect(isMatrixVerificationEventType("m.room.message")).toBe(false); + }); + + it("recognizes verification request message type", () => { + expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true); + expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false); + }); + + it("recognizes verification notice bodies", () => { + expect( + isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."), + ).toBe(true); + expect(isMatrixVerificationNoticeBody("hello world")).toBe(false); + }); + + it("classifies verification room messages", () => { + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.key.verification.request", + body: "verify request", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.notice", + body: "Matrix verification cancelled by @alice:example.org.", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.text", + body: "normal chat message", + }), + ).toBe(false); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/verification-utils.ts b/extensions/matrix-js/src/matrix/monitor/verification-utils.ts new file mode 100644 index 00000000000..d777167c4ff --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/verification-utils.ts @@ -0,0 +1,44 @@ +const VERIFICATION_EVENT_PREFIX = "m.key.verification."; +const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request"; + +const VERIFICATION_NOTICE_PREFIXES = [ + "Matrix verification request received from ", + "Matrix verification is ready with ", + "Matrix verification started with ", + "Matrix verification completed with ", + "Matrix verification cancelled by ", + "Matrix verification SAS with ", +]; + +function trimMaybeString(input: unknown): string { + return typeof input === "string" ? input.trim() : ""; +} + +export function isMatrixVerificationEventType(type: unknown): boolean { + return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX); +} + +export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean { + return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE; +} + +export function isMatrixVerificationNoticeBody(body: unknown): boolean { + const text = trimMaybeString(body); + return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix)); +} + +export function isMatrixVerificationRoomMessage(content: { + msgtype?: unknown; + body?: unknown; +}): boolean { + return ( + isMatrixVerificationRequestMsgType(content.msgtype) || + (trimMaybeString(content.msgtype) === "m.notice" && + isMatrixVerificationNoticeBody(content.body)) + ); +} + +export const matrixVerificationConstants = { + eventPrefix: VERIFICATION_EVENT_PREFIX, + requestMsgtype: VERIFICATION_REQUEST_MSGTYPE, +} as const;