mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix: split monitor verification event routing
This commit is contained in:
@@ -14,12 +14,12 @@ function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): st
|
||||
}
|
||||
|
||||
function createHarness(params?: {
|
||||
authEncryption?: boolean;
|
||||
cryptoAvailable?: boolean;
|
||||
verifications?: Array<{
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
roomId?: string;
|
||||
otherUserId: string;
|
||||
phaseName: string;
|
||||
updatedAt?: string;
|
||||
completed?: boolean;
|
||||
sas?: {
|
||||
@@ -32,25 +32,34 @@ function createHarness(params?: {
|
||||
const onRoomMessage = vi.fn(async () => {});
|
||||
const listVerifications = vi.fn(async () => params?.verifications ?? []);
|
||||
const sendMessage = vi.fn(async () => "$notice");
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const formatNativeDependencyHint = vi.fn(() => "install hint");
|
||||
const client = {
|
||||
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
|
||||
listeners.set(eventName, listener);
|
||||
return client;
|
||||
}),
|
||||
sendMessage,
|
||||
crypto: {
|
||||
listVerifications,
|
||||
},
|
||||
...(params?.cryptoAvailable === false
|
||||
? {}
|
||||
: {
|
||||
crypto: {
|
||||
listVerifications,
|
||||
},
|
||||
}),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth: { accountId: "default", encryption: true } as MatrixAuth,
|
||||
auth: {
|
||||
accountId: "default",
|
||||
encryption: params?.authEncryption ?? true,
|
||||
} as MatrixAuth,
|
||||
logVerboseMessage: vi.fn(),
|
||||
warnedEncryptedRooms: new Set<string>(),
|
||||
warnedCryptoMissingRooms: new Set<string>(),
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
formatNativeDependencyHint: vi.fn(() => "install hint"),
|
||||
logger,
|
||||
formatNativeDependencyHint,
|
||||
onRoomMessage,
|
||||
});
|
||||
|
||||
@@ -64,6 +73,8 @@ function createHarness(params?: {
|
||||
sendMessage,
|
||||
roomEventListener,
|
||||
listVerifications,
|
||||
logger,
|
||||
formatNativeDependencyHint,
|
||||
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
|
||||
};
|
||||
}
|
||||
@@ -148,7 +159,6 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
transactionId: "$different-flow-id",
|
||||
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
|
||||
otherUserId: "@alice:example.org",
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [6158, 1986, 3513],
|
||||
emoji: [
|
||||
@@ -187,7 +197,6 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
id: "verification-3",
|
||||
transactionId: "$req3",
|
||||
otherUserId: "@alice:example.org",
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1111, 2222, 3333],
|
||||
emoji: [
|
||||
@@ -231,4 +240,60 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
.filter((body) => body.includes("SAS emoji:"));
|
||||
expect(sasBodies).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("warns once when encrypted events arrive without Matrix encryption enabled", () => {
|
||||
const { logger, roomEventListener } = createHarness({
|
||||
authEncryption: false,
|
||||
});
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$enc1",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$enc2",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt",
|
||||
{ roomId: "!room:example.org" },
|
||||
);
|
||||
});
|
||||
|
||||
it("warns once when crypto bindings are unavailable for encrypted rooms", () => {
|
||||
const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({
|
||||
authEncryption: true,
|
||||
cryptoAvailable: false,
|
||||
});
|
||||
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$enc1",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
roomEventListener("!room:example.org", {
|
||||
event_id: "$enc2",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessageEncrypted,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {},
|
||||
});
|
||||
|
||||
expect(formatNativeDependencyHint).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"matrix: encryption enabled but crypto is unavailable; install hint",
|
||||
{ roomId: "!room:example.org" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,236 +3,7 @@ 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 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]>;
|
||||
};
|
||||
};
|
||||
|
||||
function trimMaybeString(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readVerificationSignal(event: MatrixRawEvent): {
|
||||
stage: MatrixVerificationStage;
|
||||
flowId: string | null;
|
||||
} | null {
|
||||
const type = trimMaybeString(event?.type) ?? "";
|
||||
const content = event?.content ?? {};
|
||||
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
|
||||
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 };
|
||||
}
|
||||
if (type === "m.key.verification.cancel") {
|
||||
return { stage: "cancel", flowId };
|
||||
}
|
||||
if (type === "m.key.verification.done") {
|
||||
return { stage: "done", flowId };
|
||||
}
|
||||
return { stage: "other", flowId };
|
||||
}
|
||||
|
||||
function formatVerificationStageNotice(params: {
|
||||
stage: MatrixVerificationStage;
|
||||
senderId: string;
|
||||
event: MatrixRawEvent;
|
||||
}): string | null {
|
||||
const { stage, senderId, event } = params;
|
||||
const content = event.content as { code?: unknown; reason?: unknown };
|
||||
switch (stage) {
|
||||
case "request":
|
||||
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":
|
||||
return `Matrix verification completed with ${senderId}.`;
|
||||
case "cancel": {
|
||||
const code = trimMaybeString(content.code);
|
||||
const reason = trimMaybeString(content.reason);
|
||||
if (code && reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
|
||||
}
|
||||
if (reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${reason}).`;
|
||||
}
|
||||
return `Matrix verification cancelled by ${senderId}.`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
|
||||
const sas = summary.sas;
|
||||
if (!sas) {
|
||||
return null;
|
||||
}
|
||||
const emojiLine =
|
||||
Array.isArray(sas.emoji) && sas.emoji.length > 0
|
||||
? `SAS emoji: ${sas.emoji
|
||||
.map(
|
||||
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
|
||||
)
|
||||
.join(" | ")}`
|
||||
: null;
|
||||
const decimalLine =
|
||||
Array.isArray(sas.decimal) && sas.decimal.length === 3
|
||||
? `SAS decimal: ${sas.decimal.join(" ")}`
|
||||
: null;
|
||||
if (!emojiLine && !decimalLine) {
|
||||
return null;
|
||||
}
|
||||
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
|
||||
if (emojiLine) {
|
||||
lines.push(emojiLine);
|
||||
}
|
||||
if (decimalLine) {
|
||||
lines.push(decimalLine);
|
||||
}
|
||||
lines.push("If both sides match, choose 'They match' in your Matrix app.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
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,
|
||||
params: {
|
||||
event: MatrixRawEvent;
|
||||
senderId: string;
|
||||
flowId: string | null;
|
||||
},
|
||||
): Promise<MatrixVerificationSummaryLike | null> {
|
||||
if (!client.crypto) {
|
||||
return null;
|
||||
}
|
||||
const list = await client.crypto.listVerifications();
|
||||
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 {
|
||||
if (!value || set.has(value)) {
|
||||
return false;
|
||||
}
|
||||
set.add(value);
|
||||
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
|
||||
const oldest = set.values().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendVerificationNotice(params: {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
body: string;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const roomId = trimMaybeString(params.roomId);
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.client.sendMessage(roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: params.body,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
import { createMatrixVerificationEventRouter } from "./verification-events.js";
|
||||
|
||||
export function registerMatrixMonitorEvents(params: {
|
||||
client: MatrixClient;
|
||||
@@ -254,63 +25,10 @@ export function registerMatrixMonitorEvents(params: {
|
||||
formatNativeDependencyHint,
|
||||
onRoomMessage,
|
||||
} = params;
|
||||
const routedVerificationEvents = new Set<string>();
|
||||
const routedVerificationSasFingerprints = new Set<string>();
|
||||
|
||||
const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => {
|
||||
const senderId = trimMaybeString(event?.sender);
|
||||
if (!senderId) {
|
||||
return false;
|
||||
}
|
||||
const signal = readVerificationSignal(event);
|
||||
if (!signal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const flowId = signal.flowId;
|
||||
const sourceEventId = trimMaybeString(event?.event_id);
|
||||
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
|
||||
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
|
||||
const summary = await resolveVerificationSummaryForSignal(client, {
|
||||
event,
|
||||
senderId,
|
||||
flowId,
|
||||
}).catch(() => null);
|
||||
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
|
||||
|
||||
const notices: string[] = [];
|
||||
if (stageNotice) {
|
||||
notices.push(stageNotice);
|
||||
}
|
||||
if (summary && sasNotice) {
|
||||
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
|
||||
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
|
||||
notices.push(sasNotice);
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const body of notices) {
|
||||
await sendVerificationNotice({
|
||||
client,
|
||||
roomId,
|
||||
body,
|
||||
logVerboseMessage,
|
||||
});
|
||||
}
|
||||
})().catch((err) => {
|
||||
logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
const routeVerificationEvent = createMatrixVerificationEventRouter({
|
||||
client,
|
||||
logVerboseMessage,
|
||||
});
|
||||
|
||||
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
|
||||
if (routeVerificationEvent(roomId, event)) {
|
||||
|
||||
294
extensions/matrix/src/matrix/monitor/verification-events.ts
Normal file
294
extensions/matrix/src/matrix/monitor/verification-events.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
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 MAX_TRACKED_VERIFICATION_EVENTS = 1024;
|
||||
|
||||
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
|
||||
|
||||
type MatrixVerificationSummaryLike = {
|
||||
id: string;
|
||||
transactionId?: string;
|
||||
otherUserId: string;
|
||||
updatedAt?: string;
|
||||
completed?: boolean;
|
||||
sas?: {
|
||||
decimal?: [number, number, number];
|
||||
emoji?: Array<[string, string]>;
|
||||
};
|
||||
};
|
||||
|
||||
function trimMaybeString(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function readVerificationSignal(event: MatrixRawEvent): {
|
||||
stage: MatrixVerificationStage;
|
||||
flowId: string | null;
|
||||
} | null {
|
||||
const type = trimMaybeString(event?.type) ?? "";
|
||||
const content = event?.content ?? {};
|
||||
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
|
||||
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 };
|
||||
}
|
||||
if (type === "m.key.verification.cancel") {
|
||||
return { stage: "cancel", flowId };
|
||||
}
|
||||
if (type === "m.key.verification.done") {
|
||||
return { stage: "done", flowId };
|
||||
}
|
||||
return { stage: "other", flowId };
|
||||
}
|
||||
|
||||
function formatVerificationStageNotice(params: {
|
||||
stage: MatrixVerificationStage;
|
||||
senderId: string;
|
||||
event: MatrixRawEvent;
|
||||
}): string | null {
|
||||
const { stage, senderId, event } = params;
|
||||
const content = event.content as { code?: unknown; reason?: unknown };
|
||||
switch (stage) {
|
||||
case "request":
|
||||
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":
|
||||
return `Matrix verification completed with ${senderId}.`;
|
||||
case "cancel": {
|
||||
const code = trimMaybeString(content.code);
|
||||
const reason = trimMaybeString(content.reason);
|
||||
if (code && reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
|
||||
}
|
||||
if (reason) {
|
||||
return `Matrix verification cancelled by ${senderId} (${reason}).`;
|
||||
}
|
||||
return `Matrix verification cancelled by ${senderId}.`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
|
||||
const sas = summary.sas;
|
||||
if (!sas) {
|
||||
return null;
|
||||
}
|
||||
const emojiLine =
|
||||
Array.isArray(sas.emoji) && sas.emoji.length > 0
|
||||
? `SAS emoji: ${sas.emoji
|
||||
.map(
|
||||
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
|
||||
)
|
||||
.join(" | ")}`
|
||||
: null;
|
||||
const decimalLine =
|
||||
Array.isArray(sas.decimal) && sas.decimal.length === 3
|
||||
? `SAS decimal: ${sas.decimal.join(" ")}`
|
||||
: null;
|
||||
if (!emojiLine && !decimalLine) {
|
||||
return null;
|
||||
}
|
||||
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
|
||||
if (emojiLine) {
|
||||
lines.push(emojiLine);
|
||||
}
|
||||
if (decimalLine) {
|
||||
lines.push(decimalLine);
|
||||
}
|
||||
lines.push("If both sides match, choose 'They match' in your Matrix app.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
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,
|
||||
params: {
|
||||
event: MatrixRawEvent;
|
||||
senderId: string;
|
||||
flowId: string | null;
|
||||
},
|
||||
): Promise<MatrixVerificationSummaryLike | null> {
|
||||
if (!client.crypto) {
|
||||
return null;
|
||||
}
|
||||
const list = await client.crypto.listVerifications();
|
||||
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 {
|
||||
if (!value || set.has(value)) {
|
||||
return false;
|
||||
}
|
||||
set.add(value);
|
||||
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
|
||||
const oldest = set.values().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
set.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendVerificationNotice(params: {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
body: string;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const roomId = trimMaybeString(params.roomId);
|
||||
if (!roomId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.client.sendMessage(roomId, {
|
||||
msgtype: "m.notice",
|
||||
body: params.body,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logVerboseMessage(
|
||||
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMatrixVerificationEventRouter(params: {
|
||||
client: MatrixClient;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}) {
|
||||
const routedVerificationEvents = new Set<string>();
|
||||
const routedVerificationSasFingerprints = new Set<string>();
|
||||
|
||||
return (roomId: string, event: MatrixRawEvent): boolean => {
|
||||
const senderId = trimMaybeString(event?.sender);
|
||||
if (!senderId) {
|
||||
return false;
|
||||
}
|
||||
const signal = readVerificationSignal(event);
|
||||
if (!signal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const flowId = signal.flowId;
|
||||
const sourceEventId = trimMaybeString(event?.event_id);
|
||||
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
|
||||
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
|
||||
const summary = await resolveVerificationSummaryForSignal(params.client, {
|
||||
event,
|
||||
senderId,
|
||||
flowId,
|
||||
}).catch(() => null);
|
||||
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
|
||||
|
||||
const notices: string[] = [];
|
||||
if (stageNotice) {
|
||||
notices.push(stageNotice);
|
||||
}
|
||||
if (summary && sasNotice) {
|
||||
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
|
||||
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
|
||||
notices.push(sasNotice);
|
||||
}
|
||||
}
|
||||
if (notices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const body of notices) {
|
||||
await sendVerificationNotice({
|
||||
client: params.client,
|
||||
roomId,
|
||||
body,
|
||||
logVerboseMessage: params.logVerboseMessage,
|
||||
});
|
||||
}
|
||||
})().catch((err) => {
|
||||
params.logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user