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 {
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;

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 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;

View File

@@ -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<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 { 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();
}
}
});
}

View File

@@ -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<T>(
opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> {
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(

View File

@@ -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<MatrixReactionSummary[]> {
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();
}
}
});
}

View File

@@ -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();
}
}
});
}

View File

@@ -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<void> {
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",
);
}

View File

@@ -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<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(
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,

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 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<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true });
): Promise<void> {
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<void> {
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(

View File

@@ -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",

View File

@@ -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<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) => {
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") {

View File

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

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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)");

View File

@@ -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;
}

View File

@@ -113,6 +113,7 @@ const THUMBNAIL_QUALITY = 80;
export async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
encrypted?: boolean;
}): Promise<DimensionalFileInfo | undefined> {
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 {

View File

@@ -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_<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.",
"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_<ACCOUNT_ID>_HOMESERVER, MATRIX_<ACCOUNT_ID>_USER_ID, MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN, MATRIX_<ACCOUNT_ID>_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, MATRIX_<ACCOUNT_ID>_DEVICE_ID, MATRIX_<ACCOUNT_ID>_DEVICE_NAME.",
`Docs: ${formatDocsLink("/channels/matrix-js", "channels/matrix-js")}`,
].join("\n"),
"Matrix setup",
@@ -70,6 +70,7 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
async function promptMatrixAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const { cfg, prompter } = params;
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?.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;
}): Promise<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) {
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,

View File

@@ -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;
};