Matrix-js: dedupe config helpers and harden monitoring/auth flows

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 22:42:51 -05:00
parent d5df455120
commit f0d8bf7cf8
25 changed files with 796 additions and 322 deletions

View File

@@ -59,6 +59,10 @@ function printTimestamp(label: string, value: string | null | undefined): void {
} }
} }
function printAccountLabel(accountId?: string): void {
console.log(`Account: ${normalizeAccountId(accountId)}`);
}
function configureCliLogMode(verbose: boolean): void { function configureCliLogMode(verbose: boolean): void {
setMatrixSdkLogMode(verbose ? "default" : "quiet"); setMatrixSdkLogMode(verbose ? "default" : "quiet");
} }
@@ -521,6 +525,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
includeRecoveryKey: options.includeRecoveryKey === true, includeRecoveryKey: options.includeRecoveryKey === true,
}), }),
onText: (status, verbose) => { onText: (status, verbose) => {
printAccountLabel(options.account);
printVerificationStatus(status, verbose); printVerificationStatus(status, verbose);
}, },
errorPrefix: "Error", errorPrefix: "Error",
@@ -542,6 +547,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
json: options.json === true, json: options.json === true,
run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }), run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }),
onText: (status, verbose) => { onText: (status, verbose) => {
printAccountLabel(options.account);
printBackupSummary(status); printBackupSummary(status);
if (verbose) { if (verbose) {
printBackupStatus(status); printBackupStatus(status);
@@ -574,6 +580,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
recoveryKey: options.recoveryKey, recoveryKey: options.recoveryKey,
}), }),
onText: (result, verbose) => { onText: (result, verbose) => {
printAccountLabel(options.account);
console.log(`Restore success: ${result.success ? "yes" : "no"}`); console.log(`Restore success: ${result.success ? "yes" : "no"}`);
if (result.error) { if (result.error) {
console.log(`Error: ${result.error}`); console.log(`Error: ${result.error}`);
@@ -622,6 +629,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
forceResetCrossSigning: options.forceResetCrossSigning === true, forceResetCrossSigning: options.forceResetCrossSigning === true,
}), }),
onText: (result, verbose) => { onText: (result, verbose) => {
printAccountLabel(options.account);
console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`); console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`);
if (result.error) { if (result.error) {
console.log(`Error: ${result.error}`); console.log(`Error: ${result.error}`);
@@ -666,6 +674,7 @@ export function registerMatrixJsCli(params: { program: Command }): void {
json: options.json === true, json: options.json === true,
run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }), run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }),
onText: (result, verbose) => { onText: (result, verbose) => {
printAccountLabel(options.account);
if (!result.success) { if (!result.success) {
console.error(`Verification failed: ${result.error ?? "unknown error"}`); console.error(`Verification failed: ${result.error ?? "unknown error"}`);
return; return;

View File

@@ -0,0 +1,37 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
return cfg.channels?.["matrix-js"] ?? {};
}
export function resolveMatrixAccountsMap(
cfg: CoreConfig,
): Readonly<Record<string, MatrixAccountConfig>> {
const accounts = resolveMatrixBaseConfig(cfg).accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
return accounts;
}
export function findMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = resolveMatrixAccountsMap(cfg);
if (accounts[accountId] && typeof accounts[accountId] === "object") {
return accounts[accountId];
}
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
const candidate = accounts[key];
if (candidate && typeof candidate === "object") {
return candidate;
}
return undefined;
}
}
return undefined;
}

View File

