mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 22:34:28 +00:00
Matrix-js: harden user verification routing and guidance
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user