mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 11:17:13 +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:
|
That includes:
|
||||||
|
|
||||||
- verification request notices
|
- verification request notices
|
||||||
|
- verification ready notices (with explicit "Verify by emoji" guidance)
|
||||||
- verification start and completion notices
|
- verification start and completion notices
|
||||||
- SAS details (emoji and decimal) when available
|
- SAS details (emoji and decimal) when available
|
||||||
|
|
||||||
Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match"
|
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.
|
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
|
## DM and room policy example
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ function createHarness(params?: {
|
|||||||
verifications?: Array<{
|
verifications?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
|
roomId?: string;
|
||||||
otherUserId: string;
|
otherUserId: string;
|
||||||
phaseName: string;
|
phaseName: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
completed?: boolean;
|
||||||
sas?: {
|
sas?: {
|
||||||
decimal?: [number, number, number];
|
decimal?: [number, number, number];
|
||||||
emoji?: Array<[string, string]>;
|
emoji?: Array<[string, string]>;
|
||||||
@@ -82,6 +85,27 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
|||||||
expect(onRoomMessage).not.toHaveBeenCalled();
|
expect(onRoomMessage).not.toHaveBeenCalled();
|
||||||
const body = (sendMessage.mock.calls[0]?.[1] as { body?: string } | undefined)?.body ?? "";
|
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("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 () => {
|
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
|
||||||
@@ -89,7 +113,8 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
|||||||
verifications: [
|
verifications: [
|
||||||
{
|
{
|
||||||
id: "verification-1",
|
id: "verification-1",
|
||||||
transactionId: "$req2",
|
transactionId: "$different-flow-id",
|
||||||
|
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
|
||||||
otherUserId: "@alice:example.org",
|
otherUserId: "@alice:example.org",
|
||||||
phaseName: "started",
|
phaseName: "started",
|
||||||
sas: {
|
sas: {
|
||||||
|
|||||||
@@ -3,16 +3,24 @@ import type { MatrixAuth } from "../client.js";
|
|||||||
import type { MatrixClient } from "../sdk.js";
|
import type { MatrixClient } from "../sdk.js";
|
||||||
import type { MatrixRawEvent } from "./types.js";
|
import type { MatrixRawEvent } from "./types.js";
|
||||||
import { EventType } 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;
|
const MAX_TRACKED_VERIFICATION_EVENTS = 1024;
|
||||||
|
|
||||||
|
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
|
||||||
|
|
||||||
type MatrixVerificationSummaryLike = {
|
type MatrixVerificationSummaryLike = {
|
||||||
id: string;
|
id: string;
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
|
roomId?: string;
|
||||||
otherUserId: string;
|
otherUserId: string;
|
||||||
phaseName: string;
|
phaseName: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
completed?: boolean;
|
||||||
sas?: {
|
sas?: {
|
||||||
decimal?: [number, number, number];
|
decimal?: [number, number, number];
|
||||||
emoji?: Array<[string, string]>;
|
emoji?: Array<[string, string]>;
|
||||||
@@ -28,25 +36,32 @@ function trimMaybeString(input: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readVerificationSignal(event: MatrixRawEvent): {
|
function readVerificationSignal(event: MatrixRawEvent): {
|
||||||
stage: "request" | "start" | "cancel" | "done" | "other";
|
stage: MatrixVerificationStage;
|
||||||
flowId: string | null;
|
flowId: string | null;
|
||||||
} | null {
|
} | null {
|
||||||
const type = trimMaybeString(event?.type) ?? "";
|
const type = trimMaybeString(event?.type) ?? "";
|
||||||
const content = event?.content ?? {};
|
const content = event?.content ?? {};
|
||||||
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
|
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
|
||||||
if (type === EventType.RoomMessage && msgtype === VERIFICATION_REQUEST_MSGTYPE) {
|
const relatedEventId = trimMaybeString(
|
||||||
return {
|
|
||||||
stage: "request",
|
|
||||||
flowId: trimMaybeString(event.event_id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!type.startsWith(VERIFICATION_EVENT_PREFIX)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const flowId = trimMaybeString(
|
|
||||||
(content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id,
|
(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") {
|
if (type === "m.key.verification.start") {
|
||||||
return { stage: "start", flowId };
|
return { stage: "start", flowId };
|
||||||
}
|
}
|
||||||
@@ -60,7 +75,7 @@ function readVerificationSignal(event: MatrixRawEvent): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatVerificationStageNotice(params: {
|
function formatVerificationStageNotice(params: {
|
||||||
stage: "request" | "start" | "cancel" | "done" | "other";
|
stage: MatrixVerificationStage;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
event: MatrixRawEvent;
|
event: MatrixRawEvent;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
@@ -68,7 +83,9 @@ function formatVerificationStageNotice(params: {
|
|||||||
const content = event.content as { code?: unknown; reason?: unknown };
|
const content = event.content as { code?: unknown; reason?: unknown };
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case "request":
|
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":
|
case "start":
|
||||||
return `Matrix verification started with ${senderId}.`;
|
return `Matrix verification started with ${senderId}.`;
|
||||||
case "done":
|
case "done":
|
||||||
@@ -120,16 +137,65 @@ function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): st
|
|||||||
return lines.join("\n");
|
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,
|
client: MatrixClient,
|
||||||
flowId: string | null,
|
params: {
|
||||||
|
event: MatrixRawEvent;
|
||||||
|
senderId: string;
|
||||||
|
flowId: string | null;
|
||||||
|
},
|
||||||
): Promise<MatrixVerificationSummaryLike | null> {
|
): Promise<MatrixVerificationSummaryLike | null> {
|
||||||
if (!flowId || !client.crypto) {
|
if (!client.crypto) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const list = await client.crypto.listVerifications();
|
const list = await client.crypto.listVerifications();
|
||||||
const summary = list.find((entry) => entry.transactionId === flowId);
|
if (list.length === 0) {
|
||||||
return summary ?? null;
|
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 {
|
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 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 sasNotice = summary ? formatVerificationSasNotice(summary) : null;
|
||||||
|
|
||||||
const notices: string[] = [];
|
const notices: string[] = [];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { resolveMatrixRoomConfig } from "./rooms.js";
|
|||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||||
import { EventType, RelationType } from "./types.js";
|
import { EventType, RelationType } from "./types.js";
|
||||||
|
import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
|
||||||
|
|
||||||
export type MatrixMonitorHandlerParams = {
|
export type MatrixMonitorHandlerParams = {
|
||||||
client: MatrixClient;
|
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({
|
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
|
||||||
eventType,
|
eventType,
|
||||||
content: content as LocationMessageEventContent,
|
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