mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Matrix-js: dedupe config helpers and harden monitoring/auth flows
This commit is contained in:
@@ -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;
|
||||
|
||||
37
extensions/matrix-js/src/matrix/account-config.ts
Normal file
37
extensions/matrix-js/src/matrix/account-config.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
extensions/matrix-js/src/matrix/credentials.test.ts
Normal file
80
extensions/matrix-js/src/matrix/credentials.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -286,7 +286,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
client,
|
||||
core,
|
||||
cfg,
|
||||
accountId: opts.accountId ?? undefined,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
logger,
|
||||
logVerboseMessage,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user