@@ -1,5 +1,10 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.js"; import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountsMap,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./client.js"; import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@@ -30,8 +35,8 @@ export type ResolvedMatrixAccount = {
}; };
function listConfiguredAccountIds(cfg: CoreConfig): string[] { function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.["matrix-js"]?.accounts; const accounts = resolveMatrixAccountsMap(cfg);
if (!accounts || typeof accounts !== "object") { if (Object.keys(accounts).length === 0) {
return []; return [];
} }
// Normalize and de-duplicate keys so listing and resolution use the same semantics // Normalize and de-duplicate keys so listing and resolution use the same semantics
@@ -62,22 +67,7 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
} }
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined { function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
const accounts = cfg.channels?.["matrix-js"]?.accounts; return findMatrixAccountConfig(cfg, accountId);
if (!accounts || typeof accounts !== "object") {
return undefined;
}
// Direct lookup first (fast path for already-normalized keys)
if (accounts[accountId]) {
return accounts[accountId] as MatrixConfig;
}
// Fall back to case-insensitive match (user may have mixed-case keys in config)
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
return accounts[key] as MatrixConfig;
}
}
return undefined;
} }
export function resolveMatrixAccount(params: { export function resolveMatrixAccount(params: {
@@ -85,7 +75,7 @@ export function resolveMatrixAccount(params: {
accountId?: string | null; accountId?: string | null;
}): ResolvedMatrixAccount { }): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const matrixBase = params.cfg.channels?.["matrix-js"] ?? {}; const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId }); const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const enabled = base.enabled !== false && matrixBase.enabled !== false; const enabled = base.enabled !== false && matrixBase.enabled !== false;
@@ -120,7 +110,7 @@ export function resolveMatrixAccountConfig(params: {
accountId?: string | null; accountId?: string | null;
}): MatrixConfig { }): MatrixConfig {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const matrixBase = params.cfg.channels?.["matrix-js"] ?? {}; const matrixBase = resolveMatrixBaseConfig(params.cfg);
const accountConfig = resolveAccountConfig(params.cfg, accountId); const accountConfig = resolveAccountConfig(params.cfg, accountId);
if (!accountConfig) { if (!accountConfig) {
return matrixBase; return matrixBase;

View File

@@ -39,3 +39,32 @@ export async function resolveActionClient(
await client.prepareForOneOff(); await client.prepareForOneOff();
return { client, stopOnDone: true }; return { client, stopOnDone: true };
} }
export type MatrixActionClientStopMode = "stop" | "persist";
export async function stopActionClient(
resolved: MatrixActionClient,
mode: MatrixActionClientStopMode = "stop",
): Promise<void> {
if (!resolved.stopOnDone) {
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
const resolved = await resolveActionClient(opts);
try {
return await run(resolved.client);
} finally {
await stopActionClient(resolved, mode);
}
}

View File

@@ -1,5 +1,5 @@
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { resolveActionClient } from "./client.js"; import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js"; import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js"; import { summarizeMatrixRawEvent } from "./summary.js";
import { import {
@@ -40,8 +40,7 @@ export async function editMatrixMessage(
if (!trimmed) { if (!trimmed) {
throw new Error("Matrix edit requires content"); throw new Error("Matrix edit requires content");
} }
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = { const newContent = {
msgtype: MsgType.Text, msgtype: MsgType.Text,
@@ -58,11 +57,7 @@ export async function editMatrixMessage(
}; };
const eventId = await client.sendMessage(resolvedRoom, payload); const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null }; return { eventId: eventId ?? null };
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }
export async function deleteMatrixMessage( export async function deleteMatrixMessage(
@@ -70,15 +65,10 @@ export async function deleteMatrixMessage(
messageId: string, messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {}, opts: MatrixActionClientOpts & { reason?: string } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason); await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }
export async function readMatrixMessages( export async function readMatrixMessages(
@@ -93,8 +83,7 @@ export async function readMatrixMessages(
nextBatch?: string | null; nextBatch?: string | null;
prevBatch?: string | null; prevBatch?: string | null;
}> { }> {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit = resolveMatrixActionLimit(opts.limit, 20); const limit = resolveMatrixActionLimit(opts.limit, 20);
const token = opts.before?.trim() || opts.after?.trim() || undefined; const token = opts.before?.trim() || opts.after?.trim() || undefined;
@@ -118,9 +107,5 @@ export async function readMatrixMessages(
nextBatch: res.end ?? null, nextBatch: res.end ?? null,
prevBatch: res.start ?? null, prevBatch: res.start ?? null,
}; };
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }

View File

@@ -1,5 +1,5 @@
import { resolveMatrixRoomId } from "../send.js"; import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } from "./client.js"; import { withResolvedActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js"; import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import { import {
EventType, EventType,
@@ -16,15 +16,10 @@ async function withResolvedPinRoom<T>(
opts: MatrixActionClientOpts, opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>, run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> { ): Promise<T> {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom); return await run(client, resolvedRoom);
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }
async function updateMatrixPins( async function updateMatrixPins(

View File

@@ -1,5 +1,5 @@
import { resolveMatrixRoomId } from "../send.js"; import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } from "./client.js"; import { withResolvedActionClient } from "./client.js";
import { import {
EventType, EventType,
RelationType, RelationType,
@@ -14,8 +14,7 @@ export async function listMatrixReactions(
messageId: string, messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {}, opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> { ): Promise<MatrixReactionSummary[]> {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit = const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit) typeof opts.limit === "number" && Number.isFinite(opts.limit)
@@ -47,11 +46,7 @@ export async function listMatrixReactions(
summaries.set(key, entry); summaries.set(key, entry);
} }
return Array.from(summaries.values()); return Array.from(summaries.values());
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }
export async function removeMatrixReactions( export async function removeMatrixReactions(
@@ -59,8 +54,7 @@ export async function removeMatrixReactions(
messageId: string, messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {}, opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> { ): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = (await client.doRequest( const res = (await client.doRequest(
"GET", "GET",
@@ -88,9 +82,5 @@ export async function removeMatrixReactions(
} }
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length }; return { removed: toRemove.length };
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }

View File

@@ -1,13 +1,12 @@
import { resolveMatrixRoomId } from "../send.js"; import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } from "./client.js"; import { withResolvedActionClient } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js"; import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo( export async function getMatrixMemberInfo(
userId: string, userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {}, opts: MatrixActionClientOpts & { roomId?: string } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getUserProfile(userId); const profile = await client.getUserProfile(userId);
// Membership and power levels are not included in profile calls; fetch state separately if needed. // Membership and power levels are not included in profile calls; fetch state separately if needed.
@@ -22,16 +21,11 @@ export async function getMatrixMemberInfo(
displayName: profile?.displayname ?? null, displayName: profile?.displayname ?? null,
roomId: roomId ?? null, roomId: roomId ?? null,
}; };
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(opts, async (client) => {
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
let name: string | null = null; let name: string | null = null;
let topic: string | null = null; let topic: string | null = null;
@@ -74,9 +68,5 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
altAliases: [], // Would need separate query altAliases: [], // Would need separate query
memberCount, memberCount,
}; };
} finally { });
if (stopOnDone) {
client.stop();
}
}
} }

View File

@@ -1,4 +1,4 @@
import { resolveActionClient } from "./client.js"; import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js"; import type { MatrixActionClientOpts } from "./types.js";
function requireCrypto( function requireCrypto(
@@ -12,16 +12,6 @@ function requireCrypto(
return client.crypto; return client.crypto;
} }
async function stopActionClient(params: {
client: import("../sdk.js").MatrixClient;
stopOnDone: boolean;
}): Promise<void> {
if (!params.stopOnDone) {
return;
}
await params.client.stopAndPersist();
}
function resolveVerificationId(input: string): string { function resolveVerificationId(input: string): string {
const normalized = input.trim(); const normalized = input.trim();
if (!normalized) { if (!normalized) {
@@ -31,13 +21,14 @@ function resolveVerificationId(input: string): string {
} }
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.listVerifications(); const crypto = requireCrypto(client);
} finally { return await crypto.listVerifications();
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function requestMatrixVerification( export async function requestMatrixVerification(
@@ -48,74 +39,79 @@ export async function requestMatrixVerification(
roomId?: string; roomId?: string;
} = {}, } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(params); return await withResolvedActionClient(
try { params,
const crypto = requireCrypto(client); async (client) => {
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); const crypto = requireCrypto(client);
return await crypto.requestVerification({ const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
ownUser, return await crypto.requestVerification({
userId: params.userId?.trim() || undefined, ownUser,
deviceId: params.deviceId?.trim() || undefined, userId: params.userId?.trim() || undefined,
roomId: params.roomId?.trim() || undefined, deviceId: params.deviceId?.trim() || undefined,
}); roomId: params.roomId?.trim() || undefined,
} finally { });
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function acceptMatrixVerification( export async function acceptMatrixVerification(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.acceptVerification(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.acceptVerification(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function cancelMatrixVerification( export async function cancelMatrixVerification(
requestId: string, requestId: string,
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.cancelVerification(resolveVerificationId(requestId), { const crypto = requireCrypto(client);
reason: opts.reason?.trim() || undefined, return await crypto.cancelVerification(resolveVerificationId(requestId), {
code: opts.code?.trim() || undefined, reason: opts.reason?.trim() || undefined,
}); code: opts.code?.trim() || undefined,
} finally { });
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function startMatrixVerification( export async function startMatrixVerification(
requestId: string, requestId: string,
opts: MatrixActionClientOpts & { method?: "sas" } = {}, opts: MatrixActionClientOpts & { method?: "sas" } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); const crypto = requireCrypto(client);
} finally { return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function generateMatrixVerificationQr( export async function generateMatrixVerificationQr(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.generateVerificationQr(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.generateVerificationQr(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function scanMatrixVerificationQr( export async function scanMatrixVerificationQr(
@@ -123,132 +119,137 @@ export async function scanMatrixVerificationQr(
qrDataBase64: string, qrDataBase64: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
const payload = qrDataBase64.trim(); const crypto = requireCrypto(client);
if (!payload) { const payload = qrDataBase64.trim();
throw new Error("Matrix QR data is required"); if (!payload) {
} throw new Error("Matrix QR data is required");
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); }
} finally { return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function getMatrixVerificationSas( export async function getMatrixVerificationSas(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.getVerificationSas(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.getVerificationSas(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function confirmMatrixVerificationSas( export async function confirmMatrixVerificationSas(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function mismatchMatrixVerificationSas( export async function mismatchMatrixVerificationSas(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function confirmMatrixVerificationReciprocateQr( export async function confirmMatrixVerificationReciprocateQr(
requestId: string, requestId: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); const crypto = requireCrypto(client);
} finally { return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function getMatrixEncryptionStatus( export async function getMatrixEncryptionStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const crypto = requireCrypto(client); async (client) => {
const recoveryKey = await crypto.getRecoveryKey(); const crypto = requireCrypto(client);
return { const recoveryKey = await crypto.getRecoveryKey();
encryptionEnabled: true, return {
recoveryKeyStored: Boolean(recoveryKey), encryptionEnabled: true,
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, recoveryKeyStored: Boolean(recoveryKey),
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
pendingVerifications: (await crypto.listVerifications()).length, ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
}; pendingVerifications: (await crypto.listVerifications()).length,
} finally { };
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function getMatrixVerificationStatus( export async function getMatrixVerificationStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
const status = await client.getOwnDeviceVerificationStatus(); async (client) => {
const payload = { const status = await client.getOwnDeviceVerificationStatus();
...status, const payload = {
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0, ...status,
}; pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
if (!opts.includeRecoveryKey) { };
return payload; if (!opts.includeRecoveryKey) {
} return payload;
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null; }
return { const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
...payload, return {
recoveryKey: recoveryKey?.encodedPrivateKey ?? null, ...payload,
}; recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
} finally { };
await stopActionClient({ client, stopOnDone }); },
} "persist",
);
} }
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) { export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
return await client.getRoomKeyBackupStatus(); async (client) => await client.getRoomKeyBackupStatus(),
} finally { "persist",
await stopActionClient({ client, stopOnDone }); );
}
} }
export async function verifyMatrixRecoveryKey( export async function verifyMatrixRecoveryKey(
recoveryKey: string, recoveryKey: string,
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
return await client.verifyWithRecoveryKey(recoveryKey); async (client) => await client.verifyWithRecoveryKey(recoveryKey),
} finally { "persist",
await stopActionClient({ client, stopOnDone }); );
}
} }
export async function restoreMatrixRoomKeyBackup( export async function restoreMatrixRoomKeyBackup(
@@ -256,14 +257,14 @@ export async function restoreMatrixRoomKeyBackup(
recoveryKey?: string; recoveryKey?: string;
} = {}, } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
return await client.restoreRoomKeyBackup({ async (client) =>
recoveryKey: opts.recoveryKey?.trim() || undefined, await client.restoreRoomKeyBackup({
}); recoveryKey: opts.recoveryKey?.trim() || undefined,
} finally { }),
await stopActionClient({ client, stopOnDone }); "persist",
} );
} }
export async function bootstrapMatrixVerification( export async function bootstrapMatrixVerification(
@@ -272,13 +273,13 @@ export async function bootstrapMatrixVerification(
forceResetCrossSigning?: boolean; forceResetCrossSigning?: boolean;
} = {}, } = {},
) { ) {
const { client, stopOnDone } = await resolveActionClient(opts); return await withResolvedActionClient(
try { opts,
return await client.bootstrapOwnDeviceVerification({ async (client) =>
recoveryKey: opts.recoveryKey?.trim() || undefined, await client.bootstrapOwnDeviceVerification({
forceResetCrossSigning: opts.forceResetCrossSigning === true, recoveryKey: opts.recoveryKey?.trim() || undefined,
}); forceResetCrossSigning: opts.forceResetCrossSigning === true,
} finally { }),
await stopActionClient({ client, stopOnDone }); "persist",
} );
} }

View File

@@ -1,6 +1,7 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
import { MatrixClient } from "../sdk.js"; import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
@@ -83,32 +84,11 @@ export function hasReadyMatrixEnvAuth(config: {
return Boolean(homeserver && (accessToken || (userId && password))); return Boolean(homeserver && (accessToken || (userId && password)));
} }
function findAccountConfig(cfg: CoreConfig, accountId: string): Record<string, unknown> {
const accounts = cfg.channels?.["matrix-js"]?.accounts;
if (!accounts || typeof accounts !== "object") {
return {};
}
if (accounts[accountId] && typeof accounts[accountId] === "object") {
return accounts[accountId] as Record<string, unknown>;
}
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
const candidate = accounts[key];
if (candidate && typeof candidate === "object") {
return candidate as Record<string, unknown>;
}
return {};
}
}
return {};
}
export function resolveMatrixConfig( export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig { ): MatrixResolvedConfig {
const matrix = cfg.channels?.["matrix-js"] ?? {}; const matrix = resolveMatrixBaseConfig(cfg);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env); const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env); const globalEnv = resolveGlobalMatrixEnvConfig(env);
const homeserver = const homeserver =
@@ -144,8 +124,8 @@ export function resolveMatrixConfigForAccount(
accountId: string, accountId: string,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig { ): MatrixResolvedConfig {
const matrix = cfg.channels?.["matrix-js"] ?? {}; const matrix = resolveMatrixBaseConfig(cfg);
const account = findAccountConfig(cfg, accountId); const account = findMatrixAccountConfig(cfg, accountId) ?? {};
const normalizedAccountId = normalizeAccountId(accountId); const normalizedAccountId = normalizeAccountId(accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env); const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env); const globalEnv = resolveGlobalMatrixEnvConfig(env);
@@ -285,7 +265,7 @@ export async function resolveMatrixAuth(params?: {
cachedCredentials.userId !== userId || cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId; (cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) { if (shouldRefreshCachedCredentials) {
saveMatrixCredentials( await saveMatrixCredentials(
{ {
homeserver: resolved.homeserver, homeserver: resolved.homeserver,
userId, userId,
@@ -296,7 +276,7 @@ export async function resolveMatrixAuth(params?: {
accountId, accountId,
); );
} else if (hasMatchingCachedToken) { } else if (hasMatchingCachedToken) {
touchMatrixCredentials(env, accountId); await touchMatrixCredentials(env, accountId);
} }
return { return {
homeserver: resolved.homeserver, homeserver: resolved.homeserver,
@@ -311,7 +291,7 @@ export async function resolveMatrixAuth(params?: {
} }
if (cachedCredentials) { if (cachedCredentials) {
touchMatrixCredentials(env, accountId); await touchMatrixCredentials(env, accountId);
return { return {
homeserver: cachedCredentials.homeserver, homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId, userId: cachedCredentials.userId,
@@ -367,7 +347,7 @@ export async function resolveMatrixAuth(params?: {
encryption: resolved.encryption, encryption: resolved.encryption,
}; };
saveMatrixCredentials( await saveMatrixCredentials(
{ {
homeserver: auth.homeserver, homeserver: auth.homeserver,
userId: auth.userId, userId: auth.userId,

View File

@@ -0,0 +1,80 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
import {
loadMatrixCredentials,
resolveMatrixCredentialsPath,
saveMatrixCredentials,
touchMatrixCredentials,
} from "./credentials.js";
describe("matrix credentials storage", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function setupStateDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-"));
tempDirs.push(dir);
setMatrixRuntime({
state: {
resolveStateDir: () => dir,
},
} as never);
return dir;
}
it("writes credentials atomically with secure file permissions", async () => {
setupStateDir();
await saveMatrixCredentials(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
deviceId: "DEVICE123",
},
{},
"ops",
);
const credPath = resolveMatrixCredentialsPath({}, "ops");
expect(fs.existsSync(credPath)).toBe(true);
const mode = fs.statSync(credPath).mode & 0o777;
expect(mode).toBe(0o600);
});
it("touch updates lastUsedAt while preserving createdAt", async () => {
setupStateDir();
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
await saveMatrixCredentials(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "secret-token",
},
{},
"default",
);
const initial = loadMatrixCredentials({}, "default");
expect(initial).not.toBeNull();
vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z"));
await touchMatrixCredentials({}, "default");
const touched = loadMatrixCredentials({}, "default");
expect(touched).not.toBeNull();
expect(touched?.createdAt).toBe(initial?.createdAt);
expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z");
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,6 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../runtime.js"; import { getMatrixRuntime } from "../runtime.js";
@@ -63,14 +64,11 @@ export function loadMatrixCredentials(
} }
} }
export function saveMatrixCredentials( export async function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">, credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
accountId?: string | null, accountId?: string | null,
): void { ): Promise<void> {
const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true });
const credPath = resolveMatrixCredentialsPath(env, accountId); const credPath = resolveMatrixCredentialsPath(env, accountId);
const existing = loadMatrixCredentials(env, accountId); const existing = loadMatrixCredentials(env, accountId);
@@ -82,13 +80,13 @@ export function saveMatrixCredentials(
lastUsedAt: now, lastUsedAt: now,
}; };
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); await writeJsonFileAtomically(credPath, toSave);
} }
export function touchMatrixCredentials( export async function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
accountId?: string | null, accountId?: string | null,
): void { ): Promise<void> {
const existing = loadMatrixCredentials(env, accountId); const existing = loadMatrixCredentials(env, accountId);
if (!existing) { if (!existing) {
return; return;
@@ -96,7 +94,7 @@ export function touchMatrixCredentials(
existing.lastUsedAt = new Date().toISOString(); existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env, accountId); const credPath = resolveMatrixCredentialsPath(env, accountId);
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); await writeJsonFileAtomically(credPath, existing);
} }
export function clearMatrixCredentials( export function clearMatrixCredentials(

View File

@@ -2,7 +2,164 @@ import { describe, expect, it, vi } from "vitest";
import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixRoomMessageHandler } from "./handler.js";
import { EventType, type MatrixRawEvent } from "./types.js"; import { EventType, type MatrixRawEvent } from "./types.js";
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })),
);
vi.mock("../send.js", () => ({
reactMatrixMessage: vi.fn(async () => {}),
sendMessageMatrix: sendMessageMatrixMock,
sendReadReceiptMatrix: vi.fn(async () => {}),
sendTypingMatrix: vi.fn(async () => {}),
}));
describe("matrix monitor handler pairing account scope", () => { describe("matrix monitor handler pairing account scope", () => {
it("caches account-scoped allowFrom store reads on hot path", async () => {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
sendMessageMatrixMock.mockClear();
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: () => "pairing",
},
},
} as never,
cfg: {} as never,
accountId: "poe",
runtime: {} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "pairing",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event1",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
await handler("!room:example.org", {
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: "$event2",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello again",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
});
it("sends pairing reminders for pending requests with cooldown", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
try {
const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
sendMessageMatrixMock.mockClear();
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: () => "Pairing code: ABCDEFGH",
},
},
} as never,
cfg: {} as never,
accountId: "poe",
runtime: {} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "pairing",
textLimit: 8_000,
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
directTracker: {
isDirectMessage: async () => true,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
});
const makeEvent = (id: string): MatrixRawEvent =>
({
type: EventType.RoomMessage,
sender: "@user:example.org",
event_id: id,
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
}) as MatrixRawEvent;
await handler("!room:example.org", makeEvent("$event1"));
await handler("!room:example.org", makeEvent("$event2"));
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing request is still pending approval.",
);
await vi.advanceTimersByTimeAsync(5 * 60_000 + 1);
await handler("!room:example.org", makeEvent("$event3"));
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("uses account-scoped pairing store reads and upserts for dm pairing", async () => { it("uses account-scoped pairing store reads and upserts for dm pairing", async () => {
const readAllowFromStore = vi.fn(async () => [] as string[]); const readAllowFromStore = vi.fn(async () => [] as string[]);
const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
@@ -57,7 +214,11 @@ describe("matrix monitor handler pairing account scope", () => {
}, },
} as MatrixRawEvent); } as MatrixRawEvent);
expect(readAllowFromStore).toHaveBeenCalledWith("matrix-js", process.env, "poe"); expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "matrix-js",
env: process.env,
accountId: "poe",
});
expect(upsertPairingRequest).toHaveBeenCalledWith({ expect(upsertPairingRequest).toHaveBeenCalledWith({
channel: "matrix-js", channel: "matrix-js",
id: "@user:example.org", id: "@user:example.org",

View File

@@ -39,11 +39,15 @@ 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"; import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
export type MatrixMonitorHandlerParams = { export type MatrixMonitorHandlerParams = {
client: MatrixClient; client: MatrixClient;
core: PluginRuntime; core: PluginRuntime;
cfg: CoreConfig; cfg: CoreConfig;
accountId?: string; accountId: string;
runtime: RuntimeEnv; runtime: RuntimeEnv;
logger: RuntimeLogger; logger: RuntimeLogger;
logVerboseMessage: (message: string) => void; logVerboseMessage: (message: string) => void;
@@ -97,6 +101,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
getRoomInfo, getRoomInfo,
getMemberDisplayName, getMemberDisplayName,
} = params; } = params;
let cachedStoreAllowFrom: {
value: string[];
expiresAtMs: number;
} | null = null;
const pairingReplySentAtMsBySender = new Map<string, number>();
const readStoreAllowFrom = async (): Promise<string[]> => {
const now = Date.now();
if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
return cachedStoreAllowFrom.value;
}
const value = await core.channel.pairing
.readAllowFromStore({
channel: "matrix-js",
env: process.env,
accountId,
})
.catch(() => []);
cachedStoreAllowFrom = {
value,
expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
};
return value;
};
const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
const now = Date.now();
if (created) {
pairingReplySentAtMsBySender.set(senderId, now);
return true;
}
const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
return false;
}
pairingReplySentAtMsBySender.set(senderId, now);
if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
if (typeof oldestSender === "string") {
pairingReplySentAtMsBySender.delete(oldestSender);
}
}
return true;
};
return async (roomId: string, event: MatrixRawEvent) => { return async (roomId: string, event: MatrixRawEvent) => {
try { try {
@@ -230,9 +278,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
const senderName = await getMemberDisplayName(roomId, senderId); const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await core.channel.pairing const storeAllowFrom = await readStoreAllowFrom();
.readAllowFromStore("matrix-js", process.env, accountId)
.catch(() => []);
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? []; const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
@@ -256,23 +302,32 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
accountId, accountId,
meta: { name: senderName }, meta: { name: senderName },
}); });
if (created) { if (shouldSendPairingReply(senderId, created)) {
const pairingReply = core.channel.pairing.buildPairingReply({
channel: "matrix-js",
idLine: `Your Matrix user id: ${senderId}`,
code,
});
logVerboseMessage( logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, created
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
); );
try { try {
await sendMessageMatrix( await sendMessageMatrix(
`room:${roomId}`, `room:${roomId}`,
core.channel.pairing.buildPairingReply({ created
channel: "matrix-js", ? pairingReply
idLine: `Your Matrix user id: ${senderId}`, : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
code,
}),
{ client }, { client },
); );
} catch (err) { } catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
} }
} else {
logVerboseMessage(
`matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
);
} }
} }
if (dmPolicy !== "pairing") { if (dmPolicy !== "pairing") {

View File

@@ -286,7 +286,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
client, client,
core, core,
cfg, cfg,
accountId: opts.accountId ?? undefined, accountId: account.accountId,
runtime, runtime,
logger, logger,
logVerboseMessage, logVerboseMessage,

View File

@@ -289,6 +289,7 @@ describe("MatrixVerificationManager", () => {
}); });
it("does not auto-confirm SAS for verifications initiated by this device", async () => { it("does not auto-confirm SAS for verifications initiated by this device", async () => {
vi.useFakeTimers();
const confirm = vi.fn(async () => {}); const confirm = vi.fn(async () => {});
const verifier = new MockVerifier( const verifier = new MockVerifier(
{ {
@@ -312,11 +313,15 @@ describe("MatrixVerificationManager", () => {
initiatedByMe: true, initiatedByMe: true,
verifier, verifier,
}); });
const manager = new MatrixVerificationManager(); try {
manager.trackVerificationRequest(request); const manager = new MatrixVerificationManager();
manager.trackVerificationRequest(request);
await new Promise((resolve) => setTimeout(resolve, 20)); await vi.advanceTimersByTimeAsync(20);
expect(confirm).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
}); });
it("prunes stale terminal sessions during list operations", () => { it("prunes stale terminal sessions during list operations", () => {

View File

@@ -54,7 +54,14 @@ describe("sendMessageMatrix media", () => {
}); });
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); loadWebMediaMock.mockReset().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
getImageMetadataMock.mockReset().mockResolvedValue(null);
resizeToJpegMock.mockReset();
setMatrixRuntime(runtimeStub); setMatrixRuntime(runtimeStub);
}); });
@@ -117,6 +124,72 @@ describe("sendMessageMatrix media", () => {
expect(content.url).toBeUndefined(); expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/file"); expect(content.file?.url).toBe("mxc://example/file");
}); });
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
const { client, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(1);
});
it("uploads thumbnail metadata for unencrypted large images", async () => {
const { client, sendMessage, uploadContent } = makeClient();
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(2);
const content = sendMessage.mock.calls[0]?.[1] as {
info?: {
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
};
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
expect(content.info?.thumbnail_info).toMatchObject({
w: 800,
h: 600,
mimetype: "image/jpeg",
size: Buffer.from("thumb").byteLength,
});
});
}); });
describe("sendMessageMatrix threads", () => { describe("sendMessageMatrix threads", () => {

View File

@@ -99,7 +99,11 @@ export async function sendMessageMatrix(
const msgtype = useVoice ? MsgType.Audio : baseMsgType; const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image; const isImage = msgtype === MsgType.Image;
const imageInfo = isImage const imageInfo = isImage
? await prepareImageInfo({ buffer: media.buffer, client }) ? await prepareImageInfo({
buffer: media.buffer,
client,
encrypted: Boolean(uploaded.file),
})
: undefined; : undefined;
const [firstChunk, ...rest] = chunks; const [firstChunk, ...rest] = chunks;
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");

View File

@@ -1,6 +1,6 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../accounts.js";
import { getActiveMatrixClient } from "../active-client.js"; import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js"; import type { MatrixClient } from "../sdk.js";
@@ -15,16 +15,8 @@ export function ensureNodeRuntime() {
export function resolveMediaMaxBytes(accountId?: string | null): number | undefined { export function resolveMediaMaxBytes(accountId?: string | null): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig; const cfg = getCore().config.loadConfig() as CoreConfig;
const matrixCfg = cfg.channels?.["matrix-js"]; const matrixCfg = resolveMatrixAccountConfig({ cfg, accountId });
const accountCfg = accountId const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined;
? (matrixCfg?.accounts?.[accountId] ?? matrixCfg?.accounts?.[normalizeAccountId(accountId)])
: undefined;
const mediaMaxMb =
typeof accountCfg?.mediaMaxMb === "number"
? accountCfg.mediaMaxMb
: typeof matrixCfg?.mediaMaxMb === "number"
? matrixCfg.mediaMaxMb
: undefined;
if (typeof mediaMaxMb === "number") { if (typeof mediaMaxMb === "number") {
return mediaMaxMb * 1024 * 1024; return mediaMaxMb * 1024 * 1024;
} }

View File

@@ -113,6 +113,7 @@ const THUMBNAIL_QUALITY = 80;
export async function prepareImageInfo(params: { export async function prepareImageInfo(params: {
buffer: Buffer; buffer: Buffer;
client: MatrixClient; client: MatrixClient;
encrypted?: boolean;
}): Promise<DimensionalFileInfo | undefined> { }): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore() const meta = await getCore()
.media.getImageMetadata(params.buffer) .media.getImageMetadata(params.buffer)
@@ -121,6 +122,10 @@ export async function prepareImageInfo(params: {
return undefined; return undefined;
} }
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
if (params.encrypted) {
// For E2EE media, avoid uploading plaintext thumbnails.
return imageInfo;
}
const maxDim = Math.max(meta.width, meta.height); const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) { if (maxDim > THUMBNAIL_MAX_SIDE) {
try { try {

View File

@@ -15,6 +15,8 @@ describe("matrix onboarding", () => {
MATRIX_USER_ID: process.env.MATRIX_USER_ID, MATRIX_USER_ID: process.env.MATRIX_USER_ID,
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN, MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
MATRIX_PASSWORD: process.env.MATRIX_PASSWORD, MATRIX_PASSWORD: process.env.MATRIX_PASSWORD,
MATRIX_DEVICE_ID: process.env.MATRIX_DEVICE_ID,
MATRIX_DEVICE_NAME: process.env.MATRIX_DEVICE_NAME,
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER, MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
}; };
@@ -114,4 +116,48 @@ describe("matrix onboarding", () => {
), ),
).toBe(true); ).toBe(true);
}); });
it("includes device env var names in auth help text", async () => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as never);
const notes: string[] = [];
const prompter = {
note: vi.fn(async (message: unknown) => {
notes.push(String(message));
}),
text: vi.fn(async () => {
throw new Error("stop-after-help");
}),
confirm: vi.fn(async () => false),
select: vi.fn(async () => "token"),
} as unknown as WizardPrompter;
await expect(
matrixOnboardingAdapter.configureInteractive!({
cfg: { channels: {} } as CoreConfig,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
prompter,
options: undefined,
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
configured: false,
label: "Matrix-js",
}),
).rejects.toThrow("stop-after-help");
const noteText = notes.join("\n");
expect(noteText).toContain("MATRIX_DEVICE_ID");
expect(noteText).toContain("MATRIX_DEVICE_NAME");
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_ID");
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_NAME");
});
}); });

View File

@@ -59,8 +59,8 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
"Matrix requires a homeserver URL.", "Matrix requires a homeserver URL.",
"Use an access token (recommended) or password login to an existing account.", "Use an access token (recommended) or password login to an existing account.",
"With access token: user ID is fetched automatically.", "With access token: user ID is fetched automatically.",
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD, MATRIX_DEVICE_ID, MATRIX_DEVICE_NAME.",
"Per-account env vars: MATRIX_<ACCOUNT_ID>_HOMESERVER, MATRIX_<ACCOUNT_ID>_USER_ID, MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN, MATRIX_<ACCOUNT_ID>_PASSWORD.", "Per-account env vars: MATRIX_<ACCOUNT_ID>_HOMESERVER, MATRIX_<ACCOUNT_ID>_USER_ID, MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN, MATRIX_<ACCOUNT_ID>_PASSWORD, MATRIX_<ACCOUNT_ID>_DEVICE_ID, MATRIX_<ACCOUNT_ID>_DEVICE_NAME.",
`Docs: ${formatDocsLink("/channels/matrix-js", "channels/matrix-js")}`, `Docs: ${formatDocsLink("/channels/matrix-js", "channels/matrix-js")}`,
].join("\n"), ].join("\n"),
"Matrix setup", "Matrix setup",
@@ -70,6 +70,7 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
async function promptMatrixAllowFrom(params: { async function promptMatrixAllowFrom(params: {
cfg: CoreConfig; cfg: CoreConfig;
prompter: WizardPrompter; prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> { }): Promise<CoreConfig> {
const { cfg, prompter } = params; const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.["matrix-js"]?.dm?.allowFrom ?? []; const existingAllowFrom = cfg.channels?.["matrix-js"]?.dm?.allowFrom ?? [];

View File

@@ -64,4 +64,29 @@ describe("resolveMatrixTargets (users)", () => {
expect(result?.id).toBe("!two:example.org"); expect(result?.id).toBe("!two:example.org");
expect(result?.note).toBe("multiple matches; chose first"); expect(result?.note).toBe("multiple matches; chose first");
}); });
it("reuses directory lookups for duplicate inputs", async () => {
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([
{ kind: "user", id: "@alice:example.org", name: "Alice" },
]);
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([
{ kind: "group", id: "!team:example.org", name: "Team", handle: "#team" },
]);
const userResults = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice", "Alice"],
kind: "user",
});
const groupResults = await resolveMatrixTargets({
cfg: {},
inputs: ["#team", "#team"],
kind: "group",
});
expect(userResults.every((entry) => entry.resolved)).toBe(true);
expect(groupResults.every((entry) => entry.resolved)).toBe(true);
expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1);
expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -72,6 +72,37 @@ export async function resolveMatrixTargets(params: {
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
}): Promise<ChannelResolveResult[]> { }): Promise<ChannelResolveResult[]> {
const results: ChannelResolveResult[] = []; const results: ChannelResolveResult[] = [];
const userLookupCache = new Map<string, ChannelDirectoryEntry[]>();
const groupLookupCache = new Map<string, ChannelDirectoryEntry[]>();
const readUserMatches = async (query: string): Promise<ChannelDirectoryEntry[]> => {
const cached = userLookupCache.get(query);
if (cached) {
return cached;
}
const matches = await listMatrixDirectoryPeersLive({
cfg: params.cfg,
query,
limit: 5,
});
userLookupCache.set(query, matches);
return matches;
};
const readGroupMatches = async (query: string): Promise<ChannelDirectoryEntry[]> => {
const cached = groupLookupCache.get(query);
if (cached) {
return cached;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query,
limit: 5,
});
groupLookupCache.set(query, matches);
return matches;
};
for (const input of params.inputs) { for (const input of params.inputs) {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) { if (!trimmed) {
@@ -84,11 +115,7 @@ export async function resolveMatrixTargets(params: {
continue; continue;
} }
try { try {
const matches = await listMatrixDirectoryPeersLive({ const matches = await readUserMatches(trimmed);
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestUserMatch(matches, trimmed); const best = pickBestUserMatch(matches, trimmed);
results.push({ results.push({
input, input,
@@ -104,11 +131,7 @@ export async function resolveMatrixTargets(params: {
continue; continue;
} }
try { try {
const matches = await listMatrixDirectoryGroupsLive({ const matches = await readGroupMatches(trimmed);
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestGroupMatch(matches, trimmed); const best = pickBestGroupMatch(matches, trimmed);
results.push({ results.push({
input, input,

View File

@@ -113,7 +113,7 @@ export type CoreConfig = {
}; };
messages?: { messages?: {
ackReaction?: string; ackReaction?: string;
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
}; };
[key: string]: unknown; [key: string]: unknown;
}; };