diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index c6d78064ef4..894880abb9c 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -241,6 +241,68 @@ describe("registerMatrixMonitorEvents verification routing", () => { }); }); + it("retries SAS notice lookup when start arrives before SAS payload is available", async () => { + vi.useFakeTimers(); + const verifications: Array<{ + id: string; + transactionId?: string; + otherUserId: string; + updatedAt?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + }> = [ + { + id: "verification-race", + transactionId: "$req-race", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + otherUserId: "@alice:example.org", + }, + ]; + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications, + }); + + try { + roomEventListener("!dm:example.org", { + event_id: "$start-race", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-race" }, + }, + }); + + await vi.advanceTimersByTimeAsync(500); + verifications[0] = { + ...verifications[0]!, + sas: { + decimal: [1234, 5678, 9012], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }; + await vi.advanceTimersByTimeAsync(500); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + }); + } finally { + vi.useRealTimers(); + } + }); + it("ignores verification notices in unrelated non-DM rooms", async () => { const { sendMessage, roomEventListener } = createHarness({ joinedMembersByRoom: { diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts index 672c37c57d6..624fcc4e176 100644 --- a/extensions/matrix/src/matrix/monitor/verification-events.ts +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -9,6 +9,7 @@ import { } from "./verification-utils.js"; const MAX_TRACKED_VERIFICATION_EVENTS = 1024; +const SAS_NOTICE_RETRY_DELAY_MS = 750; type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; @@ -225,6 +226,37 @@ async function resolveVerificationSummaryForSignal( return activeByUser.length === 1 ? (activeByUser[0] ?? null) : null; } +async function resolveVerificationSasNoticeForSignal( + client: MatrixClient, + params: { + roomId: string; + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + stage: MatrixVerificationStage; + }, +): Promise<{ summary: MatrixVerificationSummaryLike | null; sasNotice: string | null }> { + const summary = await resolveVerificationSummaryForSignal(client, params); + const immediateNotice = + summary && isActiveVerificationSummary(summary) ? formatVerificationSasNotice(summary) : null; + if (immediateNotice || (params.stage !== "ready" && params.stage !== "start")) { + return { + summary, + sasNotice: immediateNotice, + }; + } + + await new Promise((resolve) => setTimeout(resolve, SAS_NOTICE_RETRY_DELAY_MS)); + const retriedSummary = await resolveVerificationSummaryForSignal(client, params); + return { + summary: retriedSummary, + sasNotice: + retriedSummary && isActiveVerificationSummary(retriedSummary) + ? formatVerificationSasNotice(retriedSummary) + : null, + }; +} + function trackBounded(set: Set, value: string): boolean { if (!value || set.has(value)) { return false; @@ -298,16 +330,13 @@ export function createMatrixVerificationEventRouter(params: { } const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); - const summary = await resolveVerificationSummaryForSignal(params.client, { + const { summary, sasNotice } = await resolveVerificationSasNoticeForSignal(params.client, { roomId, event, senderId, flowId, - }).catch(() => null); - const sasNotice = - summary && isActiveVerificationSummary(summary) - ? formatVerificationSasNotice(summary) - : null; + stage: signal.stage, + }).catch(() => ({ summary: null, sasNotice: null })); const notices: string[] = []; if (stageNotice) {