diff --git a/extensions/matrix-js/src/cli.ts b/extensions/matrix-js/src/cli.ts index c1c970333a4..7b2dd4036c8 100644 --- a/extensions/matrix-js/src/cli.ts +++ b/extensions/matrix-js/src/cli.ts @@ -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 { setMatrixSdkLogMode(verbose ? "default" : "quiet"); } @@ -521,6 +525,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { includeRecoveryKey: options.includeRecoveryKey === true, }), onText: (status, verbose) => { + printAccountLabel(options.account); printVerificationStatus(status, verbose); }, errorPrefix: "Error", @@ -542,6 +547,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { json: options.json === true, run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }), onText: (status, verbose) => { + printAccountLabel(options.account); printBackupSummary(status); if (verbose) { printBackupStatus(status); @@ -574,6 +580,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { recoveryKey: options.recoveryKey, }), onText: (result, verbose) => { + printAccountLabel(options.account); console.log(`Restore success: ${result.success ? "yes" : "no"}`); if (result.error) { console.log(`Error: ${result.error}`); @@ -622,6 +629,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { forceResetCrossSigning: options.forceResetCrossSigning === true, }), onText: (result, verbose) => { + printAccountLabel(options.account); console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`); if (result.error) { console.log(`Error: ${result.error}`); @@ -666,6 +674,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { json: options.json === true, run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }), onText: (result, verbose) => { + printAccountLabel(options.account); if (!result.success) { console.error(`Verification failed: ${result.error ?? "unknown error"}`); return; diff --git a/extensions/matrix-js/src/matrix/account-config.ts b/extensions/matrix-js/src/matrix/account-config.ts new file mode 100644 index 00000000000..e7ef49807ea --- /dev/null +++ b/extensions/matrix-js/src/matrix/account-config.ts @@ -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> { + 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; +} diff --git a/extensions/matrix-js/src/matrix/accounts.ts b/extensions/matrix-js/src/matrix/accounts.ts index ef2eb6d6b08..a25dde78965 100644 --- a/extensions/matrix-js/src/matrix/accounts.ts +++ b/extensions/matrix-js/src/matrix/accounts.ts @@ -1,5 +1,10 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { CoreConfig, MatrixConfig } from "../types.js"; +import { + findMatrixAccountConfig, + resolveMatrixAccountsMap, + resolveMatrixBaseConfig, +} from "./account-config.js"; import { resolveMatrixConfigForAccount } from "./client.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; @@ -30,8 +35,8 @@ export type ResolvedMatrixAccount = { }; function listConfiguredAccountIds(cfg: CoreConfig): string[] { - const accounts = cfg.channels?.["matrix-js"]?.accounts; - if (!accounts || typeof accounts !== "object") { + const accounts = resolveMatrixAccountsMap(cfg); + if (Object.keys(accounts).length === 0) { return []; } // 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 { - const accounts = cfg.channels?.["matrix-js"]?.accounts; - 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; + return findMatrixAccountConfig(cfg, accountId); } export function resolveMatrixAccount(params: { @@ -85,7 +75,7 @@ export function resolveMatrixAccount(params: { accountId?: string | null; }): ResolvedMatrixAccount { 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 enabled = base.enabled !== false && matrixBase.enabled !== false; @@ -120,7 +110,7 @@ export function resolveMatrixAccountConfig(params: { accountId?: string | null; }): MatrixConfig { const accountId = normalizeAccountId(params.accountId); - const matrixBase = params.cfg.channels?.["matrix-js"] ?? {}; + const matrixBase = resolveMatrixBaseConfig(params.cfg); const accountConfig = resolveAccountConfig(params.cfg, accountId); if (!accountConfig) { return matrixBase; diff --git a/extensions/matrix-js/src/matrix/actions/client.ts b/extensions/matrix-js/src/matrix/actions/client.ts index a4597f65431..a3981be0520 100644 --- a/extensions/matrix-js/src/matrix/actions/client.ts +++ b/extensions/matrix-js/src/matrix/actions/client.ts @@ -39,3 +39,32 @@ export async function resolveActionClient( await client.prepareForOneOff(); return { client, stopOnDone: true }; } + +export type MatrixActionClientStopMode = "stop" | "persist"; + +export async function stopActionClient( + resolved: MatrixActionClient, + mode: MatrixActionClientStopMode = "stop", +): Promise { + if (!resolved.stopOnDone) { + return; + } + if (mode === "persist") { + await resolved.client.stopAndPersist(); + return; + } + resolved.client.stop(); +} + +export async function withResolvedActionClient( + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"]) => Promise, + mode: MatrixActionClientStopMode = "stop", +): Promise { + const resolved = await resolveActionClient(opts); + try { + return await run(resolved.client); + } finally { + await stopActionClient(resolved, mode); + } +} diff --git a/extensions/matrix-js/src/matrix/actions/messages.ts b/extensions/matrix-js/src/matrix/actions/messages.ts index 3e4ddd39d3c..3fcf1cd43d4 100644 --- a/extensions/matrix-js/src/matrix/actions/messages.ts +++ b/extensions/matrix-js/src/matrix/actions/messages.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; import { summarizeMatrixRawEvent } from "./summary.js"; import { @@ -40,8 +40,7 @@ export async function editMatrixMessage( if (!trimmed) { throw new Error("Matrix edit requires content"); } - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const newContent = { msgtype: MsgType.Text, @@ -58,11 +57,7 @@ export async function editMatrixMessage( }; const eventId = await client.sendMessage(resolvedRoom, payload); return { eventId: eventId ?? null }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } export async function deleteMatrixMessage( @@ -70,15 +65,10 @@ export async function deleteMatrixMessage( messageId: string, opts: MatrixActionClientOpts & { reason?: string } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); await client.redactEvent(resolvedRoom, messageId, opts.reason); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } export async function readMatrixMessages( @@ -93,8 +83,7 @@ export async function readMatrixMessages( nextBatch?: string | null; prevBatch?: string | null; }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const limit = resolveMatrixActionLimit(opts.limit, 20); const token = opts.before?.trim() || opts.after?.trim() || undefined; @@ -118,9 +107,5 @@ export async function readMatrixMessages( nextBatch: res.end ?? null, prevBatch: res.start ?? null, }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } diff --git a/extensions/matrix-js/src/matrix/actions/pins.ts b/extensions/matrix-js/src/matrix/actions/pins.ts index 52baf69fd12..ca5ca4a8524 100644 --- a/extensions/matrix-js/src/matrix/actions/pins.ts +++ b/extensions/matrix-js/src/matrix/actions/pins.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { fetchEventSummary, readPinnedEvents } from "./summary.js"; import { EventType, @@ -16,15 +16,10 @@ async function withResolvedPinRoom( opts: MatrixActionClientOpts, run: (client: ActionClient, resolvedRoom: string) => Promise, ): Promise { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); return await run(client, resolvedRoom); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } async function updateMatrixPins( diff --git a/extensions/matrix-js/src/matrix/actions/reactions.ts b/extensions/matrix-js/src/matrix/actions/reactions.ts index 5be6642169f..18b21d3de30 100644 --- a/extensions/matrix-js/src/matrix/actions/reactions.ts +++ b/extensions/matrix-js/src/matrix/actions/reactions.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { EventType, RelationType, @@ -14,8 +14,7 @@ export async function listMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { limit?: number } = {}, ): Promise { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const limit = typeof opts.limit === "number" && Number.isFinite(opts.limit) @@ -47,11 +46,7 @@ export async function listMatrixReactions( summaries.set(key, entry); } return Array.from(summaries.values()); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } export async function removeMatrixReactions( @@ -59,8 +54,7 @@ export async function removeMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { emoji?: string } = {}, ): Promise<{ removed: number }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const res = (await client.doRequest( "GET", @@ -88,9 +82,5 @@ export async function removeMatrixReactions( } await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); return { removed: toRemove.length }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } diff --git a/extensions/matrix-js/src/matrix/actions/room.ts b/extensions/matrix-js/src/matrix/actions/room.ts index 75e67b97383..8180a3dc253 100644 --- a/extensions/matrix-js/src/matrix/actions/room.ts +++ b/extensions/matrix-js/src/matrix/actions/room.ts @@ -1,13 +1,12 @@ import { resolveMatrixRoomId } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { EventType, type MatrixActionClientOpts } from "./types.js"; export async function getMatrixMemberInfo( userId: string, opts: MatrixActionClientOpts & { roomId?: string } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; const profile = await client.getUserProfile(userId); // 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, roomId: roomId ?? null, }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); let name: 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 memberCount, }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } diff --git a/extensions/matrix-js/src/matrix/actions/verification.ts b/extensions/matrix-js/src/matrix/actions/verification.ts index 86897eedfd1..f22185194e8 100644 --- a/extensions/matrix-js/src/matrix/actions/verification.ts +++ b/extensions/matrix-js/src/matrix/actions/verification.ts @@ -1,4 +1,4 @@ -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import type { MatrixActionClientOpts } from "./types.js"; function requireCrypto( @@ -12,16 +12,6 @@ function requireCrypto( return client.crypto; } -async function stopActionClient(params: { - client: import("../sdk.js").MatrixClient; - stopOnDone: boolean; -}): Promise { - if (!params.stopOnDone) { - return; - } - await params.client.stopAndPersist(); -} - function resolveVerificationId(input: string): string { const normalized = input.trim(); if (!normalized) { @@ -31,13 +21,14 @@ function resolveVerificationId(input: string): string { } export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.listVerifications(); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.listVerifications(); + }, + "persist", + ); } export async function requestMatrixVerification( @@ -48,74 +39,79 @@ export async function requestMatrixVerification( roomId?: string; } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(params); - try { - const crypto = requireCrypto(client); - const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); - return await crypto.requestVerification({ - ownUser, - userId: params.userId?.trim() || undefined, - deviceId: params.deviceId?.trim() || undefined, - roomId: params.roomId?.trim() || undefined, - }); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + params, + async (client) => { + const crypto = requireCrypto(client); + const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); + return await crypto.requestVerification({ + ownUser, + userId: params.userId?.trim() || undefined, + deviceId: params.deviceId?.trim() || undefined, + roomId: params.roomId?.trim() || undefined, + }); + }, + "persist", + ); } export async function acceptMatrixVerification( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.acceptVerification(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.acceptVerification(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function cancelMatrixVerification( requestId: string, opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.cancelVerification(resolveVerificationId(requestId), { - reason: opts.reason?.trim() || undefined, - code: opts.code?.trim() || undefined, - }); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.cancelVerification(resolveVerificationId(requestId), { + reason: opts.reason?.trim() || undefined, + code: opts.code?.trim() || undefined, + }); + }, + "persist", + ); } export async function startMatrixVerification( requestId: string, opts: MatrixActionClientOpts & { method?: "sas" } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); + }, + "persist", + ); } export async function generateMatrixVerificationQr( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.generateVerificationQr(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.generateVerificationQr(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function scanMatrixVerificationQr( @@ -123,132 +119,137 @@ export async function scanMatrixVerificationQr( qrDataBase64: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - const payload = qrDataBase64.trim(); - if (!payload) { - throw new Error("Matrix QR data is required"); - } - return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + const payload = qrDataBase64.trim(); + if (!payload) { + throw new Error("Matrix QR data is required"); + } + return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); + }, + "persist", + ); } export async function getMatrixVerificationSas( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.getVerificationSas(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.getVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function confirmMatrixVerificationSas( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function mismatchMatrixVerificationSas( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function confirmMatrixVerificationReciprocateQr( requestId: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); + }, + "persist", + ); } export async function getMatrixEncryptionStatus( opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const crypto = requireCrypto(client); - const recoveryKey = await crypto.getRecoveryKey(); - return { - encryptionEnabled: true, - recoveryKeyStored: Boolean(recoveryKey), - recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, - ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), - pendingVerifications: (await crypto.listVerifications()).length, - }; - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + const recoveryKey = await crypto.getRecoveryKey(); + return { + encryptionEnabled: true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), + pendingVerifications: (await crypto.listVerifications()).length, + }; + }, + "persist", + ); } export async function getMatrixVerificationStatus( opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const status = await client.getOwnDeviceVerificationStatus(); - const payload = { - ...status, - pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0, - }; - if (!opts.includeRecoveryKey) { - return payload; - } - const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null; - return { - ...payload, - recoveryKey: recoveryKey?.encodedPrivateKey ?? null, - }; - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => { + const status = await client.getOwnDeviceVerificationStatus(); + const payload = { + ...status, + pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0, + }; + if (!opts.includeRecoveryKey) { + return payload; + } + const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null; + return { + ...payload, + recoveryKey: recoveryKey?.encodedPrivateKey ?? null, + }; + }, + "persist", + ); } export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - return await client.getRoomKeyBackupStatus(); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => await client.getRoomKeyBackupStatus(), + "persist", + ); } export async function verifyMatrixRecoveryKey( recoveryKey: string, opts: MatrixActionClientOpts = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - return await client.verifyWithRecoveryKey(recoveryKey); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => await client.verifyWithRecoveryKey(recoveryKey), + "persist", + ); } export async function restoreMatrixRoomKeyBackup( @@ -256,14 +257,14 @@ export async function restoreMatrixRoomKeyBackup( recoveryKey?: string; } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - return await client.restoreRoomKeyBackup({ - recoveryKey: opts.recoveryKey?.trim() || undefined, - }); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => + await client.restoreRoomKeyBackup({ + recoveryKey: opts.recoveryKey?.trim() || undefined, + }), + "persist", + ); } export async function bootstrapMatrixVerification( @@ -272,13 +273,13 @@ export async function bootstrapMatrixVerification( forceResetCrossSigning?: boolean; } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - return await client.bootstrapOwnDeviceVerification({ - recoveryKey: opts.recoveryKey?.trim() || undefined, - forceResetCrossSigning: opts.forceResetCrossSigning === true, - }); - } finally { - await stopActionClient({ client, stopOnDone }); - } + return await withResolvedActionClient( + opts, + async (client) => + await client.bootstrapOwnDeviceVerification({ + recoveryKey: opts.recoveryKey?.trim() || undefined, + forceResetCrossSigning: opts.forceResetCrossSigning === true, + }), + "persist", + ); } diff --git a/extensions/matrix-js/src/matrix/client/config.ts b/extensions/matrix-js/src/matrix/client/config.ts index a38b0be84a8..975ec14298d 100644 --- a/extensions/matrix-js/src/matrix/client/config.ts +++ b/extensions/matrix-js/src/matrix/client/config.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; +import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js"; import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -83,32 +84,11 @@ export function hasReadyMatrixEnvAuth(config: { return Boolean(homeserver && (accessToken || (userId && password))); } -function findAccountConfig(cfg: CoreConfig, accountId: string): Record { - 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; - } - 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; - } - return {}; - } - } - return {}; -} - export function resolveMatrixConfig( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, env: NodeJS.ProcessEnv = process.env, ): MatrixResolvedConfig { - const matrix = cfg.channels?.["matrix-js"] ?? {}; + const matrix = resolveMatrixBaseConfig(cfg); const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env); const globalEnv = resolveGlobalMatrixEnvConfig(env); const homeserver = @@ -144,8 +124,8 @@ export function resolveMatrixConfigForAccount( accountId: string, env: NodeJS.ProcessEnv = process.env, ): MatrixResolvedConfig { - const matrix = cfg.channels?.["matrix-js"] ?? {}; - const account = findAccountConfig(cfg, accountId); + const matrix = resolveMatrixBaseConfig(cfg); + const account = findMatrixAccountConfig(cfg, accountId) ?? {}; const normalizedAccountId = normalizeAccountId(accountId); const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env); const globalEnv = resolveGlobalMatrixEnvConfig(env); @@ -285,7 +265,7 @@ export async function resolveMatrixAuth(params?: { cachedCredentials.userId !== userId || (cachedCredentials.deviceId || undefined) !== knownDeviceId; if (shouldRefreshCachedCredentials) { - saveMatrixCredentials( + await saveMatrixCredentials( { homeserver: resolved.homeserver, userId, @@ -296,7 +276,7 @@ export async function resolveMatrixAuth(params?: { accountId, ); } else if (hasMatchingCachedToken) { - touchMatrixCredentials(env, accountId); + await touchMatrixCredentials(env, accountId); } return { homeserver: resolved.homeserver, @@ -311,7 +291,7 @@ export async function resolveMatrixAuth(params?: { } if (cachedCredentials) { - touchMatrixCredentials(env, accountId); + await touchMatrixCredentials(env, accountId); return { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, @@ -367,7 +347,7 @@ export async function resolveMatrixAuth(params?: { encryption: resolved.encryption, }; - saveMatrixCredentials( + await saveMatrixCredentials( { homeserver: auth.homeserver, userId: auth.userId, diff --git a/extensions/matrix-js/src/matrix/credentials.test.ts b/extensions/matrix-js/src/matrix/credentials.test.ts new file mode 100644 index 00000000000..840b755be09 --- /dev/null +++ b/extensions/matrix-js/src/matrix/credentials.test.ts @@ -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(); + } + }); +}); diff --git a/extensions/matrix-js/src/matrix/credentials.ts b/extensions/matrix-js/src/matrix/credentials.ts index 71e3d6e5113..70a6d3c819b 100644 --- a/extensions/matrix-js/src/matrix/credentials.ts +++ b/extensions/matrix-js/src/matrix/credentials.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { writeJsonFileAtomically } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../runtime.js"; @@ -63,14 +64,11 @@ export function loadMatrixCredentials( } } -export function saveMatrixCredentials( +export async function saveMatrixCredentials( credentials: Omit, env: NodeJS.ProcessEnv = process.env, accountId?: string | null, -): void { - const dir = resolveMatrixCredentialsDir(env); - fs.mkdirSync(dir, { recursive: true }); - +): Promise { const credPath = resolveMatrixCredentialsPath(env, accountId); const existing = loadMatrixCredentials(env, accountId); @@ -82,13 +80,13 @@ export function saveMatrixCredentials( 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, accountId?: string | null, -): void { +): Promise { const existing = loadMatrixCredentials(env, accountId); if (!existing) { return; @@ -96,7 +94,7 @@ export function touchMatrixCredentials( existing.lastUsedAt = new Date().toISOString(); const credPath = resolveMatrixCredentialsPath(env, accountId); - fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); + await writeJsonFileAtomically(credPath, existing); } export function clearMatrixCredentials( diff --git a/extensions/matrix-js/src/matrix/monitor/handler.test.ts b/extensions/matrix-js/src/matrix/monitor/handler.test.ts index a1948819b2b..5d8e5d60243 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix-js/src/matrix/monitor/handler.test.ts @@ -2,7 +2,164 @@ import { describe, expect, it, vi } from "vitest"; import { createMatrixRoomMessageHandler } from "./handler.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", () => { + 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 () => { const readAllowFromStore = vi.fn(async () => [] as string[]); const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); @@ -57,7 +214,11 @@ describe("matrix monitor handler pairing account scope", () => { }, } as MatrixRawEvent); - expect(readAllowFromStore).toHaveBeenCalledWith("matrix-js", process.env, "poe"); + expect(readAllowFromStore).toHaveBeenCalledWith({ + channel: "matrix-js", + env: process.env, + accountId: "poe", + }); expect(upsertPairingRequest).toHaveBeenCalledWith({ channel: "matrix-js", id: "@user:example.org", diff --git a/extensions/matrix-js/src/matrix/monitor/handler.ts b/extensions/matrix-js/src/matrix/monitor/handler.ts index bf3c58bb0a3..c4c1393abdf 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.ts +++ b/extensions/matrix-js/src/matrix/monitor/handler.ts @@ -39,11 +39,15 @@ import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; import { EventType, RelationType } from "./types.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 = { client: MatrixClient; core: PluginRuntime; cfg: CoreConfig; - accountId?: string; + accountId: string; runtime: RuntimeEnv; logger: RuntimeLogger; logVerboseMessage: (message: string) => void; @@ -97,6 +101,50 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam getRoomInfo, getMemberDisplayName, } = params; + let cachedStoreAllowFrom: { + value: string[]; + expiresAtMs: number; + } | null = null; + const pairingReplySentAtMsBySender = new Map(); + + const readStoreAllowFrom = async (): Promise => { + 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) => { try { @@ -230,9 +278,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await core.channel.pairing - .readAllowFromStore("matrix-js", process.env, accountId) - .catch(() => []); + const storeAllowFrom = await readStoreAllowFrom(); const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? []; const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); @@ -256,23 +302,32 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam accountId, 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( - `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 { await sendMessageMatrix( `room:${roomId}`, - core.channel.pairing.buildPairingReply({ - channel: "matrix-js", - idLine: `Your Matrix user id: ${senderId}`, - code, - }), + created + ? pairingReply + : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`, { client }, ); } catch (err) { logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); } + } else { + logVerboseMessage( + `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, + ); } } if (dmPolicy !== "pairing") { diff --git a/extensions/matrix-js/src/matrix/monitor/index.ts b/extensions/matrix-js/src/matrix/monitor/index.ts index 0ceb7e0c5b8..b175de2ca5a 100644 --- a/extensions/matrix-js/src/matrix/monitor/index.ts +++ b/extensions/matrix-js/src/matrix/monitor/index.ts @@ -286,7 +286,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi client, core, cfg, - accountId: opts.accountId ?? undefined, + accountId: account.accountId, runtime, logger, logVerboseMessage, diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts index 2944321ddc7..f88f6441a4a 100644 --- a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts @@ -289,6 +289,7 @@ describe("MatrixVerificationManager", () => { }); it("does not auto-confirm SAS for verifications initiated by this device", async () => { + vi.useFakeTimers(); const confirm = vi.fn(async () => {}); const verifier = new MockVerifier( { @@ -312,11 +313,15 @@ describe("MatrixVerificationManager", () => { initiatedByMe: true, verifier, }); - const manager = new MatrixVerificationManager(); - manager.trackVerificationRequest(request); + try { + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(confirm).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(20); + expect(confirm).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); it("prunes stale terminal sessions during list operations", () => { diff --git a/extensions/matrix-js/src/matrix/send.test.ts b/extensions/matrix-js/src/matrix/send.test.ts index b31d73fd5a8..e4c5918921c 100644 --- a/extensions/matrix-js/src/matrix/send.test.ts +++ b/extensions/matrix-js/src/matrix/send.test.ts @@ -54,7 +54,14 @@ describe("sendMessageMatrix media", () => { }); 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); }); @@ -117,6 +124,72 @@ describe("sendMessageMatrix media", () => { expect(content.url).toBeUndefined(); 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", () => { diff --git a/extensions/matrix-js/src/matrix/send.ts b/extensions/matrix-js/src/matrix/send.ts index 7b85cb65ef4..768d336ed07 100644 --- a/extensions/matrix-js/src/matrix/send.ts +++ b/extensions/matrix-js/src/matrix/send.ts @@ -99,7 +99,11 @@ export async function sendMessageMatrix( const msgtype = useVoice ? MsgType.Audio : baseMsgType; const isImage = msgtype === MsgType.Image; const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) + ? await prepareImageInfo({ + buffer: media.buffer, + client, + encrypted: Boolean(uploaded.file), + }) : undefined; const [firstChunk, ...rest] = chunks; const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); diff --git a/extensions/matrix-js/src/matrix/send/client.ts b/extensions/matrix-js/src/matrix/send/client.ts index de311aebfca..75ff3204846 100644 --- a/extensions/matrix-js/src/matrix/send/client.ts +++ b/extensions/matrix-js/src/matrix/send/client.ts @@ -1,6 +1,6 @@ -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; +import { resolveMatrixAccountConfig } from "../accounts.js"; import { getActiveMatrixClient } from "../active-client.js"; import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; import type { MatrixClient } from "../sdk.js"; @@ -15,16 +15,8 @@ export function ensureNodeRuntime() { export function resolveMediaMaxBytes(accountId?: string | null): number | undefined { const cfg = getCore().config.loadConfig() as CoreConfig; - const matrixCfg = cfg.channels?.["matrix-js"]; - const accountCfg = accountId - ? (matrixCfg?.accounts?.[accountId] ?? matrixCfg?.accounts?.[normalizeAccountId(accountId)]) - : undefined; - const mediaMaxMb = - typeof accountCfg?.mediaMaxMb === "number" - ? accountCfg.mediaMaxMb - : typeof matrixCfg?.mediaMaxMb === "number" - ? matrixCfg.mediaMaxMb - : undefined; + const matrixCfg = resolveMatrixAccountConfig({ cfg, accountId }); + const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined; if (typeof mediaMaxMb === "number") { return mediaMaxMb * 1024 * 1024; } diff --git a/extensions/matrix-js/src/matrix/send/media.ts b/extensions/matrix-js/src/matrix/send/media.ts index 3d8a4a84089..03d5d98d324 100644 --- a/extensions/matrix-js/src/matrix/send/media.ts +++ b/extensions/matrix-js/src/matrix/send/media.ts @@ -113,6 +113,7 @@ const THUMBNAIL_QUALITY = 80; export async function prepareImageInfo(params: { buffer: Buffer; client: MatrixClient; + encrypted?: boolean; }): Promise { const meta = await getCore() .media.getImageMetadata(params.buffer) @@ -121,6 +122,10 @@ export async function prepareImageInfo(params: { return undefined; } 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); if (maxDim > THUMBNAIL_MAX_SIDE) { try { diff --git a/extensions/matrix-js/src/onboarding.test.ts b/extensions/matrix-js/src/onboarding.test.ts index 2368d12ee1f..559ba431fcc 100644 --- a/extensions/matrix-js/src/onboarding.test.ts +++ b/extensions/matrix-js/src/onboarding.test.ts @@ -15,6 +15,8 @@ describe("matrix onboarding", () => { MATRIX_USER_ID: process.env.MATRIX_USER_ID, MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN, 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_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, }; @@ -114,4 +116,48 @@ describe("matrix onboarding", () => { ), ).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__DEVICE_ID"); + expect(noteText).toContain("MATRIX__DEVICE_NAME"); + }); }); diff --git a/extensions/matrix-js/src/onboarding.ts b/extensions/matrix-js/src/onboarding.ts index ce58af6aee0..49ad0c4b9bd 100644 --- a/extensions/matrix-js/src/onboarding.ts +++ b/extensions/matrix-js/src/onboarding.ts @@ -59,8 +59,8 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { "Matrix requires a homeserver URL.", "Use an access token (recommended) or password login to an existing account.", "With access token: user ID is fetched automatically.", - "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", - "Per-account env vars: 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__HOMESERVER, MATRIX__USER_ID, MATRIX__ACCESS_TOKEN, MATRIX__PASSWORD, MATRIX__DEVICE_ID, MATRIX__DEVICE_NAME.", `Docs: ${formatDocsLink("/channels/matrix-js", "channels/matrix-js")}`, ].join("\n"), "Matrix setup", @@ -70,6 +70,7 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { async function promptMatrixAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; + accountId?: string; }): Promise { const { cfg, prompter } = params; const existingAllowFrom = cfg.channels?.["matrix-js"]?.dm?.allowFrom ?? []; diff --git a/extensions/matrix-js/src/resolve-targets.test.ts b/extensions/matrix-js/src/resolve-targets.test.ts index 3d6310534f8..4890df69aea 100644 --- a/extensions/matrix-js/src/resolve-targets.test.ts +++ b/extensions/matrix-js/src/resolve-targets.test.ts @@ -64,4 +64,29 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.id).toBe("!two:example.org"); 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); + }); }); diff --git a/extensions/matrix-js/src/resolve-targets.ts b/extensions/matrix-js/src/resolve-targets.ts index fb111da0c74..c4c95ee30b2 100644 --- a/extensions/matrix-js/src/resolve-targets.ts +++ b/extensions/matrix-js/src/resolve-targets.ts @@ -72,6 +72,37 @@ export async function resolveMatrixTargets(params: { runtime?: RuntimeEnv; }): Promise { const results: ChannelResolveResult[] = []; + const userLookupCache = new Map(); + const groupLookupCache = new Map(); + + const readUserMatches = async (query: string): Promise => { + 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 => { + 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) { const trimmed = input.trim(); if (!trimmed) { @@ -84,11 +115,7 @@ export async function resolveMatrixTargets(params: { continue; } try { - const matches = await listMatrixDirectoryPeersLive({ - cfg: params.cfg, - query: trimmed, - limit: 5, - }); + const matches = await readUserMatches(trimmed); const best = pickBestUserMatch(matches, trimmed); results.push({ input, @@ -104,11 +131,7 @@ export async function resolveMatrixTargets(params: { continue; } try { - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query: trimmed, - limit: 5, - }); + const matches = await readGroupMatches(trimmed); const best = pickBestGroupMatch(matches, trimmed); results.push({ input, diff --git a/extensions/matrix-js/src/types.ts b/extensions/matrix-js/src/types.ts index 94dc13549d7..1d04e63fed8 100644 --- a/extensions/matrix-js/src/types.ts +++ b/extensions/matrix-js/src/types.ts @@ -113,7 +113,7 @@ export type CoreConfig = { }; messages?: { ackReaction?: string; - ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off"; }; [key: string]: unknown; };