Matrix-js: harden user verification routing and guidance

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 16:56:35 -05:00
parent 2f57931f8a
commit 78354f14a5
6 changed files with 223 additions and 23 deletions

View File

@@ -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

View File

@@ -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: {

View File

@@ -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<string>();
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<MatrixVerificationSummaryLike | null> {
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<string>, 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[] = [];

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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;