Matrix-js: sync with main plugin-loading standards

This commit is contained in:
Gustavo Madeira Santana
2026-03-04 10:17:22 -05:00
parent 115f24819e
commit 5fddbc1d9b
140 changed files with 21472 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
import {
createActionGate,
readNumberParam,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelToolSend,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { handleMatrixAction } from "./tool-actions.js";
import type { CoreConfig } from "./types.js";
export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions);
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
if (gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (gate("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (gate("memberInfo")) {
actions.add("member-info");
}
if (gate("channelInfo")) {
actions.add("channel-info");
}
if (account.config.encryption === true && gate("verification")) {
actions.add("permissions");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "poll",
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
return { to };
},
handleAction: async (ctx: ChannelMessageActionContext) => {
const { action, params, cfg } = ctx;
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
return await handleMatrixAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyTo ?? undefined,
threadId: threadId ?? undefined,
},
cfg as CoreConfig,
);
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", { required: true });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await handleMatrixAction(
{
action: "react",
roomId: resolveRoomId(),
messageId,
emoji,
remove,
},
cfg as CoreConfig,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleMatrixAction(
{
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
},
cfg as CoreConfig,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleMatrixAction(
{
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
},
cfg as CoreConfig,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await handleMatrixAction(
{
action: "editMessage",
roomId: resolveRoomId(),
messageId,
content,
},
cfg as CoreConfig,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleMatrixAction(
{
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
},
cfg as CoreConfig,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await handleMatrixAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
roomId: resolveRoomId(),
messageId,
},
cfg as CoreConfig,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await handleMatrixAction(
{
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
},
cfg as CoreConfig,
);
}
if (action === "channel-info") {
return await handleMatrixAction(
{
action: "channelInfo",
roomId: resolveRoomId(),
},
cfg as CoreConfig,
);
}
if (action === "permissions") {
const operation = (
readStringParam(params, "operation") ??
readStringParam(params, "mode") ??
"verification-list"
)
.trim()
.toLowerCase();
const operationToAction: Record<string, string> = {
"encryption-status": "encryptionStatus",
"verification-status": "verificationStatus",
"verification-bootstrap": "verificationBootstrap",
"verification-recovery-key": "verificationRecoveryKey",
"verification-backup-status": "verificationBackupStatus",
"verification-backup-restore": "verificationBackupRestore",
"verification-list": "verificationList",
"verification-request": "verificationRequest",
"verification-accept": "verificationAccept",
"verification-cancel": "verificationCancel",
"verification-start": "verificationStart",
"verification-generate-qr": "verificationGenerateQr",
"verification-scan-qr": "verificationScanQr",
"verification-sas": "verificationSas",
"verification-confirm": "verificationConfirm",
"verification-mismatch": "verificationMismatch",
"verification-confirm-qr": "verificationConfirmQr",
};
const resolvedAction = operationToAction[operation];
if (!resolvedAction) {
throw new Error(
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
operationToAction,
).join(", ")}`,
);
}
return await handleMatrixAction(
{
...params,
action: resolvedAction,
},
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider matrix-js.`);
},
};

View File

@@ -0,0 +1,397 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "./channel.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix directory", () => {
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
"matrix-js": {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
"matrix-js": {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("resolves group mention policy from account config", () => {
const cfg = {
channels: {
"matrix-js": {
groups: {
"!room:example.org": { requireMention: true },
},
accounts: {
Assistant: {
groups: {
"!room:example.org": { requireMention: false },
},
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
true,
);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "!room:example.org",
}),
).toBe(false);
});
it("writes matrix-js non-default account credentials under channels.matrix-js.accounts", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://default.example.org",
accessToken: "default-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "default-token",
homeserver: "https://default.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
});
});
it("writes default matrix-js account credentials under channels.matrix-js.accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
});
});
it("migrates legacy top-level matrix-js credentials into accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
register: false,
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.encryption).toBeUndefined();
expect((updated.channels?.["matrix-js"] as Record<string, unknown>)?.register).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
});
});
it("merges top-level object defaults into accounts.default during migration", () => {
const cfg = {
channels: {
"matrix-js": {
dm: {
policy: "allowlist",
allowFrom: ["@legacy:example.org"],
},
accounts: {
default: {
dm: {
policy: "pairing",
},
},
},
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.dm).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default?.dm).toMatchObject({
policy: "pairing",
allowFrom: ["@legacy:example.org"],
});
});
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
const envKeys = [
"MATRIX_OPS_HOMESERVER",
"MATRIX_OPS_USER_ID",
"MATRIX_OPS_ACCESS_TOKEN",
"MATRIX_OPS_PASSWORD",
] as const;
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
for (const key of envKeys) {
delete process.env[key];
}
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBe(
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
);
} finally {
for (const key of envKeys) {
if (previousEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = previousEnv[key];
}
}
}
});
it("accepts --use-env for non-default account when scoped env vars are present", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBeNull();
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("resolves account id from input name when explicit account id is missing", () => {
const accountId = matrixPlugin.setup!.resolveAccountId?.({
cfg: {} as CoreConfig,
accountId: undefined,
input: { name: "Main Bot" },
});
expect(accountId).toBe("main-bot");
});
it("resolves binding account id from agent id when omitted", () => {
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
cfg: {} as CoreConfig,
agentId: "Ops",
accountId: undefined,
});
expect(accountId).toBe("ops");
});
it("clears stale access token when switching an account to password auth", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "old-token",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "new-password",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBe("new-password");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBeUndefined();
});
it("clears stale password when switching an account to token auth", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "old-password",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
accessToken: "new-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBe("new-token");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
});
});

View File

@@ -0,0 +1,490 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelSetupInput,
type ChannelPlugin,
} from "openclaw/plugin-sdk/matrix-js";
import { matrixMessageActions } from "./actions.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
} from "./group-mentions.js";
import {
listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixAuth,
resolveScopedMatrixEnvConfig,
} from "./matrix/client.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.js";
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const meta = {
id: "matrix-js",
label: "Matrix-js",
selectionLabel: "Matrix-js (plugin)",
docsPath: "/channels/matrix-js",
docsLabel: "matrix-js",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
};
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim();
if (!normalized) {
return undefined;
}
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim();
}
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
return stripped || undefined;
}
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
const trimmed = avatarUrl?.trim();
return trimmed ? trimmed : undefined;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix-js",
meta,
onboarding: matrixOnboardingAdapter,
pairing: {
idLabel: "matrixUserId",
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix-js"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
clearBaseFields: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => {
const accountId = account.accountId;
const prefix =
accountId && accountId !== "default"
? `channels.matrix-js.accounts.${accountId}.dm`
: "channels.matrix-js.dm";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${prefix}.policy`,
allowFromPath: `${prefix}.allowFrom`,
approveHint: formatPairingApproveHint("matrix-js"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: (cfg as CoreConfig).channels?.["matrix-js"] !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}
return [
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix-js.groupPolicy="allowlist" + channels.matrix-js.groups (and optionally channels.matrix-js.groupAllowFrom) to restrict rooms.',
];
},
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: currentTarget?.trim() || undefined,
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
return true;
}
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) {
return `user:${cleaned}`;
}
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
if (raw.startsWith("!")) {
return `room:${raw}`;
}
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId, input }) =>
normalizeAccountId(accountId?.trim() || input?.name?.trim()),
resolveBindingAccountId: ({ agentId, accountId }) =>
normalizeAccountId(accountId?.trim() || agentId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix-js",
accountId,
name,
alwaysUseAccounts: true,
}),
validateInput: ({ accountId, input }) => {
const avatarUrl = resolveAvatarInput(input);
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
}
if (input.useEnv) {
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
const keys = getMatrixScopedEnvVarNames(accountId);
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
}
return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) {
return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig);
const namedConfig = applyAccountNameToChannelSection({
cfg: migratedConfig,
channelKey: "matrix-js",
accountId,
name: input.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "matrix-js",
});
if (input.useEnv) {
return setAccountEnabledInConfigSection({
cfg: next as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled: true,
}) as CoreConfig;
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
return updateMatrixAccountConfig(next as CoreConfig, accountId, {
homeserver: input.homeserver?.trim(),
userId: password && !userId ? null : userId,
accessToken: accessToken || (password ? null : undefined),
password: password || (accessToken ? null : undefined),
deviceName: input.deviceName?.trim(),
avatarUrl: resolveAvatarInput(input),
initialSyncLimit: input.initialSyncLimit,
});
},
},
outbound: matrixOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "matrix-js",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs, cfg }) => {
try {
const auth = await resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
userId: auth.userId,
timeoutMs,
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
elapsedMs: 0,
};
}
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastProbeAt: runtime?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.homeserver,
});
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
// Serialize startup: wait for any previous startup to complete import phase.
// This works around a race condition with concurrent dynamic imports.
//
// INVARIANT: The import() below cannot hang because:
// 1. It only loads local ESM modules with no circular awaits
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
// 3. The lock only serializes the import phase, not the provider startup
const previousLock = matrixStartupLock;
let releaseLock: () => void = () => {};
matrixStartupLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
await previousLock;
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
// Wrap in try/finally to ensure lock is released even if import fails.
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},
};

View File

@@ -0,0 +1,527 @@
import { Command } from "commander";
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
const getMatrixVerificationStatusMock = vi.fn();
const matrixSetupApplyAccountConfigMock = vi.fn();
const matrixSetupValidateInputMock = vi.fn();
const matrixRuntimeLoadConfigMock = vi.fn();
const matrixRuntimeWriteConfigFileMock = vi.fn();
const restoreMatrixRoomKeyBackupMock = vi.fn();
const setMatrixSdkLogModeMock = vi.fn();
const updateMatrixOwnProfileMock = vi.fn();
const verifyMatrixRecoveryKeyMock = vi.fn();
vi.mock("./matrix/actions/verification.js", () => ({
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
}));
vi.mock("./matrix/client/logging.js", () => ({
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
}));
vi.mock("./matrix/actions/profile.js", () => ({
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
}));
vi.mock("./channel.js", () => ({
matrixPlugin: {
setup: {
applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args),
validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args),
},
},
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args),
writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args),
},
}),
}));
let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli;
function buildProgram(): Command {
const program = new Command();
registerMatrixJsCli({ program });
return program;
}
function formatExpectedLocalTimestamp(value: string): string {
return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value;
}
describe("matrix-js CLI verification commands", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
process.exitCode = undefined;
({ registerMatrixJsCli } = await import("./cli.js"));
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
matrixSetupValidateInputMock.mockReturnValue(null);
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
matrixRuntimeLoadConfigMock.mockReturnValue({});
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
updateMatrixOwnProfileMock.mockResolvedValue({
skipped: false,
displayNameUpdated: true,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
});
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
});
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: false,
error: "invalid key",
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
bootstrapMatrixVerificationMock.mockResolvedValue({
success: false,
error: "bootstrap failed",
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: null,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
});
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
success: false,
error: "missing backup key",
backupVersion: null,
imported: 0,
total: 0,
loadedFromSecretStorage: false,
backup: {
serverVersion: "1",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
},
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
});
it("adds a matrix-js account and prints a binding hint", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
matrixSetupApplyAccountConfigMock.mockImplementation(
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
...cfg,
channels: {
...(cfg.channels as Record<string, unknown> | undefined),
"matrix-js": {
accounts: {
[accountId]: {
homeserver: "https://matrix.example.org",
},
},
},
},
}),
);
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"account",
"add",
"--account",
"Ops",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@ops:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "ops",
input: expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
password: "secret",
}),
}),
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: {
"matrix-js": {
accounts: {
ops: expect.objectContaining({
homeserver: "https://matrix.example.org",
}),
},
},
},
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops");
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:ops",
);
});
it("uses --name as fallback account id and prints account-scoped config path", async () => {
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"account",
"add",
"--name",
"Main Bot",
"--homeserver",
"https://matrix.example.org",
"--user-id",
"@main:example.org",
"--password",
"secret",
],
{ from: "user" },
);
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main-bot",
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.main-bot");
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main-bot",
displayName: "Main Bot",
}),
);
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:main-bot",
);
});
it("sets profile name and avatar via profile set command", async () => {
const program = buildProgram();
await program.parseAsync(
[
"matrix-js",
"profile",
"set",
"--account",
"alerts",
"--name",
"Alerts Bot",
"--avatar-url",
"mxc://example/avatar",
],
{ from: "user" },
);
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "alerts",
displayName: "Alerts Bot",
avatarUrl: "mxc://example/avatar",
}),
);
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(console.log).toHaveBeenCalledWith("Account: alerts");
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.alerts");
});
it("returns JSON errors for invalid account setup input", async () => {
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
const program = buildProgram();
await program.parseAsync(["matrix-js", "account", "add", "--json"], {
from: "user",
});
expect(process.exitCode).toBe(1);
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('"error": "Matrix requires --homeserver"'),
);
});
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
process.exitCode = 0;
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {},
crossSigning: {},
pendingVerifications: 0,
cryptoBootstrap: {},
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" });
expect(process.exitCode).toBe(0);
});
it("prints local timezone timestamps for verify status output in verbose mode", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: recoveryCreatedAt,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status", "--verbose"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
});
it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
const verifiedAt = "2026-02-25T20:14:00.000Z";
bootstrapMatrixVerificationMock.mockResolvedValue({
success: true,
verification: {
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyId: "SSSS",
recoveryKeyCreatedAt: recoveryCreatedAt,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
},
crossSigning: {
published: true,
masterKeyPublished: true,
selfSigningKeyPublished: true,
userSigningKeyPublished: true,
},
pendingVerifications: 0,
cryptoBootstrap: {},
});
verifyMatrixRecoveryKeyMock.mockResolvedValue({
success: true,
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
verified: true,
localVerified: true,
crossSigningVerified: true,
signedByOwner: true,
recoveryKeyStored: true,
recoveryKeyId: "SSSS",
recoveryKeyCreatedAt: recoveryCreatedAt,
verifiedAt,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "bootstrap", "--verbose"], {
from: "user",
});
await program.parseAsync(["matrix-js", "verify", "device", "valid-key", "--verbose"], {
from: "user",
});
expect(console.log).toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).toHaveBeenCalledWith(
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
);
});
it("keeps default output concise when verbose is not provided", async () => {
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "1",
backup: {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: recoveryCreatedAt,
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).not.toHaveBeenCalledWith(
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
);
expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0");
expect(console.log).not.toHaveBeenCalledWith("Diagnostics:");
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet");
});
it("shows explicit backup issue in default status output", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
backup: {
serverVersion: "5256",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
);
expect(console.log).toHaveBeenCalledWith(
"- Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
expect(console.log).not.toHaveBeenCalledWith(
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
});
it("includes key load failure details in status output", async () => {
getMatrixVerificationStatusMock.mockResolvedValue({
encryptionEnabled: true,
verified: true,
userId: "@bot:example.org",
deviceId: "DEVICE123",
backupVersion: "5256",
backup: {
serverVersion: "5256",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: "secret storage key is not available",
},
recoveryKeyStored: true,
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
pendingVerifications: 0,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" });
expect(console.log).toHaveBeenCalledWith(
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
);
});
it("prints backup health lines for verify backup status in verbose mode", async () => {
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
serverVersion: "2",
activeVersion: null,
trusted: true,
matchesDecryptionKey: false,
decryptionKeyCached: false,
keyLoadAttempted: true,
keyLoadError: null,
});
const program = buildProgram();
await program.parseAsync(["matrix-js", "verify", "backup", "status", "--verbose"], {
from: "user",
});
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
});
});

View File

@@ -0,0 +1,871 @@
import type { Command } from "commander";
import {
formatZonedTimestamp,
normalizeAccountId,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/matrix-js";
import { matrixPlugin } from "./channel.js";
import { updateMatrixOwnProfile } from "./matrix/actions/profile.js";
import {
bootstrapMatrixVerification,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
restoreMatrixRoomKeyBackup,
verifyMatrixRecoveryKey,
} from "./matrix/actions/verification.js";
import { setMatrixSdkLogMode } from "./matrix/client/logging.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { getMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
let matrixJsCliExitScheduled = false;
function scheduleMatrixJsCliExit(): void {
if (matrixJsCliExitScheduled || process.env.VITEST) {
return;
}
matrixJsCliExitScheduled = true;
// matrix-js-sdk rust crypto can leave background async work alive after command completion.
setTimeout(() => {
process.exit(process.exitCode ?? 0);
}, 0);
}
function markCliFailure(): void {
process.exitCode = 1;
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function printJson(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function formatLocalTimestamp(value: string | null | undefined): string | null {
if (!value) {
return null;
}
const parsed = new Date(value);
if (!Number.isFinite(parsed.getTime())) {
return value;
}
return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value;
}
function printTimestamp(label: string, value: string | null | undefined): void {
const formatted = formatLocalTimestamp(value);
if (formatted) {
console.log(`${label}: ${formatted}`);
}
}
function printAccountLabel(accountId?: string): void {
console.log(`Account: ${normalizeAccountId(accountId)}`);
}
function configureCliLogMode(verbose: boolean): void {
setMatrixSdkLogMode(verbose ? "default" : "quiet");
}
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} must be an integer`);
}
return parsed;
}
type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
useEnv: boolean;
profile: {
attempted: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
error?: string;
};
};
async function addMatrixJsAccount(params: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
}): Promise<MatrixCliAccountAddResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const setup = matrixPlugin.setup;
if (!setup?.applyAccountConfig) {
throw new Error("Matrix-js account setup is unavailable.");
}
const input: ChannelSetupInput & { avatarUrl?: string } = {
name: params.name,
avatarUrl: params.avatarUrl,
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
password: params.password,
deviceName: params.deviceName,
initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"),
useEnv: params.useEnv === true,
};
const accountId =
setup.resolveAccountId?.({
cfg,
accountId: params.account,
input,
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
const validationError = setup.validateInput?.({
cfg,
accountId,
input,
});
if (validationError) {
throw new Error(validationError);
}
const updated = setup.applyAccountConfig({
cfg,
accountId,
input,
}) as CoreConfig;
await runtime.config.writeConfigFile(updated as never);
const desiredDisplayName = input.name?.trim();
const desiredAvatarUrl = input.avatarUrl?.trim();
let profile: MatrixCliAccountAddResult["profile"] = {
attempted: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
if (desiredDisplayName || desiredAvatarUrl) {
try {
const synced = await updateMatrixOwnProfile({
accountId,
displayName: desiredDisplayName,
avatarUrl: desiredAvatarUrl,
});
let resolvedAvatarUrl = synced.resolvedAvatarUrl;
if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) {
const latestCfg = runtime.config.loadConfig() as CoreConfig;
const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, {
avatarUrl: synced.resolvedAvatarUrl,
});
await runtime.config.writeConfigFile(withAvatar as never);
resolvedAvatarUrl = synced.resolvedAvatarUrl;
}
profile = {
attempted: true,
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
};
} catch (err) {
profile = {
attempted: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
error: toErrorMessage(err),
};
}
}
return {
accountId,
configPath: `channels.matrix-js.accounts.${accountId}`,
useEnv: input.useEnv === true,
profile,
};
}
type MatrixCliProfileSetResult = {
accountId: string;
displayName: string | null;
avatarUrl: string | null;
profile: {
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
configPath: string;
};
async function setMatrixJsProfile(params: {
account?: string;
name?: string;
avatarUrl?: string;
}): Promise<MatrixCliProfileSetResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const displayName = params.name?.trim() || null;
const avatarUrl = params.avatarUrl?.trim() || null;
if (!displayName && !avatarUrl) {
throw new Error("Provide --name and/or --avatar-url.");
}
const synced = await updateMatrixOwnProfile({
accountId,
displayName: displayName ?? undefined,
avatarUrl: avatarUrl ?? undefined,
});
const persistedAvatarUrl =
synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl
? synced.resolvedAvatarUrl
: avatarUrl;
const updated = updateMatrixAccountConfig(cfg, accountId, {
name: displayName ?? undefined,
avatarUrl: persistedAvatarUrl ?? undefined,
});
await runtime.config.writeConfigFile(updated as never);
return {
accountId,
displayName,
avatarUrl: persistedAvatarUrl ?? null,
profile: {
displayNameUpdated: synced.displayNameUpdated,
avatarUpdated: synced.avatarUpdated,
resolvedAvatarUrl: synced.resolvedAvatarUrl,
convertedAvatarFromHttp: synced.convertedAvatarFromHttp,
},
configPath: `channels.matrix-js.accounts.${accountId}`,
};
}
type MatrixCliCommandConfig<TResult> = {
verbose: boolean;
json: boolean;
run: () => Promise<TResult>;
onText: (result: TResult, verbose: boolean) => void;
onJson?: (result: TResult) => unknown;
shouldFail?: (result: TResult) => boolean;
errorPrefix: string;
onJsonError?: (message: string) => unknown;
};
async function runMatrixCliCommand<TResult>(
config: MatrixCliCommandConfig<TResult>,
): Promise<void> {
configureCliLogMode(config.verbose);
try {
const result = await config.run();
if (config.json) {
printJson(config.onJson ? config.onJson(result) : result);
} else {
config.onText(result, config.verbose);
}
if (config.shouldFail?.(result)) {
markCliFailure();
}
} catch (err) {
const message = toErrorMessage(err);
if (config.json) {
printJson(config.onJsonError ? config.onJsonError(message) : { error: message });
} else {
console.error(`${config.errorPrefix}: ${message}`);
}
markCliFailure();
} finally {
scheduleMatrixJsCliExit();
}
}
type MatrixCliBackupStatus = {
serverVersion: string | null;
activeVersion: string | null;
trusted: boolean | null;
matchesDecryptionKey: boolean | null;
decryptionKeyCached: boolean | null;
keyLoadAttempted: boolean;
keyLoadError: string | null;
};
type MatrixCliVerificationStatus = {
encryptionEnabled: boolean;
verified: boolean;
userId: string | null;
deviceId: string | null;
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
recoveryKeyStored: boolean;
recoveryKeyCreatedAt: string | null;
pendingVerifications: number;
};
function resolveBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): MatrixCliBackupStatus {
return {
serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null,
activeVersion: status.backup?.activeVersion ?? null,
trusted: status.backup?.trusted ?? null,
matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null,
decryptionKeyCached: status.backup?.decryptionKeyCached ?? null,
keyLoadAttempted: status.backup?.keyLoadAttempted ?? false,
keyLoadError: status.backup?.keyLoadError ?? null,
};
}
type MatrixCliBackupIssueCode =
| "missing-server-backup"
| "key-load-failed"
| "key-not-loaded"
| "key-mismatch"
| "untrusted-signature"
| "inactive"
| "indeterminate"
| "ok";
type MatrixCliBackupIssue = {
code: MatrixCliBackupIssueCode;
summary: string;
message: string | null;
};
function yesNoUnknown(value: boolean | null): string {
if (value === true) {
return "yes";
}
if (value === false) {
return "no";
}
return "unknown";
}
function printBackupStatus(backup: MatrixCliBackupStatus): void {
console.log(`Backup server version: ${backup.serverVersion ?? "none"}`);
console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`);
console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`);
console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`);
console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`);
console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`);
if (backup.keyLoadError) {
console.log(`Backup key load error: ${backup.keyLoadError}`);
}
}
function printVerificationIdentity(status: {
userId: string | null;
deviceId: string | null;
}): void {
console.log(`User: ${status.userId ?? "unknown"}`);
console.log(`Device: ${status.deviceId ?? "unknown"}`);
}
function printVerificationBackupSummary(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupSummary(resolveBackupStatus(status));
}
function printVerificationBackupStatus(status: {
backupVersion: string | null;
backup?: MatrixCliBackupStatus;
}): void {
printBackupStatus(resolveBackupStatus(status));
}
function printVerificationGuidance(status: MatrixCliVerificationStatus): void {
printGuidance(buildVerificationGuidance(status));
}
function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue {
if (!backup.serverVersion) {
return {
code: "missing-server-backup",
summary: "missing on server",
message: "no room-key backup exists on the homeserver",
};
}
if (backup.decryptionKeyCached === false) {
if (backup.keyLoadError) {
return {
code: "key-load-failed",
summary: "present but backup key unavailable on this device",
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
};
}
if (backup.keyLoadAttempted) {
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message:
"backup decryption key is not loaded on this device (secret storage did not return a key)",
};
}
return {
code: "key-not-loaded",
summary: "present but backup key unavailable on this device",
message: "backup decryption key is not loaded on this device",
};
}
if (backup.matchesDecryptionKey === false) {
return {
code: "key-mismatch",
summary: "present but backup key mismatch on this device",
message: "backup key mismatch (this device does not have the matching backup decryption key)",
};
}
if (backup.trusted === false) {
return {
code: "untrusted-signature",
summary: "present but not trusted on this device",
message: "backup signature chain is not trusted by this device",
};
}
if (!backup.activeVersion) {
return {
code: "inactive",
summary: "present on server but inactive on this device",
message: "backup exists but is not active on this device",
};
}
if (
backup.trusted === null ||
backup.matchesDecryptionKey === null ||
backup.decryptionKeyCached === null
) {
return {
code: "indeterminate",
summary: "present but trust state unknown",
message: "backup trust state could not be fully determined",
};
}
return {
code: "ok",
summary: "active and trusted on this device",
message: null,
};
}
function printBackupSummary(backup: MatrixCliBackupStatus): void {
const issue = resolveBackupIssue(backup);
console.log(`Backup: ${issue.summary}`);
if (backup.serverVersion) {
console.log(`Backup version: ${backup.serverVersion}`);
}
}
function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] {
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
const nextSteps = new Set<string>();
if (!status.verified) {
nextSteps.add("Run 'openclaw matrix-js verify device <key>' to verify this device.");
}
if (backupIssue.code === "missing-server-backup") {
nextSteps.add("Run 'openclaw matrix-js verify bootstrap' to create a room key backup.");
} else if (
backupIssue.code === "key-load-failed" ||
backupIssue.code === "key-not-loaded" ||
backupIssue.code === "inactive"
) {
if (status.recoveryKeyStored) {
nextSteps.add(
"Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.",
);
} else {
nextSteps.add(
"Store a recovery key with 'openclaw matrix-js verify device <key>', then run 'openclaw matrix-js verify backup restore'.",
);
}
} else if (backupIssue.code === "key-mismatch") {
nextSteps.add(
"Backup key mismatch on this device. Re-run 'openclaw matrix-js verify device <key>' with the matching recovery key.",
);
} else if (backupIssue.code === "untrusted-signature") {
nextSteps.add(
"Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device <key>'.",
);
} else if (backupIssue.code === "indeterminate") {
nextSteps.add(
"Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.",
);
}
if (status.pendingVerifications > 0) {
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
}
return Array.from(nextSteps);
}
function printGuidance(lines: string[]): void {
if (lines.length === 0) {
return;
}
console.log("Next steps:");
for (const line of lines) {
console.log(`- ${line}`);
}
}
function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void {
console.log(`Verified: ${status.verified ? "yes" : "no"}`);
const backup = resolveBackupStatus(status);
const backupIssue = resolveBackupIssue(backup);
printVerificationBackupSummary(status);
if (backupIssue.message) {
console.log(`Backup issue: ${backupIssue.message}`);
}
if (verbose) {
console.log("Diagnostics:");
printVerificationIdentity(status);
printVerificationBackupStatus(status);
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${status.pendingVerifications}`);
} else {
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
}
printVerificationGuidance(status);
}
export function registerMatrixJsCli(params: { program: Command }): void {
const root = params.program
.command("matrix-js")
.description("Matrix-js channel utilities")
.addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix-js\n");
const account = root.command("account").description("Manage matrix-js channel accounts");
account
.command("add")
.description("Add or update a matrix-js account (wrapper around channel setup)")
.option("--account <id>", "Account ID (default: normalized --name, else default)")
.option("--name <name>", "Optional display name for this account")
.option("--avatar-url <url>", "Optional Matrix avatar URL (mxc:// or http(s) URL)")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")
.option("--password <password>", "Matrix password")
.option("--device-name <name>", "Matrix device display name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option(
"--use-env",
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
)
.option("--verbose", "Show setup details")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await addMatrixJsAccount({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
homeserver: options.homeserver,
userId: options.userId,
accessToken: options.accessToken,
password: options.password,
deviceName: options.deviceName,
initialSyncLimit: options.initialSyncLimit,
useEnv: options.useEnv === true,
}),
onText: (result) => {
console.log(`Saved matrix-js account: ${result.accountId}`);
console.log(`Config path: ${result.configPath}`);
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
);
if (result.profile.attempted) {
if (result.profile.error) {
console.error(`Profile sync warning: ${result.profile.error}`);
} else {
console.log(
`Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) {
console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`);
}
}
}
const bindHint = `openclaw agents bind --agent <id> --bind matrix-js:${result.accountId}`;
console.log(`Bind this account to an agent: ${bindHint}`);
},
errorPrefix: "Account setup failed",
});
},
);
const profile = root.command("profile").description("Manage Matrix-js bot profile");
profile
.command("set")
.description("Update Matrix profile display name and/or avatar")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--name <name>", "Profile display name")
.option("--avatar-url <url>", "Profile avatar URL (mxc:// or http(s) URL)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
name?: string;
avatarUrl?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await setMatrixJsProfile({
account: options.account,
name: options.name,
avatarUrl: options.avatarUrl,
}),
onText: (result) => {
printAccountLabel(result.accountId);
console.log(`Config path: ${result.configPath}`);
console.log(
`Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`,
);
if (result.profile.convertedAvatarFromHttp && result.avatarUrl) {
console.log(`Avatar converted and saved as: ${result.avatarUrl}`);
}
},
errorPrefix: "Profile update failed",
});
},
);
const verify = root.command("verify").description("Device verification for Matrix E2EE");
verify
.command("status")
.description("Check Matrix-js device verification status")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--include-recovery-key", "Include stored recovery key in output")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
verbose?: boolean;
includeRecoveryKey?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await getMatrixVerificationStatus({
accountId: options.account,
includeRecoveryKey: options.includeRecoveryKey === true,
}),
onText: (status, verbose) => {
printAccountLabel(options.account);
printVerificationStatus(status, verbose);
},
errorPrefix: "Error",
});
},
);
const backup = verify.command("backup").description("Matrix room-key backup health and restore");
backup
.command("status")
.description("Show Matrix room-key backup status for this device")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }),
onText: (status, verbose) => {
printAccountLabel(options.account);
printBackupSummary(status);
if (verbose) {
printBackupStatus(status);
}
},
errorPrefix: "Backup status failed",
});
});
backup
.command("restore")
.description("Restore encrypted room keys from server backup")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await restoreMatrixRoomKeyBackup({
accountId: options.account,
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}`);
}
console.log(`Backup version: ${result.backupVersion ?? "none"}`);
console.log(`Imported keys: ${result.imported}/${result.total}`);
printBackupSummary(result.backup);
if (verbose) {
console.log(
`Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`,
);
printTimestamp("Restored at", result.restoredAt);
printBackupStatus(result.backup);
}
},
shouldFail: (result) => !result.success,
errorPrefix: "Backup restore failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("bootstrap")
.description("Bootstrap Matrix-js cross-signing and device verification state")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (options: {
account?: string;
recoveryKey?: string;
forceResetCrossSigning?: boolean;
verbose?: boolean;
json?: boolean;
}) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
json: options.json === true,
run: async () =>
await bootstrapMatrixVerification({
accountId: options.account,
recoveryKey: options.recoveryKey,
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}`);
}
console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`);
printVerificationIdentity(result.verification);
if (verbose) {
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`,
);
printVerificationBackupStatus(result.verification);
printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt);
console.log(`Pending verifications: ${result.pendingVerifications}`);
} else {
console.log(
`Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`,
);
printVerificationBackupSummary(result.verification);
}
printVerificationGuidance({
...result.verification,
pendingVerifications: result.pendingVerifications,
});
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification bootstrap failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
verify
.command("device <key>")
.description("Verify device using a Matrix recovery key")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => {
await runMatrixCliCommand({
verbose: options.verbose === true,
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;
}
console.log("Device verification completed successfully.");
printVerificationIdentity(result);
printVerificationBackupSummary(result);
if (verbose) {
printVerificationBackupStatus(result);
printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt);
printTimestamp("Verified at", result.verifiedAt);
}
printVerificationGuidance({
...result,
pendingVerifications: 0,
});
},
shouldFail: (result) => !result.success,
errorPrefix: "Verification failed",
onJsonError: (message) => ({ success: false, error: message }),
});
},
);
}

View File

@@ -0,0 +1,133 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js";
type LegacyAccountField =
| "name"
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName"
| "initialSyncLimit"
| "encryption"
| "allowlistOnly"
| "groupPolicy"
| "groupAllowFrom"
| "replyToMode"
| "threadReplies"
| "textChunkLimit"
| "chunkMode"
| "responsePrefix"
| "mediaMaxMb"
| "autoJoin"
| "autoJoinAllowlist"
| "dm"
| "groups"
| "rooms"
| "actions";
const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"groupPolicy",
"groupAllowFrom",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeLegacyFieldIntoDefault(
current: MatrixAccountConfig[LegacyAccountField] | undefined,
legacy: MatrixAccountConfig[LegacyAccountField],
): MatrixAccountConfig[LegacyAccountField] {
if (current === undefined) {
return legacy;
}
if (isRecord(current) && isRecord(legacy)) {
return {
...legacy,
...current,
} as MatrixAccountConfig[LegacyAccountField];
}
return current;
}
function clearLegacyOnlyFields(nextMatrix: MatrixConfig): void {
// Legacy matrix-bot-sdk onboarding toggle; not used by matrix-js config.
delete (nextMatrix as Record<string, unknown>).register;
}
export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig {
const matrix = cfg.channels?.["matrix-js"];
if (!matrix) {
return cfg;
}
const defaultAccount = {
...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}),
} as MatrixAccountConfig;
let changed = false;
for (const field of LEGACY_ACCOUNT_FIELDS) {
const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined;
if (legacyValue === undefined) {
continue;
}
(
defaultAccount as Record<
LegacyAccountField,
MatrixAccountConfig[LegacyAccountField] | undefined
>
)[field] = mergeLegacyFieldIntoDefault(defaultAccount[field], legacyValue);
changed = true;
}
const registerPresent = (matrix as Record<string, unknown>).register !== undefined;
if (registerPresent) {
changed = true;
}
if (!changed) {
return cfg;
}
const nextMatrix = { ...matrix } as MatrixConfig;
for (const field of LEGACY_ACCOUNT_FIELDS) {
delete nextMatrix[field];
}
clearLegacyOnlyFields(nextMatrix);
nextMatrix.accounts = {
...matrix.accounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
};
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": nextMatrix,
},
};
}

View File

@@ -0,0 +1,66 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix-js";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),
})
.optional();
const matrixDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: z.array(allowFromEntry).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
avatarUrl: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
describe("matrix directory live", () => {
const cfg = { channels: { "matrix-js": {} } };
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [] }),
text: async () => "",
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes accountId to peer directory auth resolution", async () => {
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes accountId to group directory auth resolution", async () => {
await listMatrixDirectoryGroupsLive({
cfg,
accountId: "assistant",
query: "!room:example.org",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("returns no peer results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryPeersLive({
cfg,
query: " ",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryGroupsLive({
cfg,
query: "",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,208 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAuth } from "./matrix/client.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
type MatrixDirectoryLiveParams = {
cfg: unknown;
accountId?: string | null;
query?: string | null;
limit?: number | null;
};
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && limit > 0 ? limit : 20;
}
async function resolveMatrixDirectoryContext(
params: MatrixDirectoryLiveParams,
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
const query = normalizeQuery(params.query);
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return { query, auth };
}
function createGroupDirectoryEntry(params: {
id: string;
name: string;
handle?: string;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name,
handle: params.handle,
} satisfies ChannelDirectoryEntry;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
method: "POST",
body: {
search_term: query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = entry.user_id?.trim();
if (!userId) {
return null;
}
return {
kind: "user",
id: userId,
name: entry.display_name?.trim() || undefined,
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
}
if (query.startsWith("!")) {
return [createGroupDirectoryEntry({ id: query, name: query })];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) {
continue;
}
if (!name.toLowerCase().includes(query)) {
continue;
}
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) {
break;
}
}
return results;
}

View File

@@ -0,0 +1,52 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
return value.toLowerCase().startsWith(prefix.toLowerCase())
? value.slice(prefix.length).trim()
: value;
}
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
const rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
return resolveMatrixRoomConfig({
rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).config;
}
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveMatrixRoomConfigForGroup(params);
if (resolved) {
if (resolved.autoReply === true) {
return false;
}
if (resolved.autoReply === false) {
return true;
}
if (typeof resolved.requireMention === "boolean") {
return resolved.requireMention;
}
}
return true;
}
export function resolveMatrixGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const resolved = resolveMatrixRoomConfigForGroup(params);
return resolved?.tools;
}

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

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
];
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
});

View File

@@ -0,0 +1,127 @@
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";
/** Merge account config with top-level defaults, preserving nested objects. */
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
const merged = { ...base, ...account };
// Deep-merge known nested objects so partial overrides inherit base fields
for (const key of ["dm", "actions"] as const) {
const b = base[key];
const o = account[key];
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
(merged as Record<string, unknown>)[key] = { ...b, ...o };
}
}
// Don't propagate the accounts map into the merged per-account config
delete (merged as Record<string, unknown>).accounts;
return merged;
}
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = resolveMatrixAccountsMap(cfg);
if (Object.keys(accounts).length === 0) {
return [];
}
// Normalize and de-duplicate keys so listing and resolution use the same semantics
return [
...new Set(
Object.keys(accounts)
.filter(Boolean)
.map((id) => normalizeAccountId(id)),
),
];
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Fall back to default if no accounts configured (legacy top-level config)
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
const ids = listMatrixAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
return findMatrixAccountConfig(cfg, accountId);
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env, accountId);
const hasStored =
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,
name: base.name?.trim() || undefined,
configured,
homeserver: resolved.homeserver || undefined,
userId: resolved.userId || undefined,
config: base,
};
}
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const accountConfig = resolveAccountConfig(params.cfg, accountId);
if (!accountConfig) {
return matrixBase;
}
// Merge account-specific config with top-level defaults so settings like
// groupPolicy and blockStreaming inherit when not overridden.
return mergeAccountConfig(matrixBase, accountConfig);
}
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,35 @@
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { updateMatrixOwnProfile } from "./actions/profile.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const loadConfigMock = vi.fn(() => ({}));
const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
config: {
loadConfig: loadConfigMock,
},
}),
}));
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: getActiveMatrixClientMock,
}));
vi.mock("../client.js", () => ({
createMatrixClient: createMatrixClientMock,
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: resolveMatrixAuthMock,
}));
let resolveActionClient: typeof import("./client.js").resolveActionClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
describe("resolveActionClient", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveActionClient } = await import("./client.js"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await resolveActionClient({ accountId: "default" });
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
autoBootstrapCrypto: false,
}),
);
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveActionClient({ accountId: "default" });
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId: opts.accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
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

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixActionLimit } from "./limits.js";
describe("resolveMatrixActionLimit", () => {
it("uses fallback for non-finite values", () => {
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
});
it("normalizes finite numbers to positive integers", () => {
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
});
});

View File

@@ -0,0 +1,6 @@
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallback;
}
return Math.max(1, Math.floor(raw));
}

View File

@@ -0,0 +1,111 @@
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
});
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
});
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
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;
const dir = opts.after ? "f" : "b";
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
});
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
let pinned = [...seedPinned];
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
const sendStateEvent = vi.fn(
async (_roomId: string, _type: string, _key: string, payload: any) => {
pinned = [...payload.pinned];
},
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
const body = knownBodies[eventId];
if (!body) {
throw new Error("missing");
}
return {
event_id: eventId,
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 123,
content: { msgtype: "m.text", body },
};
});
return {
client: {
getRoomStateEvent,
sendStateEvent,
getEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getPinned: () => pinned,
sendStateEvent,
};
}
describe("matrix pins actions", () => {
it("pins a message once even when asked twice", async () => {
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
expect(first.pinned).toEqual(["$a", "$b"]);
expect(second.pinned).toEqual(["$a", "$b"]);
expect(getPinned()).toEqual(["$a", "$b"]);
expect(sendStateEvent).toHaveBeenCalledTimes(2);
});
it("unpinds only the selected message id", async () => {
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
expect(result.pinned).toEqual(["$a", "$c"]);
expect(getPinned()).toEqual(["$a", "$c"]);
});
it("lists pinned ids and summarizes only resolvable events", async () => {
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
const result = await listMatrixPins("!room:example.org", { client });
expect(result.pinned).toEqual(["$a", "$missing"]);
expect(result.events).toEqual([
expect.objectContaining({
eventId: "$a",
body: "hello",
}),
]);
});
});

View File

@@ -0,0 +1,79 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixActionClient,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
type ActionClient = MatrixActionClient["client"];
async function withResolvedPinRoom<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}
async function updateMatrixPins(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts,
update: (current: string[]) => string[],
): Promise<{ pinned: string[] }> {
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
const current = await readPinnedEvents(client, resolvedRoom);
const next = update(current);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
});
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.includes(messageId) ? current : [...current, messageId],
);
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.filter((id) => id !== messageId),
);
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
});
}

View File

@@ -0,0 +1,29 @@
import { getMatrixRuntime } from "../../runtime.js";
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
export async function updateMatrixOwnProfile(
opts: MatrixActionClientOpts & {
displayName?: string;
avatarUrl?: string;
} = {},
): Promise<MatrixProfileSyncResult> {
const displayName = opts.displayName?.trim();
const avatarUrl = opts.avatarUrl?.trim();
const runtime = getMatrixRuntime();
return await withResolvedActionClient(
opts,
async (client) => {
const userId = await client.getUserId();
return await syncMatrixOwnProfile({
client,
userId,
displayName: displayName || undefined,
avatarUrl: avatarUrl || undefined,
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
});
},
"persist",
);
}

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
function createReactionsClient(params: {
chunk: Array<{
event_id?: string;
sender?: string;
key?: string;
}>;
userId?: string | null;
}) {
const doRequest = vi.fn(async (_method: string, _path: string, _query: any) => ({
chunk: params.chunk.map((item) => ({
event_id: item.event_id ?? "",
sender: item.sender ?? "",
content: item.key
? {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$target",
key: item.key,
},
}
: {},
})),
}));
const getUserId = vi.fn(async () => params.userId ?? null);
const redactEvent = vi.fn(async () => undefined);
return {
client: {
doRequest,
getUserId,
redactEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
redactEvent,
};
}
describe("matrix reaction actions", () => {
it("aggregates reactions by key and unique sender", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
{ event_id: "$4", sender: "@bot:example.org" },
],
userId: "@bot:example.org",
});
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
expect.objectContaining({ limit: 2 }),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "👍",
count: 2,
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
}),
expect.objectContaining({
key: "👎",
count: 1,
users: ["@alice:example.org"],
}),
]),
);
});
it("removes only current-user reactions matching emoji filter", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
],
userId: "@me:example.org",
});
const result = await removeMatrixReactions("!room:example.org", "$msg", {
client,
emoji: "👍",
});
expect(result).toEqual({ removed: 1 });
expect(redactEvent).toHaveBeenCalledTimes(1);
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
});
it("returns removed=0 when current user id is unavailable", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
userId: null,
});
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual({ removed: 0 });
expect(redactEvent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,86 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import {
EventType,
RelationType,
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
type ReactionEventContent,
} from "./types.js";
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// Relations are queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
)) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) {
continue;
}
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
users: [],
};
entry.count += 1;
if (sender && !entry.users.includes(sender)) {
entry.users.push(sender);
}
summaries.set(key, entry);
}
return Array.from(summaries.values());
});
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
)) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) {
return { removed: 0 };
}
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) {
return true;
}
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) {
return { removed: 0 };
}
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
});
}

View File

@@ -0,0 +1,72 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
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.
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
});
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
});
}

View File

@@ -0,0 +1,75 @@
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
if (raw.unsigned?.redacted_because) {
return null;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View File

@@ -0,0 +1,75 @@
import type { MatrixClient, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixReactionSummary = {
key: string;
count: number;
users: string[];
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

View File

@@ -0,0 +1,285 @@
import { withResolvedActionClient } from "./client.js";
import type { MatrixActionClientOpts } from "./types.js";
function requireCrypto(
client: import("../sdk.js").MatrixClient,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
throw new Error(
"Matrix encryption is not available (enable channels.matrix-js.encryption=true)",
);
}
return client.crypto;
}
function resolveVerificationId(input: string): string {
const normalized = input.trim();
if (!normalized) {
throw new Error("Matrix verification request id is required");
}
return normalized;
}
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(
opts,
async (client) => {
const crypto = requireCrypto(client);
return await crypto.listVerifications();
},
"persist",
);
}
export async function requestMatrixVerification(
params: MatrixActionClientOpts & {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
} = {},
) {
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 = {},
) {
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 } = {},
) {
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" } = {},
) {
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 = {},
) {
return await withResolvedActionClient(
opts,
async (client) => {
const crypto = requireCrypto(client);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
},
"persist",
);
}
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts = {},
) {
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 = {},
) {
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 = {},
) {
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 = {},
) {
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 = {},
) {
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 } = {},
) {
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 } = {},
) {
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 = {}) {
return await withResolvedActionClient(
opts,
async (client) => await client.getRoomKeyBackupStatus(),
"persist",
);
}
export async function verifyMatrixRecoveryKey(
recoveryKey: string,
opts: MatrixActionClientOpts = {},
) {
return await withResolvedActionClient(
opts,
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
"persist",
);
}
export async function restoreMatrixRoomKeyBackup(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
} = {},
) {
return await withResolvedActionClient(
opts,
async (client) =>
await client.restoreRoomKeyBackup({
recoveryKey: opts.recoveryKey?.trim() || undefined,
}),
"persist",
);
}
export async function bootstrapMatrixVerification(
opts: MatrixActionClientOpts & {
recoveryKey?: string;
forceResetCrossSigning?: boolean;
} = {},
) {
return await withResolvedActionClient(
opts,
async (client) =>
await client.bootstrapOwnDeviceVerification({
recoveryKey: opts.recoveryKey?.trim() || undefined,
forceResetCrossSigning: opts.forceResetCrossSigning === true,
}),
"persist",
);
}

View File

@@ -0,0 +1,26 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MatrixClient } from "./sdk.js";
const activeClients = new Map<string, MatrixClient>();
function resolveAccountKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized || DEFAULT_ACCOUNT_ID;
}
export function setActiveMatrixClient(
client: MatrixClient | null,
accountId?: string | null,
): void {
const key = resolveAccountKey(accountId);
if (!client) {
activeClients.delete(key);
return;
}
activeClients.set(key, client);
}
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
const key = resolveAccountKey(accountId);
return activeClients.get(key) ?? null;
}

View File

@@ -0,0 +1,39 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
};
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
userId: opts.auth.userId,
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off requests.
}
}
await client.start();
return client;
}

View File

@@ -0,0 +1,314 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: saveMatrixCredentialsMock,
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
});
describe("resolveMatrixAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("surfaces password login errors when account credentials are invalid", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
await expect(
resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Invalid username or password");
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("resolves missing whoami identity fields for token auth", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
});

View File

@@ -0,0 +1,17 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixConfig,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {
resolveSharedMatrixClient,
waitForMatrixSync,
stopSharedClient,
stopSharedClientForAccount,
} from "./client/shared.js";

View File

@@ -0,0 +1,362 @@
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";
function clean(value?: string): string {
return value?.trim() ?? "";
}
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined,
password: clean(env.MATRIX_PASSWORD) || undefined,
deviceId: clean(env.MATRIX_DEVICE_ID) || undefined,
deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined,
};
}
function resolveMatrixEnvAccountToken(accountId: string): string {
return normalizeAccountId(accountId)
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
export function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]) || undefined,
password: clean(env[keys.password]) || undefined,
deviceId: clean(env[keys.deviceId]) || undefined,
deviceName: clean(env[keys.deviceName]) || undefined,
};
}
export function hasReadyMatrixEnvAuth(config: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
}): boolean {
const homeserver = clean(config.homeserver);
const userId = clean(config.userId);
const accessToken = clean(config.accessToken);
const password = clean(config.password);
return Boolean(homeserver && (accessToken || (userId && password)));
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const homeserver =
clean(matrix.homeserver) || defaultScopedEnv.homeserver || globalEnv.homeserver;
const userId = clean(matrix.userId) || defaultScopedEnv.userId || globalEnv.userId;
const accessToken =
clean(matrix.accessToken) || defaultScopedEnv.accessToken || globalEnv.accessToken || undefined;
const password =
clean(matrix.password) || defaultScopedEnv.password || globalEnv.password || undefined;
const deviceId =
clean(matrix.deviceId) || defaultScopedEnv.deviceId || globalEnv.deviceId || undefined;
const deviceName =
clean(matrix.deviceName) || defaultScopedEnv.deviceName || globalEnv.deviceName || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export function resolveMatrixConfigForAccount(
cfg: CoreConfig,
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const accountHomeserver = clean(
typeof account.homeserver === "string" ? account.homeserver : undefined,
);
const accountUserId = clean(typeof account.userId === "string" ? account.userId : undefined);
const accountAccessToken = clean(
typeof account.accessToken === "string" ? account.accessToken : undefined,
);
const accountPassword = clean(
typeof account.password === "string" ? account.password : undefined,
);
const accountDeviceId = clean(
typeof account.deviceId === "string" ? account.deviceId : undefined,
);
const accountDeviceName = clean(
typeof account.deviceName === "string" ? account.deviceName : undefined,
);
const homeserver =
accountHomeserver || scopedEnv.homeserver || clean(matrix.homeserver) || globalEnv.homeserver;
const userId = accountUserId || scopedEnv.userId || clean(matrix.userId) || globalEnv.userId;
const accessToken =
accountAccessToken ||
scopedEnv.accessToken ||
clean(matrix.accessToken) ||
globalEnv.accessToken ||
undefined;
const password =
accountPassword ||
scopedEnv.password ||
clean(matrix.password) ||
globalEnv.password ||
undefined;
const deviceId =
accountDeviceId ||
scopedEnv.deviceId ||
clean(matrix.deviceId) ||
globalEnv.deviceId ||
undefined;
const deviceName =
accountDeviceName ||
scopedEnv.deviceName ||
clean(matrix.deviceName) ||
globalEnv.deviceName ||
undefined;
const accountInitialSyncLimit =
typeof account.initialSyncLimit === "number"
? Math.max(0, Math.floor(account.initialSyncLimit))
: undefined;
const initialSyncLimit =
accountInitialSyncLimit ??
(typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined);
const encryption =
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const accountId = params?.accountId;
const resolved = accountId
? resolveMatrixConfigForAccount(cfg, accountId, env)
: resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix-js.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
let knownDeviceId = hasMatchingCachedToken
? cachedCredentials?.deviceId || resolved.deviceId
: resolved.deviceId;
if (!userId || !knownDeviceId) {
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
device_id?: string;
};
if (!userId) {
const fetchedUserId = whoami.user_id?.trim();
if (!fetchedUserId) {
throw new Error("Matrix whoami did not return user_id");
}
userId = fetchedUserId;
}
if (!knownDeviceId) {
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
}
}
const shouldRefreshCachedCredentials =
!cachedCredentials ||
!hasMatchingCachedToken ||
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
await saveMatrixCredentials(
{
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
},
env,
accountId,
);
} else if (hasMatchingCachedToken) {
await touchMatrixCredentials(env, accountId);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
password: resolved.password,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
await touchMatrixCredentials(env, accountId);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
password: resolved.password,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix-js.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix-js.password)",
);
}
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
password: resolved.password,
deviceId: login.device_id ?? resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
await saveMatrixCredentials(
{
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
},
env,
accountId,
);
return auth;
}

View File

@@ -0,0 +1,58 @@
import fs from "node:fs";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
export async function createMatrixClient(params: {
homeserver: string;
userId?: string;
accessToken: string;
password?: string;
deviceId?: string;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
autoBootstrapCrypto?: boolean;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const userId = params.userId?.trim() || "unknown";
const matrixClientUserId = params.userId?.trim() || undefined;
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId,
accountId: params.accountId,
});
const cryptoDatabasePrefix = `openclaw-matrix-js-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
password: params.password,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
autoBootstrapCrypto: params.autoBootstrapCrypto,
});
}

View File

@@ -0,0 +1,100 @@
import { ConsoleLogger, LogService } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
debug: (...messageOrObject: unknown[]) => void;
info: (...messageOrObject: unknown[]) => void;
warn: (...messageOrObject: unknown[]) => void;
error: (...messageOrObject: unknown[]) => void;
getChild: (namespace: string) => MatrixJsSdkLogger;
};
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (!matrixSdkLoggingConfigured) {
matrixSdkLoggingConfigured = true;
}
applyMatrixSdkLogger();
}
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
matrixSdkLogMode = mode;
if (!matrixSdkLoggingConfigured) {
return;
}
applyMatrixSdkLogger();
}
export function createMatrixJsSdkClientLogger(prefix = "matrix-js"): MatrixJsSdkLogger {
return createMatrixJsSdkLoggerInstance(prefix);
}
function applyMatrixSdkLogger(): void {
if (matrixSdkLogMode === "quiet") {
LogService.setLogger({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
return;
}
LogService.setLogger({
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
if (matrixSdkLogMode === "quiet") {
return;
}
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
prefix,
...messageOrObject,
);
};
return {
trace: (...messageOrObject) => log("trace", ...messageOrObject),
debug: (...messageOrObject) => log("debug", ...messageOrObject),
info: (...messageOrObject) => log("info", ...messageOrObject),
warn: (...messageOrObject) => log("warn", ...messageOrObject),
error: (...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
return;
}
log("error", ...messageOrObject);
},
getChild: (namespace: string) => {
const nextNamespace = namespace.trim();
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
},
};
}

View File

@@ -0,0 +1,4 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "./types.js";
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
const createMatrixClientMock = vi.hoisted(() => vi.fn());
vi.mock("./config.js", () => ({
resolveMatrixAuth: resolveMatrixAuthMock,
}));
vi.mock("./create-client.js", () => ({
createMatrixClient: createMatrixClientMock,
}));
import {
resolveSharedMatrixClient,
stopSharedClient,
stopSharedClientForAccount,
} from "./shared.js";
function authFor(accountId: string): MatrixAuth {
return {
homeserver: "https://matrix.example.org",
userId: `@${accountId}:example.org`,
accessToken: `token-${accountId}`,
password: "secret",
deviceId: `${accountId.toUpperCase()}-DEVICE`,
deviceName: `${accountId} device`,
initialSyncLimit: undefined,
encryption: false,
};
}
function createMockClient(name: string) {
const client = {
name,
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
getJoinedRooms: vi.fn(async () => [] as string[]),
crypto: undefined,
};
return client;
}
describe("resolveSharedMatrixClient", () => {
beforeEach(() => {
resolveMatrixAuthMock.mockReset();
createMatrixClientMock.mockReset();
});
afterEach(() => {
stopSharedClient();
vi.clearAllMocks();
});
it("keeps account clients isolated when resolves are interleaved", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
expect(firstMain).toBe(mainClient);
expect(firstPoe).toBe(poeClient);
expect(secondMain).toBe(mainClient);
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
expect(mainClient.start).toHaveBeenCalledTimes(1);
expect(poeClient.start).toHaveBeenCalledTimes(0);
});
it("stops only the targeted account client", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
stopSharedClientForAccount(mainAuth, "main");
expect(mainClient.stop).toHaveBeenCalledTimes(1);
expect(poeClient.stop).toHaveBeenCalledTimes(0);
stopSharedClient();
expect(poeClient.stop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,193 @@
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
startPromise: Promise<void> | null;
};
const sharedClientStates = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
password: params.auth.password,
deviceId: params.auth.deviceId,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
startPromise: null,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) {
return;
}
if (params.state.startPromise) {
await params.state.startPromise;
return;
}
params.state.startPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
try {
await params.state.startPromise;
} finally {
params.state.startPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth =
params.auth ??
(await resolveMatrixAuth({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
}));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
const existingState = sharedClientStates.get(key);
if (existingState) {
if (shouldStart) {
await ensureSharedClientStarted({
state: existingState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return existingState.client;
}
const existingPromise = sharedClientPromises.get(key);
if (existingPromise) {
const pending = await existingPromise;
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
const creationPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
sharedClientPromises.set(key, creationPromise);
try {
const created = await creationPromise;
sharedClientStates.set(key, created);
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromises.delete(key);
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-js-sdk handles sync lifecycle in start() for this integration.
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
for (const state of sharedClientStates.values()) {
state.client.stop();
}
sharedClientStates.clear();
sharedClientPromises.clear();
}
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, accountId);
const state = sharedClientStates.get(key);
if (!state) {
return;
}
state.client.stop();
sharedClientStates.delete(key);
sharedClientPromises.delete(key);
}

View File

@@ -0,0 +1,134 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizePathSegment(url.host);
}
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "credentials", "matrix-js", "bot-storage.json"),
cryptoPath: path.join(stateDir, "credentials", "matrix-js", "crypto"),
};
}
export function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"credentials",
"matrix-js",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"),
accountKey,
tokenHash,
};
}
export function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): void {
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) {
return;
}
if (hasNewStorage) {
return;
}
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
export function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8");
} catch {
// ignore meta write failures
}
}

View File

@@ -0,0 +1,39 @@
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
password?: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
export type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
recoveryKeyPath: string;
idbSnapshotPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "../types.js";
import { updateMatrixAccountConfig } from "./config-update.js";
describe("updateMatrixAccountConfig", () => {
it("supports explicit null clears and boolean false values", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "old-token",
password: "old-password",
encryption: true,
},
},
},
},
} as CoreConfig;
const updated = updateMatrixAccountConfig(cfg, "default", {
accessToken: "new-token",
password: null,
userId: null,
encryption: false,
});
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "new-token",
encryption: false,
});
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default?.userId).toBeUndefined();
});
it("normalizes account id and defaults account enabled=true", () => {
const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", {
name: "Main Bot",
homeserver: "https://matrix.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.["main-bot"]).toMatchObject({
name: "Main Bot",
homeserver: "https://matrix.example.org",
enabled: true,
});
});
});

View File

@@ -0,0 +1,102 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixConfig } from "../types.js";
export type MatrixAccountPatch = {
name?: string | null;
enabled?: boolean;
homeserver?: string | null;
userId?: string | null;
accessToken?: string | null;
password?: string | null;
deviceName?: string | null;
avatarUrl?: string | null;
encryption?: boolean | null;
initialSyncLimit?: number | null;
};
function applyNullableStringField(
target: Record<string, unknown>,
key: keyof MatrixAccountPatch,
value: string | null | undefined,
): void {
if (value === undefined) {
return;
}
if (value === null) {
delete target[key];
return;
}
const trimmed = value.trim();
if (!trimmed) {
delete target[key];
return;
}
target[key] = trimmed;
}
export function updateMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,
patch: MatrixAccountPatch,
): CoreConfig {
const matrix = cfg.channels?.["matrix-js"] ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? {}) as MatrixConfig;
const nextAccount: Record<string, unknown> = { ...existingAccount };
if (patch.name !== undefined) {
if (patch.name === null) {
delete nextAccount.name;
} else {
const trimmed = patch.name.trim();
if (trimmed) {
nextAccount.name = trimmed;
} else {
delete nextAccount.name;
}
}
}
if (typeof patch.enabled === "boolean") {
nextAccount.enabled = patch.enabled;
} else if (typeof nextAccount.enabled !== "boolean") {
nextAccount.enabled = true;
}
applyNullableStringField(nextAccount, "homeserver", patch.homeserver);
applyNullableStringField(nextAccount, "userId", patch.userId);
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
applyNullableStringField(nextAccount, "password", patch.password);
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
if (patch.initialSyncLimit !== undefined) {
if (patch.initialSyncLimit === null) {
delete nextAccount.initialSyncLimit;
} else {
nextAccount.initialSyncLimit = Math.max(0, Math.floor(patch.initialSyncLimit));
}
}
if (patch.encryption !== undefined) {
if (patch.encryption === null) {
delete nextAccount.encryption;
} else {
nextAccount.encryption = patch.encryption;
}
}
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...matrix,
enabled: true,
accounts: {
...matrix.accounts,
[normalizedAccountId]: nextAccount as MatrixConfig,
},
},
},
};
}

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

@@ -0,0 +1,123 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
function credentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (normalized === DEFAULT_ACCOUNT_ID) {
return "credentials.json";
}
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
// Different raw IDs that normalize to the same value are the same logical account.
return `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix-js");
}
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, credentialsFilename(accountId));
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credPath)) {
return null;
}
const raw = fs.readFileSync(credPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return parsed as MatrixStoredCredentials;
} catch {
return null;
}
}
export async function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
const credPath = resolveMatrixCredentialsPath(env, accountId);
const existing = loadMatrixCredentials(env, accountId);
const now = new Date().toISOString();
const toSave: MatrixStoredCredentials = {
...credentials,
createdAt: existing?.createdAt ?? now,
lastUsedAt: now,
};
await writeJsonFileAtomically(credPath, toSave);
}
export async function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
const existing = loadMatrixCredentials(env, accountId);
if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env, accountId);
await writeJsonFileAtomically(credPath, existing);
}
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
fs.unlinkSync(credPath);
}
} catch {
// ignore
}
}
export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): boolean {
// If userId is empty (token-based auth), only match homeserver
if (!config.userId) {
return stored.homeserver === config.homeserver;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@@ -0,0 +1,157 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
function resolveMissingMatrixPackages(): string[] {
try {
const req = createRequire(import.meta.url);
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
try {
req.resolve(pkg);
return false;
} catch {
return true;
}
});
} catch {
return [...REQUIRED_MATRIX_PACKAGES];
}
}
export function isMatrixSdkAvailable(): boolean {
return resolveMissingMatrixPackages().length === 0;
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
}
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(params: {
argv: string[];
cwd: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
}): Promise<CommandResult> {
return await new Promise((resolve) => {
const [command, ...args] = params.argv;
if (!command) {
resolve({
code: 1,
stdout: "",
stderr: "command is required",
});
return;
}
const proc = spawn(command, args, {
cwd: params.cwd,
env: { ...process.env, ...params.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const finalize = (result: CommandResult) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
timer = setTimeout(() => {
proc.kill("SIGKILL");
finalize({
code: 124,
stdout,
stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
});
}, params.timeoutMs);
proc.on("error", (err) => {
finalize({
code: 1,
stdout,
stderr: err.message,
});
});
proc.on("close", (code) => {
finalize({
code: code ?? 1,
stdout,
stderr,
});
});
});
}
export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;
}): Promise<void> {
if (isMatrixSdkAvailable()) {
return;
}
const confirm = params.confirm;
if (confirm) {
const ok = await confirm(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
);
if (!ok) {
throw new Error(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
);
}
}
const root = resolvePluginRoot();
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await runFixedCommandWithTimeout({
argv: command,
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
if (result.code !== 0) {
throw new Error(
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
);
}
if (!isMatrixSdkAvailable()) {
const missing = resolveMissingMatrixPackages();
throw new Error(
missing.length > 0
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
: "Matrix dependency install completed but Matrix dependencies are still missing.",
);
}
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { markdownToMatrixHtml } from "./format.js";
describe("markdownToMatrixHtml", () => {
it("renders basic inline formatting", () => {
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
expect(html).toContain("<em>there</em>");
expect(html).toContain("<strong>boss</strong>");
expect(html).toContain("<code>code</code>");
});
it("renders links as HTML", () => {
const html = markdownToMatrixHtml("see [docs](https://example.com)");
expect(html).toContain('<a href="https://example.com">docs</a>');
});
it("escapes raw HTML", () => {
const html = markdownToMatrixHtml("<b>nope</b>");
expect(html).toContain("&lt;b&gt;nope&lt;/b&gt;");
expect(html).not.toContain("<b>nope</b>");
});
it("flattens images into alt text", () => {
const html = markdownToMatrixHtml("![alt](https://example.com/img.png)");
expect(html).toContain("alt");
expect(html).not.toContain("<img");
});
it("preserves line breaks", () => {
const html = markdownToMatrixHtml("line1\nline2");
expect(html).toContain("<br");
});
});

View File

@@ -0,0 +1,22 @@
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
typographer: false,
});
md.enable("strikethrough");
const { escapeHtml } = md.utils;
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
export function markdownToMatrixHtml(markdown: string): string {
const rendered = md.render(markdown ?? "");
return rendered.trimEnd();
}

View File

@@ -0,0 +1,11 @@
export { monitorMatrixProvider } from "./monitor/index.js";
export { probeMatrix } from "./probe.js";
export {
reactMatrixMessage,
resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix,
sendPollMatrix,
sendTypingMatrix,
} from "./send.js";
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
describe("resolveMatrixAllowListMatch", () => {
it("matches full user IDs and prefixes", () => {
const userId = "@Alice:Example.org";
const direct = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
userId,
});
expect(direct.allowed).toBe(true);
expect(direct.matchSource).toBe("id");
const prefixedMatrix = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
userId,
});
expect(prefixedMatrix.allowed).toBe(true);
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
const prefixedUser = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
userId,
});
expect(prefixedUser.allowed).toBe(true);
expect(prefixedUser.matchSource).toBe("prefixed-user");
});
it("ignores display names and localparts", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(false);
});
it("matches wildcard", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["*"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(true);
expect(match.matchSource).toBe("wildcard");
});
});

View File

@@ -0,0 +1,103 @@
import type { AllowlistMatch } from "openclaw/plugin-sdk/matrix-js";
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
function normalizeMatrixUser(raw?: string | null): string {
const value = (raw ?? "").trim();
if (!value) {
return "";
}
if (!value.startsWith("@") || !value.includes(":")) {
return value.toLowerCase();
}
const withoutAt = value.slice(1);
const splitIndex = withoutAt.indexOf(":");
if (splitIndex === -1) {
return value.toLowerCase();
}
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
if (!server) {
return value.toLowerCase();
}
return `@${localpart}:${server.toLowerCase()}`;
}
export function normalizeMatrixUserId(raw?: string | null): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return normalizeMatrixUser(trimmed.slice("matrix:".length));
}
if (lowered.startsWith("user:")) {
return normalizeMatrixUser(trimmed.slice("user:".length));
}
return normalizeMatrixUser(trimmed);
}
function normalizeMatrixAllowListEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return trimmed;
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
}
if (lowered.startsWith("user:")) {
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
}
return normalizeMatrixUser(trimmed);
}
export function normalizeMatrixAllowList(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
}
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) {
return { allowed: false };
}
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
];
for (const candidate of candidates) {
if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
return resolveMatrixAllowListMatch(params).allowed;
}

View File

@@ -0,0 +1,127 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
function createClientStub() {
let inviteHandler: InviteHandler | null = null;
const client = {
on: vi.fn((eventName: string, listener: unknown) => {
if (eventName === "room.invite") {
inviteHandler = listener as InviteHandler;
}
return client;
}),
joinRoom: vi.fn(async () => {}),
getRoomStateEvent: vi.fn(async () => ({})),
} as unknown as import("../sdk.js").MatrixClient;
return {
client,
getInviteHandler: () => inviteHandler,
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
.getRoomStateEvent,
};
}
describe("registerMatrixAutoJoin", () => {
beforeEach(() => {
setMatrixRuntime({
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
});
it("joins all invites when autoJoin=always", async () => {
const { client, getInviteHandler, joinRoom } = createClientStub();
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "always",
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#other:example.org",
alt_aliases: ["#else:example.org"],
});
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).not.toHaveBeenCalled();
});
it("joins invite when alias matches allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#allowed:example.org",
alt_aliases: ["#backup:example.org"],
});
const cfg: CoreConfig = {
channels: {
"matrix-js": {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
});

View File

@@ -0,0 +1,75 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
cfg: CoreConfig;
runtime: RuntimeEnv;
}) {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
runtime.log?.(message);
};
const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always";
const autoJoinAllowlist = new Set(
(cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
logVerbose("matrix: auto-join enabled for all invites");
} else {
logVerbose("matrix: auto-join enabled for allowlist invites");
}
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin === "allowlist") {
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
altAliases =
aliasState && Array.isArray(aliasState.alt_aliases)
? aliasState.alt_aliases
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean)
: [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.has("*") ||
autoJoinAllowlist.has(roomId) ||
(alias ? autoJoinAllowlist.has(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.has(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
});
}

View File

@@ -0,0 +1,104 @@
import type { MatrixClient } from "../sdk.js";
type DirectMessageCheck = {
roomId: string;
senderId?: string;
selfUserId?: string;
};
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
};
const DM_CACHE_TTL_MS = 30_000;
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
const log = opts.log ?? (() => {});
let lastDmUpdateMs = 0;
let cachedSelfUserId: string | null = null;
const memberCountCache = new Map<string, { count: number; ts: number }>();
const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) {
return cachedSelfUserId;
}
try {
cachedSelfUserId = await client.getUserId();
} catch {
cachedSelfUserId = null;
}
return cachedSelfUserId;
};
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
return;
}
lastDmUpdateMs = now;
try {
await client.dms.update();
} catch (err) {
log(`matrix: dm cache refresh failed (${String(err)})`);
}
};
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
const cached = memberCountCache.get(roomId);
const now = Date.now();
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
return cached.count;
}
try {
const members = await client.getJoinedRoomMembers(roomId);
const count = members.length;
memberCountCache.set(roomId, { count, ts: now });
return count;
} catch (err) {
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
return null;
}
};
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim();
if (!target) {
return false;
}
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;
} catch {
return false;
}
};
return {
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
await refreshDmCache();
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}
const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
return true;
}
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const directViaState =
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
if (directViaState) {
log(`matrix: dm detected via member state room=${roomId}`);
return true;
}
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
return false;
},
};
}

View File

@@ -0,0 +1,208 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import { registerMatrixMonitorEvents } from "./events.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string {
const calls = sendMessage.mock.calls as unknown[][];
const payload = (calls[index]?.[1] ?? {}) as { body?: string };
return payload.body ?? "";
}
function createHarness(params?: {
verifications?: Array<{
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
}>;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const onRoomMessage = vi.fn(async () => {});
const listVerifications = vi.fn(async () => params?.verifications ?? []);
const sendMessage = vi.fn(async () => "$notice");
const client = {
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
return client;
}),
sendMessage,
crypto: {
listVerifications,
},
} as unknown as MatrixClient;
registerMatrixMonitorEvents({
client,
auth: { encryption: true } as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
formatNativeDependencyHint: vi.fn(() => "install hint"),
onRoomMessage,
});
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
if (!roomEventListener) {
throw new Error("room.event listener was not registered");
}
return {
onRoomMessage,
sendMessage,
roomEventListener,
listVerifications,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
};
}
describe("registerMatrixMonitorEvents verification routing", () => {
it("posts verification request notices directly into the room", async () => {
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
roomMessageListener("!room:example.org", {
event_id: "$req1",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.key.verification.request",
body: "verification request",
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
expect(onRoomMessage).not.toHaveBeenCalled();
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification request received from @alice:example.org.");
expect(body).toContain('Open "Verify by emoji"');
});
it("posts ready-stage guidance for emoji verification", async () => {
const { sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$ready-1",
sender: "@alice:example.org",
type: "m.key.verification.ready",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-ready-1" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
expect(body).toContain('Choose "Verify by emoji"');
});
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-1",
transactionId: "$different-flow-id",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start2",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req2" },
},
});
await vi.waitFor(() => {
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
});
it("does not emit duplicate SAS notices for the same verification payload", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-3",
transactionId: "$req3",
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start3",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(sendMessage.mock.calls.length).toBeGreaterThan(0);
});
roomEventListener("!room:example.org", {
event_id: "$key3",
sender: "@alice:example.org",
type: "m.key.verification.key",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(listVerifications).toHaveBeenCalledTimes(2);
});
const sasBodies = sendMessage.mock.calls
.map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? ""))
.filter((body) => body.includes("SAS emoji:"));
expect(sasBodies).toHaveLength(1);
});
});

View File

@@ -0,0 +1,396 @@
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
import {
isMatrixVerificationEventType,
isMatrixVerificationRequestMsgType,
matrixVerificationConstants,
} from "./verification-utils.js";
const MAX_TRACKED_VERIFICATION_EVENTS = 1024;
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
type MatrixVerificationSummaryLike = {
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
};
function trimMaybeString(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readVerificationSignal(event: MatrixRawEvent): {
stage: MatrixVerificationStage;
flowId: string | null;
} | null {
const type = trimMaybeString(event?.type) ?? "";
const content = event?.content ?? {};
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
const relatedEventId = trimMaybeString(
(content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id,
);
const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id);
if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) {
return {
stage: "request",
flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId,
};
}
if (!isMatrixVerificationEventType(type)) {
return null;
}
const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id);
if (type === `${matrixVerificationConstants.eventPrefix}request`) {
return { stage: "request", flowId };
}
if (type === `${matrixVerificationConstants.eventPrefix}ready`) {
return { stage: "ready", flowId };
}
if (type === "m.key.verification.start") {
return { stage: "start", flowId };
}
if (type === "m.key.verification.cancel") {
return { stage: "cancel", flowId };
}
if (type === "m.key.verification.done") {
return { stage: "done", flowId };
}
return { stage: "other", flowId };
}
function formatVerificationStageNotice(params: {
stage: MatrixVerificationStage;
senderId: string;
event: MatrixRawEvent;
}): string | null {
const { stage, senderId, event } = params;
const content = event.content as { code?: unknown; reason?: unknown };
switch (stage) {
case "request":
return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`;
case "ready":
return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`;
case "start":
return `Matrix verification started with ${senderId}.`;
case "done":
return `Matrix verification completed with ${senderId}.`;
case "cancel": {
const code = trimMaybeString(content.code);
const reason = trimMaybeString(content.reason);
if (code && reason) {
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
}
if (reason) {
return `Matrix verification cancelled by ${senderId} (${reason}).`;
}
return `Matrix verification cancelled by ${senderId}.`;
}
default:
return null;
}
}
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
const sas = summary.sas;
if (!sas) {
return null;
}
const emojiLine =
Array.isArray(sas.emoji) && sas.emoji.length > 0
? `SAS emoji: ${sas.emoji
.map(
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
)
.join(" | ")}`
: null;
const decimalLine =
Array.isArray(sas.decimal) && sas.decimal.length === 3
? `SAS decimal: ${sas.decimal.join(" ")}`
: null;
if (!emojiLine && !decimalLine) {
return null;
}
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
if (emojiLine) {
lines.push(emojiLine);
}
if (decimalLine) {
lines.push(decimalLine);
}
lines.push("If both sides match, choose 'They match' in your Matrix app.");
return lines.join("\n");
}
function resolveVerificationFlowCandidates(params: {
event: MatrixRawEvent;
flowId: string | null;
}): string[] {
const { event, flowId } = params;
const content = event.content as {
transaction_id?: unknown;
"m.relates_to"?: { event_id?: unknown };
};
const candidates = new Set<string>();
const add = (value: unknown) => {
const normalized = trimMaybeString(value);
if (normalized) {
candidates.add(normalized);
}
};
add(flowId);
add(event.event_id);
add(content.transaction_id);
add(content["m.relates_to"]?.event_id);
return Array.from(candidates);
}
function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number {
const ts = Date.parse(summary.updatedAt ?? "");
return Number.isFinite(ts) ? ts : 0;
}
async function resolveVerificationSummaryForSignal(
client: MatrixClient,
params: {
event: MatrixRawEvent;
senderId: string;
flowId: string | null;
},
): Promise<MatrixVerificationSummaryLike | null> {
if (!client.crypto) {
return null;
}
const list = await client.crypto.listVerifications();
if (list.length === 0) {
return null;
}
const candidates = resolveVerificationFlowCandidates({
event: params.event,
flowId: params.flowId,
});
const byTransactionId = list.find((entry) =>
candidates.some((candidate) => entry.transactionId === candidate),
);
if (byTransactionId) {
return byTransactionId;
}
// Fallback for flows where transaction IDs do not match room event IDs consistently.
const byUser = list
.filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true)
.sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0];
return byUser ?? null;
}
function trackBounded(set: Set<string>, value: string): boolean {
if (!value || set.has(value)) {
return false;
}
set.add(value);
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
const oldest = set.values().next().value;
if (typeof oldest === "string") {
set.delete(oldest);
}
}
return true;
}
async function sendVerificationNotice(params: {
client: MatrixClient;
roomId: string;
body: string;
logVerboseMessage: (message: string) => void;
}): Promise<void> {
const roomId = trimMaybeString(params.roomId);
if (!roomId) {
return;
}
try {
await params.client.sendMessage(roomId, {
msgtype: "m.notice",
body: params.body,
});
} catch (err) {
params.logVerboseMessage(
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
);
}
}
export function registerMatrixMonitorEvents(params: {
client: MatrixClient;
auth: MatrixAuth;
logVerboseMessage: (message: string) => void;
warnedEncryptedRooms: Set<string>;
warnedCryptoMissingRooms: Set<string>;
logger: RuntimeLogger;
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
const {
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint,
onRoomMessage,
} = params;
const routedVerificationEvents = new Set<string>();
const routedVerificationSasFingerprints = new Set<string>();
const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => {
const senderId = trimMaybeString(event?.sender);
if (!senderId) {
return false;
}
const signal = readVerificationSignal(event);
if (!signal) {
return false;
}
void (async () => {
const flowId = signal.flowId;
const sourceEventId = trimMaybeString(event?.event_id);
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
return;
}
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
const summary = await resolveVerificationSummaryForSignal(client, {
event,
senderId,
flowId,
}).catch(() => null);
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
const notices: string[] = [];
if (stageNotice) {
notices.push(stageNotice);
}
if (summary && sasNotice) {
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
notices.push(sasNotice);
}
}
if (notices.length === 0) {
return;
}
for (const body of notices) {
await sendVerificationNotice({
client,
roomId,
body,
logVerboseMessage,
});
}
})().catch((err) => {
logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
});
return true;
};
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
if (routeVerificationEvent(roomId, event)) {
return;
}
void onRoomMessage(roomId, event);
});
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on(
"room.failed_decryption",
async (roomId: string, event: MatrixRawEvent, error: Error) => {
logger.warn("Failed to decrypt message", {
roomId,
eventId: event.event_id,
error: error.message,
});
logVerboseMessage(
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
);
},
);
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const sender = event?.sender ?? "unknown";
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
logVerboseMessage(
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
);
});
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
});
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
const eventType = event?.type ?? "unknown";
if (eventType === EventType.RoomMessageEncrypted) {
logVerboseMessage(
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
);
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
warnedEncryptedRooms.add(roomId);
const warning =
"matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt";
logger.warn(warning, { roomId });
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
warnedCryptoMissingRooms.add(roomId);
const hint = formatNativeDependencyHint({
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
manager: "pnpm",
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
});
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
logger.warn(warning, { roomId });
}
return;
}
if (eventType === EventType.RoomMember) {
const membership = (event?.content as { membership?: string } | undefined)?.membership;
const stateKey = (event as { state_key?: string }).state_key ?? "";
logVerboseMessage(
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
);
}
routeVerificationEvent(roomId, event);
});
}

View File

@@ -0,0 +1,308 @@
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: "ops",
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: "ops",
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 }));
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
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);
expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "matrix-js",
env: process.env,
accountId: "ops",
});
expect(upsertPairingRequest).toHaveBeenCalledWith({
channel: "matrix-js",
id: "@user:example.org",
accountId: "ops",
meta: { name: "sender" },
});
});
it("passes accountId into route resolution for inbound dm messages", async () => {
const resolveAgentRoute = vi.fn(() => ({
agentId: "ops",
channel: "matrix-js",
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
}));
const handler = createMatrixRoomMessageHandler({
client: {
getUserId: async () => "@bot:example.org",
} as never,
core: {
channel: {
pairing: {
readAllowFromStore: async () => [] as string[],
upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }),
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
},
routing: {
resolveAgentRoute,
},
},
} as never,
cfg: {} as never,
accountId: "ops",
runtime: {
error: () => {},
} as never,
logger: {
info: () => {},
warn: () => {},
} as never,
logVerboseMessage: () => {},
allowFrom: [],
mentionRegexes: [],
groupPolicy: "open",
replyToMode: "off",
threadReplies: "inbound",
dmEnabled: true,
dmPolicy: "open",
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: "$event2",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { room: true },
},
} as MatrixRawEvent);
expect(resolveAgentRoute).toHaveBeenCalledWith(
expect.objectContaining({
channel: "matrix-js",
accountId: "ops",
}),
);
});
});

View File

@@ -0,0 +1,713 @@
import {
createReplyPrefixOptions,
createTypingCallbacks,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type PluginRuntime,
type ReplyPayload,
type RuntimeEnv,
type RuntimeLogger,
} from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import {
formatPollAsText,
isPollStartType,
parsePollStartContent,
type PollStartContent,
} from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
} from "./allowlist.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
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;
runtime: RuntimeEnv;
logger: RuntimeLogger;
logVerboseMessage: (message: string) => void;
allowFrom: string[];
roomsConfig?: Record<string, MatrixRoomConfig>;
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
groupPolicy: "open" | "allowlist" | "disabled";
replyToMode: ReplyToMode;
threadReplies: "off" | "inbound" | "always";
dmEnabled: boolean;
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
textLimit: number;
mediaMaxBytes: number;
startupMs: number;
startupGraceMs: number;
directTracker: {
isDirectMessage: (params: {
roomId: string;
senderId: string;
selfUserId: string;
}) => Promise<boolean>;
};
getRoomInfo: (
roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
};
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
const {
client,
core,
cfg,
accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
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 {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted payloads are emitted separately after decryption.
return;
}
const isPollEvent = isPollStartType(eventType);
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
return;
}
logVerboseMessage(
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
);
if (event.unsigned?.redacted_because) {
return;
}
const senderId = event.sender;
if (!senderId) {
return;
}
const selfUserId = await client.getUserId();
if (senderId === selfUserId) {
return;
}
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
}
if (
typeof eventTs !== "number" &&
typeof eventAge === "number" &&
eventAge > startupGraceMs
) {
return;
}
const roomInfo = await getRoomInfo(roomId);
const roomName = roomInfo.name;
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
body: pollText,
} as unknown as RoomMessageEventContent;
} else {
return;
}
}
if (
eventType === EventType.RoomMessage &&
isMatrixVerificationRoomMessage({
msgtype: (content as { msgtype?: unknown }).msgtype,
body: content.body,
})
) {
logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
return;
}
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
eventType,
content: content as LocationMessageEventContent,
});
const relates = content["m.relates_to"];
if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) {
return;
}
}
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
senderId,
selfUserId,
});
const isRoom = !isDirectMessage;
if (isRoom && groupPolicy === "disabled") {
return;
}
const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases: roomAliases,
name: roomName,
})
: undefined;
const roomConfig = roomConfigInfo?.config;
const roomMatchMeta = roomConfigInfo
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`
: "matchKey=none matchSource=none";
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await readStoreAllowFrom();
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
return;
}
if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix-js",
id: senderId,
accountId,
meta: { name: senderName },
});
if (shouldSendPairingReply(senderId, created)) {
const pairingReply = core.channel.pairing.buildPairingReply({
channel: "matrix-js",
idLine: `Your Matrix user id: ${senderId}`,
code,
});
logVerboseMessage(
created
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
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") {
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
}
}
const roomUsers = roomConfig?.users ?? [];
if (isRoom && roomUsers.length > 0) {
const userMatch = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
});
if (!userMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch,
)})`,
);
return;
}
}
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
});
if (!groupAllowMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
groupAllowMatch,
)})`,
);
return;
}
}
if (isRoom) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody =
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
let media: {
path: string;
contentType?: string;
placeholder: string;
} | null = null;
const contentUrl =
"url" in content && typeof content.url === "string" ? content.url : undefined;
const contentFile =
"file" in content && content.file && typeof content.file === "object"
? content.file
: undefined;
const mediaUrl = contentUrl ?? contentFile?.url;
if (!rawBody && !mediaUrl) {
return;
}
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});
} catch (err) {
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
}
}
const bodyText = rawBody || media?.placeholder || "";
if (!bodyText) {
return;
}
const { wasMentioned, hasExplicitMention } = resolveMentions({
content,
userId: selfUserId,
text: bodyText,
mentionRegexes,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "matrix-js",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix-js",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
const shouldRequireMention = isRoom
? roomConfig?.autoReply === true
? false
: roomConfig?.autoReply === false
? true
: typeof roomConfig?.requireMention === "boolean"
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info("skipping room message", { roomId, reason: "no-mention" });
return;
}
const messageId = event.event_id ?? "";
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
const threadRootId = resolveMatrixThreadRootId({ event, content });
const threadTarget = resolveMatrixThreadTarget({
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
});
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix-js",
accountId,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName,
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix-js" as const,
Surface: "matrix-js" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
MessageThreadId: threadTarget,
Timestamp: eventTs ?? undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...locationPayload?.context,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix-js" as const,
OriginatingTo: `room:${roomId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix-js",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
});
},
});
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
const replyTarget = ctxPayload.To;
if (!replyTarget) {
runtime.error?.("matrix: missing reply target");
return;
}
if (messageId) {
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: route.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "matrix-js",
accountId: route.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
await deliverMatrixReplies({
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
});
markDispatchIdle();
if (!queuedFinal) {
return;
}
didSendReply = true;
const finalCount = counts.final;
logVerboseMessage(
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
if (didSendReply) {
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
});
}
} catch (err) {
runtime.error?.(`matrix handler failed: ${String(err)}`);
}
};
}

View File

@@ -0,0 +1,400 @@
import { format } from "node:util";
import {
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
type RuntimeEnv,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import { resolveMatrixAccount } from "../accounts.js";
import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
initialSyncLimit?: number;
replyToMode?: ReplyToMode;
accountId?: string | null;
};
const DEFAULT_MEDIA_MAX_MB = 20;
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.["matrix-js"]?.enabled === false) {
return;
}
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
const runtime: RuntimeEnv = opts.runtime ?? {
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug?.(message);
};
const normalizeUserEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^user:/i, "")
.trim();
const normalizeRoomEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^(room|channel):/i, "")
.trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const resolveUserAllowlist = async (
label: string,
list?: Array<string | number>,
): Promise<string[]> => {
let allowList = list ?? [];
if (allowList.length === 0) {
return allowList.map(String);
}
const entries = allowList
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length === 0) {
return allowList.map(String);
}
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(normalizeMatrixUserId(entry));
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
const normalizedId = normalizeMatrixUserId(entry.id);
additions.push(normalizedId);
mapping.push(`${entry.input}${normalizedId}`);
} else {
unresolved.push(entry.input);
}
}
}
allowList = mergeAllowlist({ existing: allowList, additions });
summarizeMapping(label, mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
);
}
return allowList.map(String);
};
// Resolve account-specific config for multi-account support
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
const accountConfig = account.config;
const allowlistOnly = accountConfig.allowlistOnly === true;
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
if (roomsConfig["*"]) {
nextRooms["*"] = roomsConfig["*"];
}
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
[];
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
if (entry === "*") {
continue;
}
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomConfig;
}
if (cleaned !== entry) {
mapping.push(`${entry}${cleaned}`);
}
continue;
}
pending.push({ input: entry, query: trimmed, config: roomConfig });
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending.map((entry) => entry.query),
kind: "group",
runtime,
});
resolved.forEach((entry, index) => {
const source = pending[index];
if (!source) {
return;
}
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = source.config;
}
mapping.push(`${source.input}${entry.id}`);
} else {
unresolved.push(source.input);
}
});
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const nextRooms = { ...roomsConfig };
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
const users = roomConfig?.users ?? [];
if (users.length === 0) {
continue;
}
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
if (resolvedUsers !== users) {
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
}
}
roomsConfig = nextRooms;
}
cfg = {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
dm: {
...cfg.channels?.["matrix-js"]?.dm,
allowFrom,
},
groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))
: auth.initialSyncLimit;
const authWithLimit =
resolvedInitialSyncLimit === auth.initialSyncLimit
? auth
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
const client = await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
startClient: false,
accountId: opts.accountId ?? undefined,
});
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix-js",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => logVerboseMessage(message),
});
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = accountConfig.threadReplies ?? "inbound";
const dmConfig = accountConfig.dm;
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
const warnedCryptoMissingRooms = new Set<string>();
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
const handleRoomMessage = createMatrixRoomMessageHandler({
client,
core,
cfg,
accountId: account.accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
});
registerMatrixMonitorEvents({
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
onRoomMessage: handleRoomMessage,
});
logVerboseMessage("matrix: starting client");
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
accountId: opts.accountId,
});
logVerboseMessage("matrix: client started");
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
try {
const profileSync = await syncMatrixOwnProfile({
client,
userId: auth.userId,
displayName: accountConfig.name,
avatarUrl: accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
logger.info(`matrix: profile display name updated for ${auth.userId}`);
}
if (profileSync.avatarUpdated) {
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = core.config.loadConfig() as CoreConfig;
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await core.config.writeConfigFile(updatedCfg as never);
logVerboseMessage(
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and guidance.
if (auth.encryption && client.crypto) {
try {
const status = await client.getOwnDeviceVerificationStatus();
if (status.verified) {
logger.info("matrix: device is verified and ready for encrypted rooms");
} else {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
);
}
} catch (err) {
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
error: String(err),
});
}
}
await new Promise<void>((resolve) => {
const onAbort = () => {
try {
logVerboseMessage("matrix: stopping client");
stopSharedClientForAccount(auth, opts.accountId);
} finally {
setActiveMatrixClient(null, opts.accountId);
resolve();
}
};
if (opts.abortSignal?.aborted) {
onAbort();
return;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,100 @@
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk/matrix-js";
import type { LocationMessageEventContent } from "../sdk.js";
import { EventType } from "./types.js";
export type MatrixLocationPayload = {
text: string;
context: ReturnType<typeof toLocationContext>;
};
type GeoUriParams = {
latitude: number;
longitude: number;
accuracy?: number;
};
function parseGeoUri(value: string): GeoUriParams | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!trimmed.toLowerCase().startsWith("geo:")) {
return null;
}
const payload = trimmed.slice(4);
const [coordsPart, ...paramParts] = payload.split(";");
const coords = coordsPart.split(",");
if (coords.length < 2) {
return null;
}
const latitude = Number.parseFloat(coords[0] ?? "");
const longitude = Number.parseFloat(coords[1] ?? "");
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const params = new Map<string, string>();
for (const part of paramParts) {
const segment = part.trim();
if (!segment) {
continue;
}
const eqIndex = segment.indexOf("=");
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
const key = rawKey.trim().toLowerCase();
if (!key) {
continue;
}
const valuePart = rawValue.trim();
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
}
const accuracyRaw = params.get("u");
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
return {
latitude,
longitude,
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
};
}
export function resolveMatrixLocation(params: {
eventType: string;
content: LocationMessageEventContent;
}): MatrixLocationPayload | null {
const { eventType, content } = params;
const isLocation =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
if (!isLocation) {
return null;
}
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
if (!geoUri) {
return null;
}
const parsed = parseGeoUri(geoUri);
if (!parsed) {
return null;
}
const caption = typeof content.body === "string" ? content.body.trim() : "";
const location: NormalizedLocation = {
latitude: parsed.latitude,
longitude: parsed.longitude,
accuracy: parsed.accuracy,
caption: caption || undefined,
source: "pin",
isLive: false,
};
return {
text: formatLocationText(location),
context: toLocationContext(location),
};
}

View File

@@ -0,0 +1,102 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import { downloadMatrixMedia } from "./media.js";
describe("downloadMatrixMedia", () => {
const saveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/media",
contentType: "image/png",
});
const runtimeStub = {
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
},
},
} as unknown as PluginRuntime;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const result = await downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
maxBytes: 1024,
file,
});
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
"inbound",
1024,
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,117 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
// Type for encrypted file info
type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
// The client wrapper exposes mxcToHttp for Matrix media URIs.
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) {
return null;
}
// Use the client's download method which handles auth
try {
const raw = await params.client.downloadContent(params.mxcUrl);
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
}
}
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses the Matrix crypto adapter's decryptMedia helper.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
file: EncryptedFile;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
if (!params.client.crypto) {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(
params.file as Parameters<typeof params.client.crypto.decryptMedia>[0],
);
if (decrypted.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: decrypted };
}
export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
client: params.client,
file: params.file,
maxBytes: params.maxBytes,
});
} else {
// Unencrypted media
fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
}
if (!fetched) {
return null;
}
const headerType = params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "[matrix media]",
};
}

View File

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from "vitest";
// Mock the runtime before importing resolveMentions
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
mentions: {
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
patterns.some((p) => p.test(text)),
},
},
}),
}));
import { resolveMentions } from "./mentions.js";
describe("resolveMentions", () => {
const userId = "@bot:matrix.org";
const mentionRegexes = [/@bot/i];
describe("m.mentions field", () => {
it("detects mention via m.mentions.user_ids", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { user_ids: ["@bot:matrix.org"] },
},
userId,
text: "hello",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
expect(result.hasExplicitMention).toBe(true);
});
it("detects room mention via m.mentions.room", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello everyone",
"m.mentions": { room: true },
},
userId,
text: "hello everyone",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("formatted_body matrix.to links", () => {
it("detects mention in formatted_body with plain user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/@bot:matrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention in formatted_body with URL-encoded user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/%40bot%3Amatrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention with single quotes in href", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: "<a href='https://matrix.to/#/@bot:matrix.org'>Bot</a>: hello",
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("does not detect mention for different user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Other: hello",
formatted_body: '<a href="https://matrix.to/#/@other:matrix.org">Other</a>: hello',
},
userId,
text: "Other: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
it("does not false-positive on partial user ID match", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot2: hello",
formatted_body: '<a href="https://matrix.to/#/@bot2:matrix.org">Bot2</a>: hello',
},
userId: "@bot:matrix.org",
text: "Bot2: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
});
describe("regex patterns", () => {
it("detects mention via regex pattern in body text", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hey @bot can you help?",
},
userId,
text: "hey @bot can you help?",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("no mention", () => {
it("returns false when no mention is present", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello world",
},
userId,
text: "hello world",
mentionRegexes,
});
expect(result.wasMentioned).toBe(false);
expect(result.hasExplicitMention).toBe(false);
});
});
});

View File

@@ -0,0 +1,52 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
/**
* Check if the formatted_body contains a matrix.to mention link for the given user ID.
* Many Matrix clients (including Element) use HTML links in formatted_body instead of
* or in addition to the m.mentions field.
*/
function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
if (!formattedBody || !userId) {
return false;
}
// Escape special regex characters in the user ID (e.g., @user:matrix.org)
const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match matrix.to links with the user ID, handling both URL-encoded and plain formats
// Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
if (plainPattern.test(formattedBody)) {
return true;
}
// Also check URL-encoded version (@ -> %40, : -> %3A)
const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
return encodedPattern.test(formattedBody);
}
export function resolveMentions(params: {
content: RoomMessageEventContent;
userId?: string | null;
text?: string;
mentionRegexes: RegExp[];
}) {
const mentions = params.content["m.mentions"];
const mentionedUsers = Array.isArray(mentions?.user_ids)
? new Set(mentions.user_ids)
: new Set<string>();
// Check formatted_body for matrix.to mention links (legacy/alternative mention format)
const mentionedInFormattedBody = params.userId
? checkFormattedBodyMention(params.content.formatted_body, params.userId)
: false;
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
mentionedInFormattedBody ||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View File

@@ -0,0 +1,132 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
vi.mock("../send.js", () => ({
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
sendMessageMatrixMock(to, message, opts),
}));
import { setMatrixRuntime } from "../../runtime.js";
import { deliverMatrixReplies } from "./replies.js";
describe("deliverMatrixReplies", () => {
const loadConfigMock = vi.fn(() => ({}));
const resolveMarkdownTableModeMock = vi.fn(() => "code");
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const resolveChunkModeMock = vi.fn(() => "length");
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
const runtimeStub = {
config: {
loadConfig: () => loadConfigMock(),
},
channel: {
text: {
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
});
it("keeps replyToId on first reply only when replyToMode=first", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [
{ text: "first-a|first-b", replyToId: "reply-1" },
{ text: "second", replyToId: "reply-2" },
],
roomId: "room:1",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "first",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: undefined }),
);
});
it("keeps replyToId on every reply when replyToMode=all", async () => {
await deliverMatrixReplies({
replies: [
{
text: "caption",
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
replyToId: "reply-media",
audioAsVoice: true,
},
{ text: "plain", replyToId: "reply-text" },
],
roomId: "room:2",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
"room:2",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
"room:2",
"",
expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-text" }),
);
});
it("suppresses replyToId when threadId is set", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
roomId: "room:3",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
threadId: "thread-77",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
});
});

View File

@@ -0,0 +1,100 @@
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import { sendMessageMatrix } from "../send.js";
export async function deliverMatrixReplies(params: {
replies: ReplyPayload[];
roomId: string;
client: MatrixClient;
runtime: RuntimeEnv;
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.("matrix reply missing text/media");
continue;
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const shouldIncludeReply = (id?: string) =>
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
if (mediaList.length === 0) {
let sentTextChunk = false;
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) {
continue;
}
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: replyToIdForReply,
threadId: params.threadId,
accountId: params.accountId,
});
sentTextChunk = true;
}
if (replyToIdForReply && !hasReplied && sentTextChunk) {
hasReplied = true;
}
continue;
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
first = false;
}
if (replyToIdForReply && !hasReplied) {
hasReplied = true;
}
}
}

View File

@@ -0,0 +1,65 @@
import type { MatrixClient } from "../sdk.js";
export type MatrixRoomInfo = {
name?: string;
canonicalAlias?: string;
altAliases: string[];
};
export function createMatrixRoomInfoResolver(client: MatrixClient) {
const roomInfoCache = new Map<string, MatrixRoomInfo>();
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
const cached = roomInfoCache.get(roomId);
if (cached) {
return cached;
}
let name: string | undefined;
let canonicalAlias: string | undefined;
let altAliases: string[] = [];
try {
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
if (nameState && typeof nameState.name === "string") {
name = nameState.name;
}
} catch {
// ignore
}
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
if (aliasState && typeof aliasState.alias === "string") {
canonicalAlias = aliasState.alias;
}
const rawAliases = aliasState?.alt_aliases;
if (Array.isArray(rawAliases)) {
altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string");
}
} catch {
// ignore
}
const info = { name, canonicalAlias, altAliases };
roomInfoCache.set(roomId, info);
return info;
};
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
try {
const memberState = await client
.getRoomStateEvent(roomId, "m.room.member", userId)
.catch(() => null);
if (memberState && typeof memberState.displayname === "string") {
return memberState.displayname;
}
return userId;
} catch {
return userId;
}
};
return {
getRoomInfo,
getMemberDisplayName,
};
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixRoomConfig } from "./rooms.js";
describe("resolveMatrixRoomConfig", () => {
it("matches room IDs and aliases, not names", () => {
const rooms = {
"!room:example.org": { allow: true },
"#alias:example.org": { allow: true },
"Project Room": { allow: true },
};
const byId = resolveMatrixRoomConfig({
rooms,
roomId: "!room:example.org",
aliases: [],
name: "Project Room",
});
expect(byId.allowed).toBe(true);
expect(byId.matchKey).toBe("!room:example.org");
const byAlias = resolveMatrixRoomConfig({
rooms,
roomId: "!other:example.org",
aliases: ["#alias:example.org"],
name: "Other Room",
});
expect(byAlias.allowed).toBe(true);
expect(byAlias.matchKey).toBe("#alias:example.org");
const byName = resolveMatrixRoomConfig({
rooms: { "Project Room": { allow: true } },
roomId: "!different:example.org",
aliases: [],
name: "Project Room",
});
expect(byName.allowed).toBe(false);
expect(byName.config).toBeUndefined();
});
});

View File

@@ -0,0 +1,47 @@
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixRoomConfig } from "../../types.js";
export type MatrixRoomConfigResolved = {
allowed: boolean;
allowlistConfigured: boolean;
config?: MatrixRoomConfig;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
export function resolveMatrixRoomConfig(params: {
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
aliases: string[];
name?: string | null;
}): MatrixRoomConfigResolved {
const rooms = params.rooms ?? {};
const keys = Object.keys(rooms);
const allowlistConfigured = keys.length > 0;
const candidates = buildChannelKeyCandidates(
params.roomId,
`room:${params.roomId}`,
...params.aliases,
);
const {
entry: matched,
key: matchedKey,
wildcardEntry,
wildcardKey,
} = resolveChannelEntryMatch({
entries: rooms,
keys: candidates,
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {
allowed,
allowlistConfigured,
config: resolved,
matchKey,
matchSource,
};
}

View File

@@ -0,0 +1,48 @@
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { RelationType } from "./types.js";
export function resolveMatrixThreadTarget(params: {
threadReplies: "off" | "inbound" | "always";
messageId: string;
threadRootId?: string;
isThreadRoot?: boolean;
}): string | undefined {
const { threadReplies, messageId, threadRootId } = params;
if (threadReplies === "off") {
return undefined;
}
const isThreadRoot = params.isThreadRoot === true;
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
if (threadReplies === "inbound") {
return hasInboundThread ? threadRootId : undefined;
}
if (threadReplies === "always") {
return threadRootId ?? messageId;
}
return undefined;
}
export function resolveMatrixThreadRootId(params: {
event: MatrixRawEvent;
content: RoomMessageEventContent;
}): string | undefined {
const relates = params.content["m.relates_to"];
if (!relates || typeof relates !== "object") {
return undefined;
}
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
if ("event_id" in relates && typeof relates.event_id === "string") {
return relates.event_id;
}
if (
"m.in_reply_to" in relates &&
typeof relates["m.in_reply_to"] === "object" &&
relates["m.in_reply_to"] &&
"event_id" in relates["m.in_reply_to"] &&
typeof relates["m.in_reply_to"].event_id === "string"
) {
return relates["m.in_reply_to"].event_id;
}
}
return undefined;
}

View File

@@ -0,0 +1,28 @@
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const EventType = {
RoomMessage: "m.room.message",
RoomMessageEncrypted: "m.room.encrypted",
RoomMember: "m.room.member",
Location: "m.location",
} as const;
export const RelationType = {
Replace: "m.replace",
Thread: "m.thread",
} as const;
export type RoomMessageEventContent = MessageEventContent & {
url?: string;
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
isMatrixVerificationEventType,
isMatrixVerificationNoticeBody,
isMatrixVerificationRequestMsgType,
isMatrixVerificationRoomMessage,
} from "./verification-utils.js";
describe("matrix verification message classifiers", () => {
it("recognizes verification event types", () => {
expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true);
expect(isMatrixVerificationEventType("m.room.message")).toBe(false);
});
it("recognizes verification request message type", () => {
expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true);
expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false);
});
it("recognizes verification notice bodies", () => {
expect(
isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."),
).toBe(true);
expect(isMatrixVerificationNoticeBody("hello world")).toBe(false);
});
it("classifies verification room messages", () => {
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.key.verification.request",
body: "verify request",
}),
).toBe(true);
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.notice",
body: "Matrix verification cancelled by @alice:example.org.",
}),
).toBe(true);
expect(
isMatrixVerificationRoomMessage({
msgtype: "m.text",
body: "normal chat message",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
const VERIFICATION_EVENT_PREFIX = "m.key.verification.";
const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request";
const VERIFICATION_NOTICE_PREFIXES = [
"Matrix verification request received from ",
"Matrix verification is ready with ",
"Matrix verification started with ",
"Matrix verification completed with ",
"Matrix verification cancelled by ",
"Matrix verification SAS with ",
];
function trimMaybeString(input: unknown): string {
return typeof input === "string" ? input.trim() : "";
}
export function isMatrixVerificationEventType(type: unknown): boolean {
return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX);
}
export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean {
return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE;
}
export function isMatrixVerificationNoticeBody(body: unknown): boolean {
const text = trimMaybeString(body);
return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix));
}
export function isMatrixVerificationRoomMessage(content: {
msgtype?: unknown;
body?: unknown;
}): boolean {
return (
isMatrixVerificationRequestMsgType(content.msgtype) ||
(trimMaybeString(content.msgtype) === "m.notice" &&
isMatrixVerificationNoticeBody(content.body))
);
}
export const matrixVerificationConstants = {
eventPrefix: VERIFICATION_EVENT_PREFIX,
requestMsgtype: VERIFICATION_REQUEST_MSGTYPE,
} as const;

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});

View File

@@ -0,0 +1,167 @@
/**
* Matrix Poll Types (MSC3381)
*
* Defines types for Matrix poll events:
* - m.poll.start - Creates a new poll
* - m.poll.response - Records a vote
* - m.poll.end - Closes a poll
*/
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [
M_POLL_START,
M_POLL_RESPONSE,
M_POLL_END,
ORG_POLL_START,
ORG_POLL_RESPONSE,
ORG_POLL_END,
];
export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string;
};
export type PollAnswer = {
id: string;
} & TextContent;
export type PollStartSubtype = {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = {
[M_POLL_START]?: PollStartSubtype;
[ORG_POLL_START]?: PollStartSubtype;
"m.poll"?: PollStartSubtype;
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
};
export type PollSummary = {
eventId: string;
roomId: string;
sender: string;
senderName: string;
question: string;
answers: string[];
kind: PollKind;
maxSelections: number;
};
export function isPollStartType(eventType: string): boolean {
return (POLL_START_TYPES as readonly string[]).includes(eventType);
}
export function getTextContent(text?: TextContent): string {
if (!text) {
return "";
}
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
}
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll =
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) {
return null;
}
const question = getTextContent(poll.question);
if (!question) {
return null;
}
const answers = poll.answers
.map((answer) => getTextContent(answer))
.filter((a) => a.trim().length > 0);
return {
eventId: "",
roomId: "",
sender: "",
senderName: "",
question,
answers,
kind: poll.kind ?? "m.poll.disclosed",
maxSelections: poll.max_selections ?? 1,
};
}
export function formatPollAsText(summary: PollSummary): string {
const lines = [
"[Poll]",
summary.question,
"",
...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
];
return lines.join("\n");
}
function buildTextContent(body: string): TextContent {
return {
"m.text": body,
"org.matrix.msc1767.text": body,
};
}
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) {
return question;
}
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim();
const answers = poll.options
.map((option) => option.trim())
.filter((option) => option.length > 0)
.map((option, idx) => ({
id: `answer${idx + 1}`,
...buildTextContent(option),
}));
const isMultiple = (poll.maxSelections ?? 1) > 1;
const maxSelections = isMultiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return {
[M_POLL_START]: {
question: buildTextContent(question),
kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
vi.mock("./client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
}));
import { probeMatrix } from "./probe.js";
describe("probeMatrix", () => {
beforeEach(() => {
vi.clearAllMocks();
isBunRuntimeMock.mockReturnValue(false);
createMatrixClientMock.mockResolvedValue({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
it("passes undefined userId when not provided", async () => {
const result = await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
timeoutMs: 1234,
});
expect(result.ok).toBe(true);
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: undefined,
accessToken: "tok",
localTimeoutMs: 1234,
});
});
it("trims provided userId before client creation", async () => {
await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
userId: " @bot:example.org ",
timeoutMs: 500,
});
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
localTimeoutMs: 500,
});
});
});

View File

@@ -0,0 +1,70 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix-js";
import { createMatrixClient, isBunRuntime } from "./client.js";
export type MatrixProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
userId?: string | null;
};
export async function probeMatrix(params: {
homeserver: string;
accessToken: string;
userId?: string;
timeoutMs: number;
}): Promise<MatrixProbe> {
const started = Date.now();
const result: MatrixProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
if (isBunRuntime()) {
return {
...result,
error: "Matrix probe requires Node (bun runtime not supported)",
elapsedMs: Date.now() - started,
};
}
if (!params.homeserver?.trim()) {
return {
...result,
error: "missing homeserver",
elapsedMs: Date.now() - started,
};
}
if (!params.accessToken?.trim()) {
return {
...result,
error: "missing access token",
elapsedMs: Date.now() - started,
};
}
try {
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
result.elapsedMs = Date.now() - started;
return result;
} catch (err) {
return {
...result,
status:
typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}

View File

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import {
isSupportedMatrixAvatarSource,
syncMatrixOwnProfile,
type MatrixProfileSyncResult,
} from "./profile.js";
function createClientStub() {
return {
getUserProfile: vi.fn(async () => ({})),
setDisplayName: vi.fn(async () => {}),
setAvatarUrl: vi.fn(async () => {}),
uploadContent: vi.fn(async () => "mxc://example/avatar"),
};
}
function expectNoUpdates(result: MatrixProfileSyncResult) {
expect(result.displayNameUpdated).toBe(false);
expect(result.avatarUpdated).toBe(false);
}
describe("matrix profile sync", () => {
it("skips when no desired profile values are provided", async () => {
const client = createClientStub();
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
});
expect(result.skipped).toBe(true);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("updates display name when desired name differs", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Old Name",
avatar_url: "mxc://example/existing",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "New Name",
});
expect(result.skipped).toBe(false);
expect(result.displayNameUpdated).toBe(true);
expect(result.avatarUpdated).toBe(false);
expect(client.setDisplayName).toHaveBeenCalledWith("New Name");
});
it("does not update when name and avatar already match", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/avatar",
});
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
displayName: "Bot",
avatarUrl: "mxc://example/avatar",
});
expect(result.skipped).toBe(false);
expectNoUpdates(result);
expect(client.setDisplayName).not.toHaveBeenCalled();
expect(client.setAvatarUrl).not.toHaveBeenCalled();
});
it("converts http avatar URL by uploading and then updates profile avatar", async () => {
const client = createClientStub();
client.getUserProfile.mockResolvedValue({
displayname: "Bot",
avatar_url: "mxc://example/old",
});
client.uploadContent.mockResolvedValue("mxc://example/new-avatar");
const loadAvatarFromUrl = vi.fn(async () => ({
buffer: Buffer.from("avatar-bytes"),
contentType: "image/png",
fileName: "avatar.png",
}));
const result = await syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "https://cdn.example.org/avatar.png",
loadAvatarFromUrl,
});
expect(result.convertedAvatarFromHttp).toBe(true);
expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar");
expect(result.avatarUpdated).toBe(true);
expect(loadAvatarFromUrl).toHaveBeenCalledWith(
"https://cdn.example.org/avatar.png",
10 * 1024 * 1024,
);
expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar");
});
it("rejects unsupported avatar URL schemes", async () => {
const client = createClientStub();
await expect(
syncMatrixOwnProfile({
client,
userId: "@bot:example.org",
avatarUrl: "file:///tmp/avatar.png",
}),
).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
});
it("recognizes supported avatar sources", () => {
expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true);
expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true);
expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false);
});
});

View File

@@ -0,0 +1,143 @@
import type { MatrixClient } from "./sdk.js";
export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024;
type MatrixProfileClient = Pick<
MatrixClient,
"getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent"
>;
type MatrixProfileLoadResult = {
buffer: Buffer;
contentType?: string;
fileName?: string;
};
export type MatrixProfileSyncResult = {
skipped: boolean;
displayNameUpdated: boolean;
avatarUpdated: boolean;
resolvedAvatarUrl: string | null;
convertedAvatarFromHttp: boolean;
};
function normalizeOptionalText(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function isMatrixMxcUri(value: string): boolean {
return value.trim().toLowerCase().startsWith("mxc://");
}
export function isMatrixHttpAvatarUri(value: string): boolean {
const normalized = value.trim().toLowerCase();
return normalized.startsWith("https://") || normalized.startsWith("http://");
}
export function isSupportedMatrixAvatarSource(value: string): boolean {
return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value);
}
async function resolveAvatarUrl(params: {
client: MatrixProfileClient;
avatarUrl: string | null;
avatarMaxBytes: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> {
const avatarUrl = normalizeOptionalText(params.avatarUrl);
if (!avatarUrl) {
return {
resolvedAvatarUrl: null,
convertedAvatarFromHttp: false,
};
}
if (isMatrixMxcUri(avatarUrl)) {
return {
resolvedAvatarUrl: avatarUrl,
convertedAvatarFromHttp: false,
};
}
if (!isMatrixHttpAvatarUri(avatarUrl)) {
throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL.");
}
if (!params.loadAvatarFromUrl) {
throw new Error("Matrix avatar URL conversion requires a media loader.");
}
const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes);
const uploadedMxc = await params.client.uploadContent(
media.buffer,
media.contentType,
media.fileName || "avatar",
);
return {
resolvedAvatarUrl: uploadedMxc,
convertedAvatarFromHttp: true,
};
}
export async function syncMatrixOwnProfile(params: {
client: MatrixProfileClient;
userId: string;
displayName?: string | null;
avatarUrl?: string | null;
avatarMaxBytes?: number;
loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise<MatrixProfileLoadResult>;
}): Promise<MatrixProfileSyncResult> {
const desiredDisplayName = normalizeOptionalText(params.displayName);
const avatar = await resolveAvatarUrl({
client: params.client,
avatarUrl: params.avatarUrl ?? null,
avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES,
loadAvatarFromUrl: params.loadAvatarFromUrl,
});
const desiredAvatarUrl = avatar.resolvedAvatarUrl;
if (!desiredDisplayName && !desiredAvatarUrl) {
return {
skipped: true,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}
let currentDisplayName: string | undefined;
let currentAvatarUrl: string | undefined;
try {
const currentProfile = await params.client.getUserProfile(params.userId);
currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined;
currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined;
} catch {
// If profile fetch fails, attempt writes directly.
}
let displayNameUpdated = false;
let avatarUpdated = false;
if (desiredDisplayName && currentDisplayName !== desiredDisplayName) {
await params.client.setDisplayName(desiredDisplayName);
displayNameUpdated = true;
}
if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) {
await params.client.setAvatarUrl(desiredAvatarUrl);
avatarUpdated = true;
}
return {
skipped: false,
displayNameUpdated,
avatarUpdated,
resolvedAvatarUrl: desiredAvatarUrl,
convertedAvatarFromHttp: avatar.convertedAvatarFromHttp,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
function createBootstrapperDeps() {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getPassword: vi.fn(() => "super-secret-password"),
getDeviceId: vi.fn(() => "DEVICE123"),
verificationManager: {
trackVerificationRequest: vi.fn(),
},
recoveryKeyStore: {
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
},
decryptBridge: {
bindCryptoRetrySignals: vi.fn(),
},
};
}
function createCryptoApi(overrides?: Partial<MatrixCryptoBootstrapApi>): MatrixCryptoBootstrapApi {
return {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
...overrides,
};
}
describe("MatrixCryptoBootstrapper", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto);
});
it("forces new cross-signing keys only when readiness check still fails", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
userHasCrossSigningKeys: vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("fails in strict mode when cross-signing keys are still unpublished", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
bootstrapCrossSigning: vi.fn(async () => {}),
isCrossSigningReady: vi.fn(async () => false),
userHasCrossSigningKeys: vi.fn(async () => false),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow(
"Cross-signing bootstrap finished but server keys are still not published",
);
});
it("uses password UIA fallback when null and dummy auth fail", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const bootstrapCrossSigningCalls = bootstrapCrossSigning.mock.calls as Array<
[
{
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
}?,
]
>;
const authUploadDeviceSigningKeys =
bootstrapCrossSigningCalls[0]?.[0]?.authUploadDeviceSigningKeys;
expect(authUploadDeviceSigningKeys).toBeTypeOf("function");
const seenAuthStages: Array<Record<string, unknown> | null> = [];
const result = await authUploadDeviceSigningKeys?.(async (authData) => {
seenAuthStages.push(authData);
if (authData === null) {
throw new Error("need auth");
}
if (authData.type === "m.login.dummy") {
throw new Error("dummy rejected");
}
if (authData.type === "m.login.password") {
return "ok";
}
throw new Error("unexpected auth stage");
});
expect(result).toBe("ok");
expect(seenAuthStages).toEqual([
null,
{ type: "m.login.dummy" },
{
type: "m.login.password",
identifier: { type: "m.id.user", user: "@bot:example.org" },
password: "super-secret-password",
},
]);
});
it("resets cross-signing when first bootstrap attempt throws", async () => {
const deps = createBootstrapperDeps();
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("first attempt failed"))
.mockResolvedValueOnce(undefined);
const crypto = createCryptoApi({
bootstrapCrossSigning,
isCrossSigningReady: vi.fn(async () => true),
userHasCrossSigningKeys: vi.fn(async () => true),
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2);
expect(bootstrapCrossSigning).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("marks own device verified and cross-signs it when needed", async () => {
const deps = createBootstrapperDeps();
const setDeviceVerified = vi.fn(async () => {});
const crossSignDevice = vi.fn(async () => {});
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
})),
setDeviceVerified,
crossSignDevice,
isCrossSigningReady: vi.fn(async () => true),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true);
expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123");
});
it("auto-accepts incoming verification requests from other users", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith(
verificationRequest,
);
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
});
it("still auto-accepts verification when tracking summary throws", async () => {
const deps = createBootstrapperDeps();
deps.verificationManager.trackVerificationRequest = vi.fn(() => {
throw new Error("summary failure");
});
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(verificationRequest.accept).toHaveBeenCalledTimes(1);
});
it("skips auto-accept for requests that are no longer requested", async () => {
const deps = createBootstrapperDeps();
const listeners = new Map<string, (...args: unknown[]) => void>();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
}),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
const verificationRequest = {
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: false,
phase: VerificationPhase.Cancelled,
accepting: false,
declining: false,
accept: vi.fn(async () => {}),
};
const listener = Array.from(listeners.entries()).find(([eventName]) =>
eventName.toLowerCase().includes("verificationrequest"),
)?.[1];
expect(listener).toBeTypeOf("function");
await listener?.(verificationRequest);
expect(verificationRequest.accept).not.toHaveBeenCalled();
});
it("registers verification listeners only once across repeated bootstrap calls", async () => {
const deps = createBootstrapperDeps();
const crypto = createCryptoApi({
getDeviceVerificationStatus: vi.fn(async () => ({
isVerified: () => true,
})),
});
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await bootstrapper.bootstrap(crypto);
await bootstrapper.bootstrap(crypto);
expect(crypto.on).toHaveBeenCalledTimes(1);
expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,334 @@
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js";
import type { MatrixDecryptBridge } from "./decrypt-bridge.js";
import { LogService } from "./logger.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type {
MatrixAuthDict,
MatrixCryptoBootstrapApi,
MatrixRawEvent,
MatrixUiAuthCallback,
} from "./types.js";
import type {
MatrixVerificationManager,
MatrixVerificationRequestLike,
} from "./verification-manager.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
decryptBridge: Pick<MatrixDecryptBridge<TRawEvent>, "bindCryptoRetrySignals">;
};
export type MatrixCryptoBootstrapOptions = {
forceResetCrossSigning?: boolean;
strict?: boolean;
};
export type MatrixCryptoBootstrapResult = {
crossSigningReady: boolean;
crossSigningPublished: boolean;
ownDeviceVerified: boolean | null;
};
export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
private verificationHandlerRegistered = false;
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(
crypto: MatrixCryptoBootstrapApi,
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
await this.bootstrapSecretStorage(crypto, strict);
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
strict,
});
await this.bootstrapSecretStorage(crypto, strict);
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict);
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
ownDeviceVerified,
};
}
private createSigningKeysUiAuthCallback(params: {
userId: string;
password?: string;
}): MatrixUiAuthCallback {
return async <T>(makeRequest: (authData: MatrixAuthDict | null) => Promise<T>): Promise<T> => {
try {
return await makeRequest(null);
} catch {
// Some homeservers require an explicit dummy UIA stage even when no user interaction is needed.
try {
return await makeRequest({ type: "m.login.dummy" });
} catch {
if (!params.password?.trim()) {
throw new Error(
"Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback",
);
}
return await makeRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: params.userId },
password: params.password,
});
}
}
};
}
private async bootstrapCrossSigning(
crypto: MatrixCryptoBootstrapApi,
options: { forceResetCrossSigning: boolean; strict: boolean },
): Promise<{ ready: boolean; published: boolean }> {
const userId = await this.deps.getUserId();
const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({
userId,
password: this.deps.getPassword?.(),
});
const hasPublishedCrossSigningKeys = async (): Promise<boolean> => {
if (typeof crypto.userHasCrossSigningKeys !== "function") {
return true;
}
try {
return await crypto.userHasCrossSigningKeys(userId, true);
} catch {
return false;
}
};
const isCrossSigningReady = async (): Promise<boolean> => {
if (typeof crypto.isCrossSigningReady !== "function") {
return true;
}
try {
return await crypto.isCrossSigningReady();
} catch {
return false;
}
};
const finalize = async (): Promise<{ ready: boolean; published: boolean }> => {
const ready = await isCrossSigningReady();
const published = await hasPublishedCrossSigningKeys();
if (ready && published) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready, published };
}
const message = "Cross-signing bootstrap finished but server keys are still not published";
LogService.warn("MatrixClientLite", message);
if (options.strict) {
throw new Error(message);
}
return { ready, published };
};
if (options.forceResetCrossSigning) {
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
return await finalize();
}
// First pass: preserve existing cross-signing identity and ensure public keys are uploaded.
try {
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Initial cross-signing bootstrap failed, trying reset:",
err,
);
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (resetErr) {
LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr);
if (options.strict) {
throw resetErr instanceof Error ? resetErr : new Error(String(resetErr));
}
return { ready: false, published: false };
}
}
const firstPassReady = await isCrossSigningReady();
const firstPassPublished = await hasPublishedCrossSigningKeys();
if (firstPassReady && firstPassPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return { ready: true, published: true };
}
// Fallback: recover from broken local/server state by creating a fresh identity.
try {
await crypto.bootstrapCrossSigning({
setupNewCrossSigning: true,
authUploadDeviceSigningKeys,
});
} catch (err) {
LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err);
if (options.strict) {
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };
}
return await finalize();
}
private async bootstrapSecretStorage(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): Promise<void> {
try {
await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto);
LogService.info("MatrixClientLite", "Secret storage bootstrap complete");
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err);
if (strict) {
throw err instanceof Error ? err : new Error(String(err));
}
}
}
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
if (this.verificationHandlerRegistered) {
return;
}
this.verificationHandlerRegistered = true;
// Auto-accept incoming verification requests from other users/devices.
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
const verificationRequest = request as MatrixVerificationRequestLike;
try {
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
} catch (err) {
LogService.warn(
"MatrixClientLite",
`Failed to track verification request from ${verificationRequest.otherUserId}:`,
err,
);
}
const otherUserId = verificationRequest.otherUserId;
const isSelfVerification = verificationRequest.isSelfVerification;
const initiatedByMe = verificationRequest.initiatedByMe;
const phase =
typeof verificationRequest.phase === "number"
? verificationRequest.phase
: VerificationPhase.Requested;
const accepting = verificationRequest.accepting === true;
const declining = verificationRequest.declining === true;
if (isSelfVerification || initiatedByMe) {
LogService.debug(
"MatrixClientLite",
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
);
return;
}
if (phase !== VerificationPhase.Requested || accepting || declining) {
LogService.debug(
"MatrixClientLite",
`Skipping auto-accept for ${otherUserId} in phase=${phase} accepting=${accepting} declining=${declining}`,
);
return;
}
try {
LogService.info(
"MatrixClientLite",
`Auto-accepting verification request from ${otherUserId}`,
);
await verificationRequest.accept();
LogService.info(
"MatrixClientLite",
`Verification request from ${otherUserId} accepted, waiting for SAS...`,
);
} catch (err) {
LogService.warn(
"MatrixClientLite",
`Failed to auto-accept verification from ${otherUserId}:`,
err,
);
}
});
this.deps.decryptBridge.bindCryptoRetrySignals(crypto);
LogService.info("MatrixClientLite", "Verification request handler registered");
}
private async ensureOwnDeviceTrust(
crypto: MatrixCryptoBootstrapApi,
strict = false,
): Promise<boolean | null> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
return null;
}
const userId = await this.deps.getUserId();
const deviceStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const alreadyVerified =
deviceStatus?.isVerified?.() === true ||
deviceStatus?.localVerified === true ||
deviceStatus?.crossSigningVerified === true ||
deviceStatus?.signedByOwner === true;
if (alreadyVerified) {
return true;
}
if (typeof crypto.setDeviceVerified === "function") {
await crypto.setDeviceVerified(userId, deviceId, true);
}
if (typeof crypto.crossSignDevice === "function") {
const crossSigningReady =
typeof crypto.isCrossSigningReady === "function"
? await crypto.isCrossSigningReady()
: true;
if (crossSigningReady) {
await crypto.crossSignDevice(deviceId);
}
}
const refreshedStatus =
typeof crypto.getDeviceVerificationStatus === "function"
? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null)
: null;
const verified =
refreshedStatus?.isVerified?.() === true ||
refreshedStatus?.localVerified === true ||
refreshedStatus?.crossSigningVerified === true ||
refreshedStatus?.signedByOwner === true;
if (!verified && strict) {
throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`);
}
return verified;
}
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import { createMatrixCryptoFacade } from "./crypto-facade.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixVerificationManager } from "./verification-manager.js";
describe("createMatrixCryptoFacade", () => {
it("detects encrypted rooms from cached room state", async () => {
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => true,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
});
it("falls back to server room state when room cache has no encryption event", async () => {
const getRoomStateEvent = vi.fn(async () => ({
algorithm: "m.megolm.v1.aes-sha2",
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => ({
hasEncryptionStateEvent: () => false,
}),
getCrypto: () => undefined,
},
verificationManager: {
requestOwnUserVerification: vi.fn(),
listVerifications: vi.fn(async () => []),
requestVerification: vi.fn(),
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => null),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent,
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true);
expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", "");
});
it("forwards verification requests and uses client crypto API", async () => {
const crypto = { requestOwnUserVerification: vi.fn(async () => null) };
const requestVerification = vi.fn(async () => ({
id: "verification-1",
otherUserId: "@alice:example.org",
isSelfVerification: false,
initiatedByMe: true,
phase: 2,
phaseName: "ready",
pending: true,
methods: ["m.sas.v1"],
canAccept: false,
hasSas: false,
hasReciprocateQr: false,
completed: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const facade = createMatrixCryptoFacade({
client: {
getRoom: () => null,
getCrypto: () => crypto,
},
verificationManager: {
requestOwnUserVerification: vi.fn(async () => null),
listVerifications: vi.fn(async () => []),
requestVerification,
acceptVerification: vi.fn(),
cancelVerification: vi.fn(),
startVerification: vi.fn(),
generateVerificationQr: vi.fn(),
scanVerificationQr: vi.fn(),
confirmVerificationSas: vi.fn(),
mismatchVerificationSas: vi.fn(),
confirmVerificationReciprocateQr: vi.fn(),
getVerificationSas: vi.fn(),
} as unknown as MatrixVerificationManager,
recoveryKeyStore: {
getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })),
} as unknown as MatrixRecoveryKeyStore,
getRoomStateEvent: vi.fn(async () => ({})),
downloadContent: vi.fn(async () => Buffer.alloc(0)),
});
const result = await facade.requestVerification({
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(requestVerification).toHaveBeenCalledWith(crypto, {
userId: "@alice:example.org",
deviceId: "DEVICE",
});
expect(result.id).toBe("verification-1");
await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" });
});
});

View File

@@ -0,0 +1,173 @@
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { EncryptedFile } from "./types.js";
import type {
MatrixVerificationCryptoApi,
MatrixVerificationManager,
MatrixVerificationMethod,
MatrixVerificationSummary,
} from "./verification-manager.js";
type MatrixCryptoFacadeClient = {
getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null;
getCrypto: () => unknown;
};
export type MatrixCryptoFacade = {
prepare: (joinedRooms: string[]) => Promise<void>;
updateSyncData: (
toDeviceMessages: unknown,
otkCounts: unknown,
unusedFallbackKeyAlgs: unknown,
changedDeviceLists: unknown,
leftDeviceLists: unknown,
) => Promise<void>;
isRoomEncrypted: (roomId: string) => Promise<boolean>;
requestOwnUserVerification: () => Promise<unknown | null>;
encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }>;
decryptMedia: (file: EncryptedFile) => Promise<Buffer>;
getRecoveryKey: () => Promise<{
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null>;
listVerifications: () => Promise<MatrixVerificationSummary[]>;
requestVerification: (params: {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
}) => Promise<MatrixVerificationSummary>;
acceptVerification: (id: string) => Promise<MatrixVerificationSummary>;
cancelVerification: (
id: string,
params?: { reason?: string; code?: string },
) => Promise<MatrixVerificationSummary>;
startVerification: (
id: string,
method?: MatrixVerificationMethod,
) => Promise<MatrixVerificationSummary>;
generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>;
scanVerificationQr: (id: string, qrDataBase64: string) => Promise<MatrixVerificationSummary>;
confirmVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
mismatchVerificationSas: (id: string) => Promise<MatrixVerificationSummary>;
confirmVerificationReciprocateQr: (id: string) => Promise<MatrixVerificationSummary>;
getVerificationSas: (
id: string,
) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>;
};
export function createMatrixCryptoFacade(deps: {
client: MatrixCryptoFacadeClient;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
getRoomStateEvent: (
roomId: string,
eventType: string,
stateKey?: string,
) => Promise<Record<string, unknown>>;
downloadContent: (mxcUrl: string) => Promise<Buffer>;
}): MatrixCryptoFacade {
return {
prepare: async (_joinedRooms: string[]) => {
// matrix-js-sdk performs crypto prep during startup; no extra work required here.
},
updateSyncData: async (
_toDeviceMessages: unknown,
_otkCounts: unknown,
_unusedFallbackKeyAlgs: unknown,
_changedDeviceLists: unknown,
_leftDeviceLists: unknown,
) => {
// compatibility no-op
},
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
const room = deps.client.getRoom(roomId);
if (room?.hasEncryptionStateEvent()) {
return true;
}
try {
const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", "");
return typeof event.algorithm === "string" && event.algorithm.length > 0;
} catch {
return false;
}
},
requestOwnUserVerification: async (): Promise<unknown | null> => {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
return await deps.verificationManager.requestOwnUserVerification(crypto);
},
encryptMedia: async (
buffer: Buffer,
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
const mediaInfoJson = encrypted.mediaEncryptionInfo;
if (!mediaInfoJson) {
throw new Error("Matrix media encryption failed: missing media encryption info");
}
const parsed = JSON.parse(mediaInfoJson) as EncryptedFile;
return {
buffer: Buffer.from(encrypted.encryptedData),
file: {
key: parsed.key,
iv: parsed.iv,
hashes: parsed.hashes,
v: parsed.v,
},
};
},
decryptMedia: async (file: EncryptedFile): Promise<Buffer> => {
const encrypted = await deps.downloadContent(file.url);
const metadata: EncryptedFile = {
url: file.url,
key: file.key,
iv: file.iv,
hashes: file.hashes,
v: file.v,
};
const attachment = new EncryptedAttachment(
new Uint8Array(encrypted),
JSON.stringify(metadata),
);
const decrypted = Attachment.decrypt(attachment);
return Buffer.from(decrypted);
},
getRecoveryKey: async () => {
return deps.recoveryKeyStore.getRecoveryKeySummary();
},
listVerifications: async () => {
return deps.verificationManager.listVerifications();
},
requestVerification: async (params) => {
const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined;
return await deps.verificationManager.requestVerification(crypto, params);
},
acceptVerification: async (id) => {
return await deps.verificationManager.acceptVerification(id);
},
cancelVerification: async (id, params) => {
return await deps.verificationManager.cancelVerification(id, params);
},
startVerification: async (id, method = "sas") => {
return await deps.verificationManager.startVerification(id, method);
},
generateVerificationQr: async (id) => {
return await deps.verificationManager.generateVerificationQr(id);
},
scanVerificationQr: async (id, qrDataBase64) => {
return await deps.verificationManager.scanVerificationQr(id, qrDataBase64);
},
confirmVerificationSas: async (id) => {
return await deps.verificationManager.confirmVerificationSas(id);
},
mismatchVerificationSas: async (id) => {
return deps.verificationManager.mismatchVerificationSas(id);
},
confirmVerificationReciprocateQr: async (id) => {
return deps.verificationManager.confirmVerificationReciprocateQr(id);
},
getVerificationSas: async (id) => {
return deps.verificationManager.getVerificationSas(id);
},
};
}

View File

@@ -0,0 +1,307 @@
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk";
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
import { LogService, noop } from "./logger.js";
type MatrixDecryptIfNeededClient = {
decryptEventIfNeeded?: (
event: MatrixEvent,
opts?: {
isRetry?: boolean;
},
) => Promise<void>;
};
type MatrixDecryptRetryState = {
event: MatrixEvent;
roomId: string;
eventId: string;
attempts: number;
inFlight: boolean;
timer: ReturnType<typeof setTimeout> | null;
};
type DecryptBridgeRawEvent = {
event_id: string;
};
type MatrixCryptoRetrySignalSource = {
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
};
const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500;
const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000;
const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8;
function resolveDecryptRetryKey(roomId: string, eventId: string): string | null {
if (!roomId || !eventId) {
return null;
}
return `${roomId}|${eventId}`;
}
function isDecryptionFailure(event: MatrixEvent): boolean {
return (
typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" &&
(event as { isDecryptionFailure: () => boolean }).isDecryptionFailure()
);
}
export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
private readonly trackedEncryptedEvents = new WeakSet<object>();
private readonly decryptedMessageDedupe = new Map<string, number>();
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
private readonly failedDecryptionsNotified = new Set<string>();
private cryptoRetrySignalsBound = false;
constructor(
private readonly deps: {
client: MatrixDecryptIfNeededClient;
toRaw: (event: MatrixEvent) => TRawEvent;
emitDecryptedEvent: (roomId: string, event: TRawEvent) => void;
emitMessage: (roomId: string, event: TRawEvent) => void;
emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void;
},
) {}
shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean {
if (!eventId) {
return true;
}
const key = `${roomId}|${eventId}`;
const createdAt = this.decryptedMessageDedupe.get(key);
if (createdAt === undefined) {
return true;
}
this.decryptedMessageDedupe.delete(key);
return false;
}
attachEncryptedEvent(event: MatrixEvent, roomId: string): void {
if (this.trackedEncryptedEvents.has(event)) {
return;
}
this.trackedEncryptedEvents.add(event);
event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => {
this.handleEncryptedEventDecrypted({
roomId,
encryptedEvent: event,
decryptedEvent,
err,
});
});
}
retryPendingNow(reason: string): void {
const pending = Array.from(this.decryptRetries.entries());
if (pending.length === 0) {
return;
}
LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`);
for (const [retryKey, state] of pending) {
if (state.timer) {
clearTimeout(state.timer);
state.timer = null;
}
if (state.inFlight) {
continue;
}
this.runDecryptRetry(retryKey).catch(noop);
}
}
bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void {
if (!crypto || this.cryptoRetrySignalsBound) {
return;
}
this.cryptoRetrySignalsBound = true;
const trigger = (reason: string): void => {
this.retryPendingNow(reason);
};
crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => {
trigger("crypto.keyBackupDecryptionKeyCached");
});
crypto.on(CryptoEvent.RehydrationCompleted, () => {
trigger("dehydration.RehydrationCompleted");
});
crypto.on(CryptoEvent.DevicesUpdated, () => {
trigger("crypto.devicesUpdated");
});
crypto.on(CryptoEvent.KeysChanged, () => {
trigger("crossSigning.keysChanged");
});
}
stop(): void {
for (const retryKey of this.decryptRetries.keys()) {
this.clearDecryptRetry(retryKey);
}
}
private handleEncryptedEventDecrypted(params: {
roomId: string;
encryptedEvent: MatrixEvent;
decryptedEvent: MatrixEvent;
err?: Error;
}): void {
const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId;
const decryptedRaw = this.deps.toRaw(params.decryptedEvent);
const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || "";
const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId);
if (params.err) {
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
return;
}
if (isDecryptionFailure(params.decryptedEvent)) {
this.emitFailedDecryptionOnce(
retryKey,
decryptedRoomId,
decryptedRaw,
new Error("Matrix event failed to decrypt"),
);
this.scheduleDecryptRetry({
event: params.encryptedEvent,
roomId: decryptedRoomId,
eventId: retryEventId,
});
return;
}
if (retryKey) {
this.clearDecryptRetry(retryKey);
}
this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id);
this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw);
this.deps.emitMessage(decryptedRoomId, decryptedRaw);
}
private emitFailedDecryptionOnce(
retryKey: string | null,
roomId: string,
event: TRawEvent,
error: Error,
): void {
if (retryKey) {
if (this.failedDecryptionsNotified.has(retryKey)) {
return;
}
this.failedDecryptionsNotified.add(retryKey);
}
this.deps.emitFailedDecryption(roomId, event, error);
}
private scheduleDecryptRetry(params: {
event: MatrixEvent;
roomId: string;
eventId: string;
}): void {
const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId);
if (!retryKey) {
return;
}
const existing = this.decryptRetries.get(retryKey);
if (existing?.timer || existing?.inFlight) {
return;
}
const attempts = (existing?.attempts ?? 0) + 1;
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
this.clearDecryptRetry(retryKey);
LogService.debug(
"MatrixClientLite",
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
);
return;
}
const delayMs = Math.min(
MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1),
MATRIX_DECRYPT_RETRY_MAX_DELAY_MS,
);
const next: MatrixDecryptRetryState = {
event: params.event,
roomId: params.roomId,
eventId: params.eventId,
attempts,
inFlight: false,
timer: null,
};
next.timer = setTimeout(() => {
this.runDecryptRetry(retryKey).catch(noop);
}, delayMs);
this.decryptRetries.set(retryKey, next);
}
private async runDecryptRetry(retryKey: string): Promise<void> {
const state = this.decryptRetries.get(retryKey);
if (!state || state.inFlight) {
return;
}
state.inFlight = true;
state.timer = null;
const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function";
if (!canDecrypt) {
this.clearDecryptRetry(retryKey);
return;
}
try {
await this.deps.client.decryptEventIfNeeded?.(state.event, {
isRetry: true,
});
} catch {
// Retry with backoff until we hit the configured retry cap.
} finally {
state.inFlight = false;
}
if (isDecryptionFailure(state.event)) {
this.scheduleDecryptRetry(state);
return;
}
this.clearDecryptRetry(retryKey);
}
private clearDecryptRetry(retryKey: string): void {
const state = this.decryptRetries.get(retryKey);
if (state?.timer) {
clearTimeout(state.timer);
}
this.decryptRetries.delete(retryKey);
this.failedDecryptionsNotified.delete(retryKey);
}
private rememberDecryptedMessage(roomId: string, eventId: string): void {
if (!eventId) {
return;
}
const now = Date.now();
this.pruneDecryptedMessageDedupe(now);
this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now);
}
private pruneDecryptedMessageDedupe(now: number): void {
const ttlMs = 30_000;
for (const [key, createdAt] of this.decryptedMessageDedupe) {
if (now - createdAt > ttlMs) {
this.decryptedMessageDedupe.delete(key);
}
}
const maxEntries = 2048;
while (this.decryptedMessageDedupe.size > maxEntries) {
const oldest = this.decryptedMessageDedupe.keys().next().value;
if (oldest === undefined) {
break;
}
this.decryptedMessageDedupe.delete(oldest);
}
}
}

View File

@@ -0,0 +1,60 @@
import type { MatrixEvent } from "matrix-js-sdk";
import { describe, expect, it } from "vitest";
import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js";
describe("event-helpers", () => {
it("parses mxc URIs", () => {
expect(parseMxc("mxc://server.example/media-id")).toEqual({
server: "server.example",
mediaId: "media-id",
});
expect(parseMxc("not-mxc")).toBeNull();
});
it("builds HTTP errors from JSON and plain text payloads", () => {
const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" }));
expect(fromJson.message).toBe("forbidden");
expect(fromJson.statusCode).toBe(403);
const fromText = buildHttpError(500, "internal failure");
expect(fromText.message).toBe("internal failure");
expect(fromText.statusCode).toBe(500);
});
it("serializes Matrix events and resolves state key from available sources", () => {
const viaGetter = {
getId: () => "$1",
getSender: () => "@alice:example.org",
getType: () => "m.room.member",
getTs: () => 1000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({ age: 1 }),
getStateKey: () => "@alice:example.org",
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org");
const viaWire = {
getId: () => "$2",
getSender: () => "@bob:example.org",
getType: () => "m.room.member",
getTs: () => 2000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({}),
getStateKey: () => undefined,
getWireContent: () => ({ state_key: "@bob:example.org" }),
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org");
const viaRaw = {
getId: () => "$3",
getSender: () => "@carol:example.org",
getType: () => "m.room.member",
getTs: () => 3000,
getContent: () => ({ membership: "join" }),
getUnsigned: () => ({}),
getStateKey: () => undefined,
event: { state_key: "@carol:example.org" },
} as unknown as MatrixEvent;
expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org");
});
});

View File

@@ -0,0 +1,71 @@
import type { MatrixEvent } from "matrix-js-sdk";
import type { MatrixRawEvent } from "./types.js";
export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
const unsigned = (event.getUnsigned?.() ?? {}) as {
age?: number;
redacted_because?: unknown;
};
const raw: MatrixRawEvent = {
event_id: event.getId() ?? "",
sender: event.getSender() ?? "",
type: event.getType() ?? "",
origin_server_ts: event.getTs() ?? 0,
content: ((event.getContent?.() ?? {}) as Record<string, unknown>) || {},
unsigned,
};
const stateKey = resolveMatrixStateKey(event);
if (typeof stateKey === "string") {
raw.state_key = stateKey;
}
return raw;
}
export function parseMxc(url: string): { server: string; mediaId: string } | null {
const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim());
if (!match) {
return null;
}
return {
server: match[1],
mediaId: match[2],
};
}
export function buildHttpError(
statusCode: number,
bodyText: string,
): Error & { statusCode: number } {
let message = `Matrix HTTP ${statusCode}`;
if (bodyText.trim()) {
try {
const parsed = JSON.parse(bodyText) as { error?: string };
if (typeof parsed.error === "string" && parsed.error.trim()) {
message = parsed.error.trim();
} else {
message = bodyText.slice(0, 500);
}
} catch {
message = bodyText.slice(0, 500);
}
}
return Object.assign(new Error(message), { statusCode });
}
function resolveMatrixStateKey(event: MatrixEvent): string | undefined {
const direct = event.getStateKey?.();
if (typeof direct === "string") {
return direct;
}
const wireContent = (
event as { getWireContent?: () => { state_key?: unknown } }
).getWireContent?.();
if (wireContent && typeof wireContent.state_key === "string") {
return wireContent.state_key;
}
const rawEvent = (event as { event?: { state_key?: unknown } }).event;
if (rawEvent && typeof rawEvent.state_key === "string") {
return rawEvent.state_key;
}
return undefined;
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { performMatrixRequestMock } = vi.hoisted(() => ({
performMatrixRequestMock: vi.fn(),
}));
vi.mock("./transport.js", () => ({
performMatrixRequest: performMatrixRequestMock,
}));
import { MatrixAuthedHttpClient } from "./http-client.js";
describe("MatrixAuthedHttpClient", () => {
beforeEach(() => {
performMatrixRequestMock.mockReset();
});
it("parses JSON responses and forwards absolute-endpoint opt-in", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json" },
}),
text: '{"ok":true}',
buffer: Buffer.from('{"ok":true}', "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestJson({
method: "GET",
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
allowAbsoluteEndpoint: true,
});
expect(result).toEqual({ ok: true });
expect(performMatrixRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami",
allowAbsoluteEndpoint: true,
}),
);
});
it("returns plain text when response is not JSON", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response("pong", {
status: 200,
headers: { "content-type": "text/plain" },
}),
text: "pong",
buffer: Buffer.from("pong", "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestJson({
method: "GET",
endpoint: "/_matrix/client/v3/ping",
timeoutMs: 5000,
});
expect(result).toBe("pong");
});
it("returns raw buffers for media requests", async () => {
const payload = Buffer.from([1, 2, 3, 4]);
performMatrixRequestMock.mockResolvedValue({
response: new Response(payload, { status: 200 }),
text: payload.toString("utf8"),
buffer: payload,
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
const result = await client.requestRaw({
method: "GET",
endpoint: "/_matrix/media/v3/download/example/id",
timeoutMs: 5000,
});
expect(result).toEqual(payload);
});
it("raises HTTP errors with status code metadata", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
}),
text: JSON.stringify({ error: "forbidden" }),
buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"),
});
const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token");
await expect(
client.requestJson({
method: "GET",
endpoint: "/_matrix/client/v3/rooms",
timeoutMs: 5000,
}),
).rejects.toMatchObject({
message: "forbidden",
statusCode: 403,
});
});
});

View File

@@ -0,0 +1,63 @@
import { buildHttpError } from "./event-helpers.js";
import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js";
export class MatrixAuthedHttpClient {
constructor(
private readonly homeserver: string,
private readonly accessToken: string,
) {}
async requestJson(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
body?: unknown;
timeoutMs: number;
allowAbsoluteEndpoint?: boolean;
}): Promise<unknown> {
const { response, text } = await performMatrixRequest({
homeserver: this.homeserver,
accessToken: this.accessToken,
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
body: params.body,
timeoutMs: params.timeoutMs,
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
});
if (!response.ok) {
throw buildHttpError(response.status, text);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (!text.trim()) {
return {};
}
return JSON.parse(text);
}
return text;
}
async requestRaw(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
timeoutMs: number;
allowAbsoluteEndpoint?: boolean;
}): Promise<Buffer> {
const { response, buffer } = await performMatrixRequest({
homeserver: this.homeserver,
accessToken: this.accessToken,
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
timeoutMs: params.timeoutMs,
raw: true,
allowAbsoluteEndpoint: params.allowAbsoluteEndpoint,
});
if (!response.ok) {
throw buildHttpError(response.status, buffer.toString("utf8"));
}
return buffer;
}
}

View File

@@ -0,0 +1,164 @@
import fs from "node:fs";
import path from "node:path";
import { indexedDB as fakeIndexedDB } from "fake-indexeddb";
import { LogService } from "./logger.js";
type IdbStoreSnapshot = {
name: string;
keyPath: IDBObjectStoreParameters["keyPath"];
autoIncrement: boolean;
indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[];
records: { key: IDBValidKey; value: unknown }[];
};
type IdbDatabaseSnapshot = {
name: string;
version: number;
stores: IdbStoreSnapshot[];
};
function idbReq<T>(req: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dumpIndexedDatabases(databasePrefix?: string): Promise<IdbDatabaseSnapshot[]> {
const idb = fakeIndexedDB;
const dbList = await idb.databases();
const snapshot: IdbDatabaseSnapshot[] = [];
const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null;
for (const { name, version } of dbList) {
if (!name || !version) continue;
if (expectedPrefix && !name.startsWith(expectedPrefix)) continue;
const db: IDBDatabase = await new Promise((resolve, reject) => {
const r = idb.open(name, version);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
const stores: IdbStoreSnapshot[] = [];
for (const storeName of db.objectStoreNames) {
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const storeInfo: IdbStoreSnapshot = {
name: storeName,
keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"],
autoIncrement: store.autoIncrement,
indexes: [],
records: [],
};
for (const idxName of store.indexNames) {
const idx = store.index(idxName);
storeInfo.indexes.push({
name: idxName,
keyPath: idx.keyPath as string | string[],
multiEntry: idx.multiEntry,
unique: idx.unique,
});
}
const keys = await idbReq(store.getAllKeys());
const values = await idbReq(store.getAll());
storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] }));
stores.push(storeInfo);
}
snapshot.push({ name, version, stores });
db.close();
}
return snapshot;
}
async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise<void> {
const idb = fakeIndexedDB;
for (const dbSnap of snapshot) {
await new Promise<void>((resolve, reject) => {
const r = idb.open(dbSnap.name, dbSnap.version);
r.onupgradeneeded = () => {
const db = r.result;
for (const storeSnap of dbSnap.stores) {
const opts: IDBObjectStoreParameters = {};
if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath;
if (storeSnap.autoIncrement) opts.autoIncrement = true;
const store = db.createObjectStore(storeSnap.name, opts);
for (const idx of storeSnap.indexes) {
store.createIndex(idx.name, idx.keyPath, {
unique: idx.unique,
multiEntry: idx.multiEntry,
});
}
}
};
r.onsuccess = async () => {
try {
const db = r.result;
for (const storeSnap of dbSnap.stores) {
if (storeSnap.records.length === 0) continue;
const tx = db.transaction(storeSnap.name, "readwrite");
const store = tx.objectStore(storeSnap.name);
for (const rec of storeSnap.records) {
if (storeSnap.keyPath !== null) {
store.put(rec.value);
} else {
store.put(rec.value, rec.key);
}
}
await new Promise<void>((res) => {
tx.oncomplete = () => res();
});
}
db.close();
resolve();
} catch (err) {
reject(err);
}
};
r.onerror = () => reject(r.error);
});
}
}
function resolveDefaultIdbSnapshotPath(): string {
const stateDir =
process.env.OPENCLAW_STATE_DIR ||
process.env.MOLTBOT_STATE_DIR ||
path.join(process.env.HOME || "/tmp", ".openclaw");
return path.join(stateDir, "credentials", "matrix-js", "crypto-idb-snapshot.json");
}
export async function restoreIdbFromDisk(snapshotPath?: string): Promise<boolean> {
const resolvedPath = snapshotPath ?? resolveDefaultIdbSnapshotPath();
try {
const data = fs.readFileSync(resolvedPath, "utf8");
const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data);
if (!Array.isArray(snapshot) || snapshot.length === 0) return false;
await restoreIndexedDatabases(snapshot);
LogService.info(
"IdbPersistence",
`Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`,
);
return true;
} catch {
return false;
}
}
export async function persistIdbToDisk(params?: {
snapshotPath?: string;
databasePrefix?: string;
}): Promise<void> {
const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath();
try {
const snapshot = await dumpIndexedDatabases(params?.databasePrefix);
if (snapshot.length === 0) return;
fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot));
LogService.debug(
"IdbPersistence",
`Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`,
);
} catch (err) {
LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err);
}
}

View File

@@ -0,0 +1,98 @@
import { format } from "node:util";
import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
export type Logger = {
trace: (module: string, ...messageOrObject: unknown[]) => void;
debug: (module: string, ...messageOrObject: unknown[]) => void;
info: (module: string, ...messageOrObject: unknown[]) => void;
warn: (module: string, ...messageOrObject: unknown[]) => void;
error: (module: string, ...messageOrObject: unknown[]) => void;
};
export function noop(): void {
// no-op
}
function resolveRuntimeLogger(module: string): RuntimeLogger | null {
try {
return getMatrixRuntime().logging.getChildLogger({ module: `matrix-js:${module}` });
} catch {
return null;
}
}
function formatMessage(module: string, messageOrObject: unknown[]): string {
if (messageOrObject.length === 0) {
return `[${module}]`;
}
return `[${module}] ${format(...messageOrObject)}`;
}
export class ConsoleLogger {
private emit(
level: "debug" | "info" | "warn" | "error",
module: string,
...messageOrObject: unknown[]
): void {
const runtimeLogger = resolveRuntimeLogger(module);
const message = formatMessage(module, messageOrObject);
if (runtimeLogger) {
if (level === "debug") {
runtimeLogger.debug?.(message);
return;
}
runtimeLogger[level](message);
return;
}
if (level === "debug") {
console.debug(message);
return;
}
console[level](message);
}
trace(module: string, ...messageOrObject: unknown[]): void {
this.emit("debug", module, ...messageOrObject);
}
debug(module: string, ...messageOrObject: unknown[]): void {
this.emit("debug", module, ...messageOrObject);
}
info(module: string, ...messageOrObject: unknown[]): void {
this.emit("info", module, ...messageOrObject);
}
warn(module: string, ...messageOrObject: unknown[]): void {
this.emit("warn", module, ...messageOrObject);
}
error(module: string, ...messageOrObject: unknown[]): void {
this.emit("error", module, ...messageOrObject);
}
}
const defaultLogger = new ConsoleLogger();
let activeLogger: Logger = defaultLogger;
export const LogService = {
setLogger(logger: Logger): void {
activeLogger = logger;
},
trace(module: string, ...messageOrObject: unknown[]): void {
activeLogger.trace(module, ...messageOrObject);
},
debug(module: string, ...messageOrObject: unknown[]): void {
activeLogger.debug(module, ...messageOrObject);
},
info(module: string, ...messageOrObject: unknown[]): void {
activeLogger.info(module, ...messageOrObject);
},
warn(module: string, ...messageOrObject: unknown[]): void {
activeLogger.warn(module, ...messageOrObject);
},
error(module: string, ...messageOrObject: unknown[]): void {
activeLogger.error(module, ...messageOrObject);
},
};

View File

@@ -0,0 +1,202 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixCryptoBootstrapApi } from "./types.js";
function createTempRecoveryKeyPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-"));
return path.join(dir, "recovery-key.json");
}
describe("MatrixRecoveryKeyStore", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("loads a stored recovery key for requested secret-storage keys", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSS",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const callbacks = store.buildCryptoCallbacks();
const resolved = await callbacks.getSecretStorageKey?.(
{ keys: { SSSS: { name: "test" } } },
"m.cross_signing.master",
);
expect(resolved?.[0]).toBe("SSSS");
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
});
it("persists cached secret-storage keys with secure file permissions", () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const callbacks = store.buildCryptoCallbacks();
callbacks.cacheSecretStorageKey?.(
"KEY123",
{
name: "openclaw",
},
new Uint8Array([9, 8, 7]),
);
const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
keyId?: string;
privateKeyBase64?: string;
};
expect(saved.keyId).toBe("KEY123");
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
const mode = fs.statSync(recoveryKeyPath).mode & 0o777;
expect(mode).toBe(0o600);
});
it("creates and persists a recovery key when secret storage is missing", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "GENERATED",
keyInfo: { name: "generated" },
privateKey: new Uint8Array([5, 6, 7, 8]),
encodedPrivateKey: "encoded-generated-key",
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewSecretStorage: true,
}),
);
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "GENERATED",
encodedPrivateKey: "encoded-generated-key",
});
});
it("rebinds stored recovery key to server default key id when it changes", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "OLD",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const bootstrapSecretStorage = vi.fn(async () => {});
const createRecoveryKeyFromPassphrase = vi.fn(async () => {
throw new Error("should not be called");
});
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled();
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "NEW",
});
});
it("recreates secret storage when default key exists but is not usable locally", async () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const generated = {
keyId: "RECOVERED",
keyInfo: { name: "recovered" },
privateKey: new Uint8Array([1, 1, 2, 3]),
encodedPrivateKey: "encoded-recovered-key",
};
const createRecoveryKeyFromPassphrase = vi.fn(async () => generated);
const bootstrapSecretStorage = vi.fn(
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
await opts?.createSecretStorageKey?.();
},
);
const crypto = {
on: vi.fn(),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage,
createRecoveryKeyFromPassphrase,
getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })),
requestOwnUserVerification: vi.fn(async () => null),
} as unknown as MatrixCryptoBootstrapApi;
await store.bootstrapSecretStorageWithRecoveryKey(crypto);
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
expect.objectContaining({
setupNewSecretStorage: true,
}),
);
expect(store.getRecoveryKeySummary()).toMatchObject({
keyId: "RECOVERED",
encodedPrivateKey: "encoded-recovered-key",
});
});
it("stores an encoded recovery key and decodes its private key material", () => {
const recoveryKeyPath = createTempRecoveryKeyPath();
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
expect(encoded).toBeTypeOf("string");
const summary = store.storeEncodedRecoveryKey({
encodedPrivateKey: encoded as string,
keyId: "SSSSKEY",
});
expect(summary.keyId).toBe("SSSSKEY");
expect(summary.encodedPrivateKey).toBe(encoded);
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
privateKeyBase64?: string;
keyId?: string;
};
expect(persisted.keyId).toBe("SSSSKEY");
expect(
Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals(
Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,294 @@
import fs from "node:fs";
import path from "node:path";
import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js";
import { LogService } from "./logger.js";
import type {
MatrixCryptoBootstrapApi,
MatrixCryptoCallbacks,
MatrixGeneratedSecretStorageKey,
MatrixSecretStorageStatus,
MatrixStoredRecoveryKey,
} from "./types.js";
export class MatrixRecoveryKeyStore {
private readonly secretStorageKeyCache = new Map<
string,
{ key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] }
>();
constructor(private readonly recoveryKeyPath?: string) {}
buildCryptoCallbacks(): MatrixCryptoCallbacks {
return {
getSecretStorageKey: async ({ keys }) => {
const requestedKeyIds = Object.keys(keys ?? {});
if (requestedKeyIds.length === 0) {
return null;
}
for (const keyId of requestedKeyIds) {
const cached = this.secretStorageKeyCache.get(keyId);
if (cached) {
return [keyId, new Uint8Array(cached.key)];
}
}
const stored = this.loadStoredRecoveryKey();
if (!stored || !stored.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
if (privateKey.length === 0) {
return null;
}
if (stored.keyId && requestedKeyIds.includes(stored.keyId)) {
this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo);
return [stored.keyId, privateKey];
}
const firstRequestedKeyId = requestedKeyIds[0];
if (!firstRequestedKeyId) {
return null;
}
this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo);
return [firstRequestedKeyId, privateKey];
},
cacheSecretStorageKey: (keyId, keyInfo, key) => {
const privateKey = new Uint8Array(key);
const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = {
passphrase: keyInfo?.passphrase,
name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined,
};
this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo);
const stored = this.loadStoredRecoveryKey();
this.saveRecoveryKeyToDisk({
keyId,
keyInfo: normalizedKeyInfo,
privateKey,
encodedPrivateKey: stored?.encodedPrivateKey,
});
},
};
}
getRecoveryKeySummary(): {
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null {
const stored = this.loadStoredRecoveryKey();
if (!stored) {
return null;
}
return {
encodedPrivateKey: stored.encodedPrivateKey,
keyId: stored.keyId,
createdAt: stored.createdAt,
};
}
storeEncodedRecoveryKey(params: {
encodedPrivateKey: string;
keyId?: string | null;
keyInfo?: MatrixStoredRecoveryKey["keyInfo"];
}): {
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} {
const encodedPrivateKey = params.encodedPrivateKey.trim();
if (!encodedPrivateKey) {
throw new Error("Matrix recovery key is required");
}
let privateKey: Uint8Array;
try {
privateKey = decodeRecoveryKey(encodedPrivateKey);
} catch (err) {
throw new Error(
`Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`,
);
}
const normalizedKeyId =
typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null;
const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo;
this.saveRecoveryKeyToDisk({
keyId: normalizedKeyId,
keyInfo,
privateKey,
encodedPrivateKey,
});
if (normalizedKeyId) {
this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo);
}
return this.getRecoveryKeySummary() ?? {};
}
async bootstrapSecretStorageWithRecoveryKey(
crypto: MatrixCryptoBootstrapApi,
options: { setupNewKeyBackup?: boolean } = {},
): Promise<void> {
let status: MatrixSecretStorageStatus | null = null;
if (typeof crypto.getSecretStorageStatus === "function") {
try {
status = await crypto.getSecretStorageStatus();
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err);
}
}
const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId);
const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some(
(valid) => valid === false,
);
let generatedRecoveryKey = false;
const storedRecovery = this.loadStoredRecoveryKey();
let recoveryKey: MatrixGeneratedSecretStorageKey | null = storedRecovery
? {
keyInfo: storedRecovery.keyInfo,
privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")),
encodedPrivateKey: storedRecovery.encodedPrivateKey,
}
: null;
if (recoveryKey && status?.defaultKeyId) {
const defaultKeyId = status.defaultKeyId;
this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo);
if (storedRecovery?.keyId !== defaultKeyId) {
this.saveRecoveryKeyToDisk({
keyId: defaultKeyId,
keyInfo: recoveryKey.keyInfo,
privateKey: recoveryKey.privateKey,
encodedPrivateKey: recoveryKey.encodedPrivateKey,
});
}
}
const ensureRecoveryKey = async (): Promise<MatrixGeneratedSecretStorageKey> => {
if (recoveryKey) {
return recoveryKey;
}
if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") {
throw new Error(
"Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)",
);
}
recoveryKey = await crypto.createRecoveryKeyFromPassphrase();
this.saveRecoveryKeyToDisk(recoveryKey);
generatedRecoveryKey = true;
return recoveryKey;
};
const shouldRecreateSecretStorage =
!hasDefaultSecretStorageKey ||
(!recoveryKey && status?.ready === false) ||
hasKnownInvalidSecrets;
if (hasKnownInvalidSecrets) {
// Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key.
recoveryKey = null;
}
const secretStorageOptions: {
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
setupNewKeyBackup?: boolean;
} = {
setupNewKeyBackup: options.setupNewKeyBackup === true,
};
if (shouldRecreateSecretStorage) {
secretStorageOptions.setupNewSecretStorage = true;
secretStorageOptions.createSecretStorageKey = ensureRecoveryKey;
}
await crypto.bootstrapSecretStorage(secretStorageOptions);
if (generatedRecoveryKey && this.recoveryKeyPath) {
LogService.warn(
"MatrixClientLite",
`Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`,
);
}
}
private rememberSecretStorageKey(
keyId: string,
key: Uint8Array,
keyInfo?: MatrixStoredRecoveryKey["keyInfo"],
): void {
if (!keyId.trim()) {
return;
}
this.secretStorageKeyCache.set(keyId, {
key: new Uint8Array(key),
keyInfo,
});
}
private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null {
if (!this.recoveryKeyPath) {
return null;
}
try {
if (!fs.existsSync(this.recoveryKeyPath)) {
return null;
}
const raw = fs.readFileSync(this.recoveryKeyPath, "utf8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredRecoveryKey>;
if (
parsed.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.privateKeyBase64 !== "string" ||
!parsed.privateKeyBase64.trim()
) {
return null;
}
return {
version: 1,
createdAt: parsed.createdAt,
keyId: typeof parsed.keyId === "string" ? parsed.keyId : null,
encodedPrivateKey:
typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined,
privateKeyBase64: parsed.privateKeyBase64,
keyInfo:
parsed.keyInfo && typeof parsed.keyInfo === "object"
? {
passphrase: parsed.keyInfo.passphrase,
name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined,
}
: undefined,
};
} catch {
return null;
}
}
private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void {
if (!this.recoveryKeyPath) {
return;
}
try {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: typeof params.keyId === "string" ? params.keyId : null,
encodedPrivateKey: params.encodedPrivateKey,
privateKeyBase64: Buffer.from(params.privateKey).toString("base64"),
keyInfo: params.keyInfo
? {
passphrase: params.keyInfo.passphrase,
name: params.keyInfo.name,
}
: undefined,
};
fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true });
fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8");
fs.chmodSync(this.recoveryKeyPath, 0o600);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err);
}
}
}

View File

@@ -0,0 +1,171 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
export type QueryParams = Record<string, QueryValue> | null | undefined;
function normalizeEndpoint(endpoint: string): string {
if (!endpoint) {
return "/";
}
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
}
function applyQuery(url: URL, qs: QueryParams): void {
if (!qs) {
return;
}
for (const [key, rawValue] of Object.entries(qs)) {
if (rawValue === undefined || rawValue === null) {
continue;
}
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
if (item === undefined || item === null) {
continue;
}
url.searchParams.append(key, String(item));
}
continue;
}
url.searchParams.set(key, String(rawValue));
}
}
function isRedirectStatus(statusCode: number): boolean {
return statusCode >= 300 && statusCode < 400;
}
async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise<Response> {
let currentUrl = new URL(url.toString());
let method = (init.method ?? "GET").toUpperCase();
let body = init.body;
let headers = new Headers(init.headers ?? {});
const maxRedirects = 5;
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
const response = await fetch(currentUrl, {
...init,
method,
body,
headers,
redirect: "manual",
});
if (!isRedirectStatus(response.status)) {
return response;
}
const location = response.headers.get("location");
if (!location) {
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
}
const nextUrl = new URL(location, currentUrl);
if (nextUrl.protocol !== currentUrl.protocol) {
throw new Error(
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
);
}
if (nextUrl.origin !== currentUrl.origin) {
headers = new Headers(headers);
headers.delete("authorization");
}
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method !== "GET" &&
method !== "HEAD")
) {
method = "GET";
body = undefined;
headers = new Headers(headers);
headers.delete("content-type");
headers.delete("content-length");
}
currentUrl = nextUrl;
}
throw new Error(`Too many redirects while requesting ${url.toString()}`);
}
export async function performMatrixRequest(params: {
homeserver: string;
accessToken: string;
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
body?: unknown;
timeoutMs: number;
raw?: boolean;
allowAbsoluteEndpoint?: boolean;
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
const isAbsoluteEndpoint =
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://");
if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) {
throw new Error(
`Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`,
);
}
const baseUrl = isAbsoluteEndpoint
? new URL(params.endpoint)
: new URL(normalizeEndpoint(params.endpoint), params.homeserver);
applyQuery(baseUrl, params.qs);
const headers = new Headers();
headers.set("Accept", params.raw ? "*/*" : "application/json");
if (params.accessToken) {
headers.set("Authorization", `Bearer ${params.accessToken}`);
}
let body: BodyInit | undefined;
if (params.body !== undefined) {
if (
params.body instanceof Uint8Array ||
params.body instanceof ArrayBuffer ||
typeof params.body === "string"
) {
body = params.body as BodyInit;
} else {
headers.set("Content-Type", "application/json");
body = JSON.stringify(params.body);
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const response = await fetchWithSafeRedirects(baseUrl, {
method: params.method,
headers,
body,
signal: controller.signal,
});
if (params.raw) {
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
text: bytes.toString("utf8"),
buffer: bytes,
};
}
const text = await response.text();
return {
response,
text,
buffer: Buffer.from(text, "utf8"),
};
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -0,0 +1,217 @@
import type { MatrixVerificationRequestLike } from "./verification-manager.js";
export type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
state_key?: string;
};
export type MatrixClientEventMap = {
"room.event": [roomId: string, event: MatrixRawEvent];
"room.message": [roomId: string, event: MatrixRawEvent];
"room.encrypted_event": [roomId: string, event: MatrixRawEvent];
"room.decrypted_event": [roomId: string, event: MatrixRawEvent];
"room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error];
"room.invite": [roomId: string, event: MatrixRawEvent];
"room.join": [roomId: string, event: MatrixRawEvent];
};
export type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
export type FileWithThumbnailInfo = {
size?: number;
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
export type DimensionalFileInfo = FileWithThumbnailInfo & {
w?: number;
h?: number;
};
export type TimedFileInfo = FileWithThumbnailInfo & {
duration?: number;
};
export type VideoFileInfo = DimensionalFileInfo &
TimedFileInfo & {
duration?: number;
};
export type MessageEventContent = {
msgtype?: string;
body?: string;
format?: string;
formatted_body?: string;
filename?: string;
url?: string;
file?: EncryptedFile;
info?: Record<string, unknown>;
"m.relates_to"?: Record<string, unknown>;
"m.new_content"?: unknown;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
[key: string]: unknown;
};
export type TextualMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
};
export type LocationMessageEventContent = MessageEventContent & {
msgtype?: string;
geo_uri?: string;
};
export type MatrixSecretStorageStatus = {
ready: boolean;
defaultKeyId: string | null;
secretStorageKeyValidityMap?: Record<string, boolean>;
};
export type MatrixGeneratedSecretStorageKey = {
keyId?: string | null;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
privateKey: Uint8Array;
encodedPrivateKey?: string;
};
export type MatrixDeviceVerificationStatusLike = {
isVerified?: () => boolean;
localVerified?: boolean;
crossSigningVerified?: boolean;
signedByOwner?: boolean;
};
export type MatrixKeyBackupInfo = {
algorithm: string;
auth_data: Record<string, unknown>;
count?: number;
etag?: string;
version?: string;
};
export type MatrixKeyBackupTrustInfo = {
trusted: boolean;
matchesDecryptionKey: boolean;
};
export type MatrixRoomKeyBackupRestoreResult = {
total: number;
imported: number;
};
export type MatrixImportRoomKeyProgress = {
stage: string;
successes?: number;
failures?: number;
total?: number;
};
export type MatrixSecretStorageKeyDescription = {
passphrase?: unknown;
name?: string;
[key: string]: unknown;
};
export type MatrixCryptoCallbacks = {
getSecretStorageKey?: (
params: { keys: Record<string, MatrixSecretStorageKeyDescription> },
name: string,
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (
keyId: string,
keyInfo: MatrixSecretStorageKeyDescription,
key: Uint8Array,
) => void;
};
export type MatrixStoredRecoveryKey = {
version: 1;
createdAt: string;
keyId?: string | null;
encodedPrivateKey?: string;
privateKeyBase64: string;
keyInfo?: {
passphrase?: unknown;
name?: string;
};
};
export type MatrixAuthDict = Record<string, unknown>;
export type MatrixUiAuthCallback = <T>(
makeRequest: (authData: MatrixAuthDict | null) => Promise<T>,
) => Promise<T>;
export type MatrixCryptoBootstrapApi = {
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
bootstrapCrossSigning: (opts: {
setupNewCrossSigning?: boolean;
authUploadDeviceSigningKeys?: MatrixUiAuthCallback;
}) => Promise<void>;
bootstrapSecretStorage: (opts?: {
createSecretStorageKey?: () => Promise<MatrixGeneratedSecretStorageKey>;
setupNewSecretStorage?: boolean;
setupNewKeyBackup?: boolean;
}) => Promise<void>;
createRecoveryKeyFromPassphrase?: (password?: string) => Promise<MatrixGeneratedSecretStorageKey>;
getSecretStorageStatus?: () => Promise<MatrixSecretStorageStatus>;
requestOwnUserVerification: () => Promise<unknown | null>;
requestDeviceVerification?: (
userId: string,
deviceId: string,
) => Promise<MatrixVerificationRequestLike>;
requestVerificationDM?: (
userId: string,
roomId: string,
) => Promise<MatrixVerificationRequestLike>;
getDeviceVerificationStatus?: (
userId: string,
deviceId: string,
) => Promise<MatrixDeviceVerificationStatusLike | null>;
getSessionBackupPrivateKey?: () => Promise<Uint8Array | null>;
loadSessionBackupPrivateKeyFromSecretStorage?: () => Promise<void>;
getActiveSessionBackupVersion?: () => Promise<string | null>;
getKeyBackupInfo?: () => Promise<MatrixKeyBackupInfo | null>;
isKeyBackupTrusted?: (info: MatrixKeyBackupInfo) => Promise<MatrixKeyBackupTrustInfo>;
checkKeyBackupAndEnable?: () => Promise<unknown>;
restoreKeyBackup?: (opts?: {
progressCallback?: (progress: MatrixImportRoomKeyProgress) => void;
}) => Promise<MatrixRoomKeyBackupRestoreResult>;
setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise<void>;
crossSignDevice?: (deviceId: string) => Promise<void>;
isCrossSigningReady?: () => Promise<boolean>;
userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise<boolean>;
};

View File

@@ -0,0 +1,347 @@
import { EventEmitter } from "node:events";
import {
VerificationPhase,
VerificationRequestEvent,
} from "matrix-js-sdk/lib/crypto-api/verification.js";
import { describe, expect, it, vi } from "vitest";
import {
MatrixVerificationManager,
type MatrixShowQrCodeCallbacks,
type MatrixShowSasCallbacks,
type MatrixVerificationRequestLike,
type MatrixVerifierLike,
} from "./verification-manager.js";
class MockVerifier extends EventEmitter implements MatrixVerifierLike {
constructor(
private readonly sasCallbacks: MatrixShowSasCallbacks | null,
private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null,
private readonly verifyImpl: () => Promise<void> = async () => {},
) {
super();
}
verify(): Promise<void> {
return this.verifyImpl();
}
cancel(_e: Error): void {
void _e;
}
getShowSasCallbacks(): MatrixShowSasCallbacks | null {
return this.sasCallbacks;
}
getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null {
return this.qrCallbacks;
}
}
class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike {
transactionId?: string;
roomId?: string;
initiatedByMe = false;
otherUserId = "@alice:example.org";
otherDeviceId?: string;
isSelfVerification = false;
phase = VerificationPhase.Requested;
pending = true;
accepting = false;
declining = false;
methods: string[] = ["m.sas.v1"];
chosenMethod?: string | null;
cancellationCode?: string | null;
verifier?: MatrixVerifierLike;
constructor(init?: Partial<MockVerificationRequest>) {
super();
Object.assign(this, init);
}
accept = vi.fn(async () => {
this.phase = VerificationPhase.Ready;
});
cancel = vi.fn(async () => {
this.phase = VerificationPhase.Cancelled;
});
startVerification = vi.fn(async (_method: string) => {
if (!this.verifier) {
throw new Error("verifier not configured");
}
this.phase = VerificationPhase.Started;
return this.verifier;
});
scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => {
if (!this.verifier) {
throw new Error("verifier not configured");
}
this.phase = VerificationPhase.Started;
return this.verifier;
});
generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3]));
}
describe("MatrixVerificationManager", () => {
it("handles rust verification requests whose methods getter throws", () => {
const manager = new MatrixVerificationManager();
const request = new MockVerificationRequest({
transactionId: "txn-rust-methods",
phase: VerificationPhase.Requested,
});
Object.defineProperty(request, "methods", {
get() {
throw new Error("not implemented");
},
});
const summary = manager.trackVerificationRequest(request);
expect(summary.id).toBeTruthy();
expect(summary.methods).toEqual([]);
expect(summary.phase).toBe(VerificationPhase.Requested);
});
it("reuses the same tracked id for repeated transaction IDs", () => {
const manager = new MatrixVerificationManager();
const first = new MockVerificationRequest({
transactionId: "txn-1",
phase: VerificationPhase.Requested,
});
const second = new MockVerificationRequest({
transactionId: "txn-1",
phase: VerificationPhase.Ready,
pending: false,
chosenMethod: "m.sas.v1",
});
const firstSummary = manager.trackVerificationRequest(first);
const secondSummary = manager.trackVerificationRequest(second);
expect(secondSummary.id).toBe(firstSummary.id);
expect(secondSummary.phase).toBe(VerificationPhase.Ready);
expect(secondSummary.pending).toBe(false);
expect(secondSummary.chosenMethod).toBe("m.sas.v1");
});
it("starts SAS verification and exposes SAS payload/callback flow", async () => {
const confirm = vi.fn(async () => {});
const mismatch = vi.fn();
const verifier = new MockVerifier(
{
sas: {
decimal: [111, 222, 333],
emoji: [
["cat", "cat"],
["dog", "dog"],
["fox", "fox"],
],
},
confirm,
mismatch,
cancel: vi.fn(),
},
null,
async () => {},
);
const request = new MockVerificationRequest({
transactionId: "txn-2",
verifier,
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
const started = await manager.startVerification(tracked.id, "sas");
expect(started.hasSas).toBe(true);
expect(started.sas?.decimal).toEqual([111, 222, 333]);
expect(started.sas?.emoji?.length).toBe(3);
const sas = manager.getVerificationSas(tracked.id);
expect(sas.decimal).toEqual([111, 222, 333]);
expect(sas.emoji?.length).toBe(3);
await manager.confirmVerificationSas(tracked.id);
expect(confirm).toHaveBeenCalledTimes(2);
manager.mismatchVerificationSas(tracked.id);
expect(mismatch).toHaveBeenCalledTimes(1);
});
it("auto-starts an incoming verifier exposed via request change events", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const request = new MockVerificationRequest({
transactionId: "txn-incoming-change",
verifier: undefined,
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
request.verifier = verifier;
request.emit(VerificationRequestEvent.Change);
await vi.waitFor(() => {
expect(verify).toHaveBeenCalledTimes(1);
});
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
expect(summary?.hasSas).toBe(true);
expect(summary?.sas?.decimal).toEqual([6158, 1986, 3513]);
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]);
});
it("auto-starts inbound SAS when request becomes ready without a verifier", async () => {
const verify = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [1234, 5678, 9012],
emoji: [
["gift", "Gift"],
["rocket", "Rocket"],
["butterfly", "Butterfly"],
],
},
confirm: vi.fn(async () => {}),
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
verify,
);
const request = new MockVerificationRequest({
transactionId: "txn-auto-start-sas",
initiatedByMe: false,
verifier: undefined,
});
request.startVerification = vi.fn(async (_method: string) => {
request.phase = VerificationPhase.Started;
request.verifier = verifier;
return verifier;
});
const manager = new MatrixVerificationManager();
const tracked = manager.trackVerificationRequest(request);
request.phase = VerificationPhase.Ready;
request.emit(VerificationRequestEvent.Change);
await vi.waitFor(() => {
expect(request.startVerification).toHaveBeenCalledWith("m.sas.v1");
});
await vi.waitFor(() => {
expect(verify).toHaveBeenCalledTimes(1);
});
const summary = manager.listVerifications().find((item) => item.id === tracked.id);
expect(summary?.hasSas).toBe(true);
expect(summary?.sas?.decimal).toEqual([1234, 5678, 9012]);
expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]);
});
it("auto-confirms inbound SAS when callbacks are available", async () => {
const confirm = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["gift", "Gift"],
["globe", "Globe"],
["horse", "Horse"],
],
},
confirm,
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
async () => {},
);
const request = new MockVerificationRequest({
transactionId: "txn-auto-confirm",
initiatedByMe: false,
verifier,
});
const manager = new MatrixVerificationManager();
manager.trackVerificationRequest(request);
await vi.waitFor(() => {
expect(confirm).toHaveBeenCalledTimes(1);
});
});
it("does not auto-confirm SAS for verifications initiated by this device", async () => {
vi.useFakeTimers();
const confirm = vi.fn(async () => {});
const verifier = new MockVerifier(
{
sas: {
decimal: [111, 222, 333],
emoji: [
["cat", "Cat"],
["dog", "Dog"],
["fox", "Fox"],
],
},
confirm,
mismatch: vi.fn(),
cancel: vi.fn(),
},
null,
async () => {},
);
const request = new MockVerificationRequest({
transactionId: "txn-no-auto-confirm",
initiatedByMe: true,
verifier,
});
try {
const manager = new MatrixVerificationManager();
manager.trackVerificationRequest(request);
await vi.advanceTimersByTimeAsync(20);
expect(confirm).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("prunes stale terminal sessions during list operations", () => {
const now = new Date("2026-02-08T15:00:00.000Z").getTime();
const nowSpy = vi.spyOn(Date, "now");
nowSpy.mockReturnValue(now);
const manager = new MatrixVerificationManager();
manager.trackVerificationRequest(
new MockVerificationRequest({
transactionId: "txn-old-done",
phase: VerificationPhase.Done,
pending: false,
}),
);
nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1);
const summaries = manager.listVerifications();
expect(summaries).toHaveLength(0);
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,586 @@
import {
VerificationPhase,
VerificationRequestEvent,
VerifierEvent,
} from "matrix-js-sdk/lib/crypto-api/verification.js";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
export type MatrixVerificationSummary = {
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
otherDeviceId?: string;
isSelfVerification: boolean;
initiatedByMe: boolean;
phase: number;
phaseName: string;
pending: boolean;
methods: string[];
chosenMethod?: string | null;
canAccept: boolean;
hasSas: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
hasReciprocateQr: boolean;
completed: boolean;
error?: string;
createdAt: string;
updatedAt: string;
};
export type MatrixShowSasCallbacks = {
sas: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
confirm: () => Promise<void>;
mismatch: () => void;
cancel: () => void;
};
export type MatrixShowQrCodeCallbacks = {
confirm: () => void;
cancel: () => void;
};
export type MatrixVerifierLike = {
verify: () => Promise<void>;
cancel: (e: Error) => void;
getShowSasCallbacks: () => MatrixShowSasCallbacks | null;
getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null;
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
};
export type MatrixVerificationRequestLike = {
transactionId?: string;
roomId?: string;
initiatedByMe: boolean;
otherUserId: string;
otherDeviceId?: string;
isSelfVerification: boolean;
phase: number;
pending: boolean;
accepting: boolean;
declining: boolean;
methods: string[];
chosenMethod?: string | null;
cancellationCode?: string | null;
accept: () => Promise<void>;
cancel: (params?: { reason?: string; code?: string }) => Promise<void>;
startVerification: (method: string) => Promise<MatrixVerifierLike>;
scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise<MatrixVerifierLike>;
generateQRCode: () => Promise<Uint8ClampedArray | undefined>;
verifier?: MatrixVerifierLike;
on: (eventName: string, listener: (...args: unknown[]) => void) => void;
};
export type MatrixVerificationCryptoApi = {
requestOwnUserVerification: () => Promise<unknown | null>;
requestDeviceVerification?: (
userId: string,
deviceId: string,
) => Promise<MatrixVerificationRequestLike>;
requestVerificationDM?: (
userId: string,
roomId: string,
) => Promise<MatrixVerificationRequestLike>;
};
type MatrixVerificationSession = {
id: string;
request: MatrixVerificationRequestLike;
createdAtMs: number;
updatedAtMs: number;
error?: string;
activeVerifier?: MatrixVerifierLike;
verifyPromise?: Promise<void>;
verifyStarted: boolean;
startRequested: boolean;
sasAutoConfirmStarted: boolean;
sasCallbacks?: MatrixShowSasCallbacks;
reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks;
};
const MAX_TRACKED_VERIFICATION_SESSIONS = 256;
const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000;
export class MatrixVerificationManager {
private readonly verificationSessions = new Map<string, MatrixVerificationSession>();
private verificationSessionCounter = 0;
private readonly trackedVerificationRequests = new WeakSet<object>();
private readonly trackedVerificationVerifiers = new WeakSet<object>();
private readRequestValue<T>(
request: MatrixVerificationRequestLike,
reader: () => T,
fallback: T,
): T {
try {
return reader();
} catch {
return fallback;
}
}
private pruneVerificationSessions(nowMs: number): void {
for (const [id, session] of this.verificationSessions) {
const phase = this.readRequestValue(session.request, () => session.request.phase, -1);
const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled;
if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) {
this.verificationSessions.delete(id);
}
}
if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) {
return;
}
const sortedByAge = Array.from(this.verificationSessions.entries()).sort(
(a, b) => a[1].updatedAtMs - b[1].updatedAtMs,
);
const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS;
for (let i = 0; i < overflow; i += 1) {
const entry = sortedByAge[i];
if (entry) {
this.verificationSessions.delete(entry[0]);
}
}
}
private getVerificationPhaseName(phase: number): string {
switch (phase) {
case VerificationPhase.Unsent:
return "unsent";
case VerificationPhase.Requested:
return "requested";
case VerificationPhase.Ready:
return "ready";
case VerificationPhase.Started:
return "started";
case VerificationPhase.Cancelled:
return "cancelled";
case VerificationPhase.Done:
return "done";
default:
return `unknown(${phase})`;
}
}
private touchVerificationSession(session: MatrixVerificationSession): void {
session.updatedAtMs = Date.now();
}
private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary {
const request = session.request;
const phase = this.readRequestValue(request, () => request.phase, VerificationPhase.Requested);
const accepting = this.readRequestValue(request, () => request.accepting, false);
const declining = this.readRequestValue(request, () => request.declining, false);
const pending = this.readRequestValue(request, () => request.pending, false);
const methodsRaw = this.readRequestValue<unknown>(request, () => request.methods, []);
const methods = Array.isArray(methodsRaw)
? methodsRaw.filter((entry): entry is string => typeof entry === "string")
: [];
const sasCallbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
if (sasCallbacks) {
session.sasCallbacks = sasCallbacks;
}
const canAccept = phase < VerificationPhase.Ready && !accepting && !declining;
return {
id: session.id,
transactionId: this.readRequestValue(request, () => request.transactionId, undefined),
roomId: this.readRequestValue(request, () => request.roomId, undefined),
otherUserId: this.readRequestValue(request, () => request.otherUserId, "unknown"),
otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId, undefined),
isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false),
initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false),
phase,
phaseName: this.getVerificationPhaseName(phase),
pending,
methods,
chosenMethod: this.readRequestValue(request, () => request.chosenMethod ?? null, null),
canAccept,
hasSas: Boolean(sasCallbacks),
sas: sasCallbacks
? {
decimal: sasCallbacks.sas.decimal,
emoji: sasCallbacks.sas.emoji,
}
: undefined,
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
completed: phase === VerificationPhase.Done,
error: session.error,
createdAt: new Date(session.createdAtMs).toISOString(),
updatedAt: new Date(session.updatedAtMs).toISOString(),
};
}
private findVerificationSession(id: string): MatrixVerificationSession {
const direct = this.verificationSessions.get(id);
if (direct) {
return direct;
}
for (const session of this.verificationSessions.values()) {
const txId = this.readRequestValue(session.request, () => session.request.transactionId, "");
if (txId === id) {
return session;
}
}
throw new Error(`Matrix verification request not found: ${id}`);
}
private ensureVerificationRequestTracked(session: MatrixVerificationSession): void {
const requestObj = session.request as unknown as object;
if (this.trackedVerificationRequests.has(requestObj)) {
return;
}
this.trackedVerificationRequests.add(requestObj);
session.request.on(VerificationRequestEvent.Change, () => {
this.touchVerificationSession(session);
const verifier = this.readRequestValue(session.request, () => session.request.verifier, null);
if (verifier) {
this.attachVerifierToVerificationSession(session, verifier);
}
this.maybeAutoStartInboundSas(session);
});
}
private maybeAutoStartInboundSas(session: MatrixVerificationSession): void {
if (session.activeVerifier || session.verifyStarted || session.startRequested) {
return;
}
if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) {
return;
}
const phase = this.readRequestValue(
session.request,
() => session.request.phase,
VerificationPhase.Requested,
);
if (phase < VerificationPhase.Ready || phase >= VerificationPhase.Cancelled) {
return;
}
const methodsRaw = this.readRequestValue<unknown>(
session.request,
() => session.request.methods,
[],
);
const methods = Array.isArray(methodsRaw)
? methodsRaw.filter((entry): entry is string => typeof entry === "string")
: [];
const chosenMethod = this.readRequestValue(
session.request,
() => session.request.chosenMethod,
null,
);
const supportsSas =
methods.includes(VerificationMethod.Sas) || chosenMethod === VerificationMethod.Sas;
if (!supportsSas) {
return;
}
session.startRequested = true;
void session.request
.startVerification(VerificationMethod.Sas)
.then((verifier) => {
this.attachVerifierToVerificationSession(session, verifier);
this.touchVerificationSession(session);
})
.catch(() => {
session.startRequested = false;
});
}
private attachVerifierToVerificationSession(
session: MatrixVerificationSession,
verifier: MatrixVerifierLike,
): void {
session.activeVerifier = verifier;
this.touchVerificationSession(session);
const maybeSas = verifier.getShowSasCallbacks();
if (maybeSas) {
session.sasCallbacks = maybeSas;
this.maybeAutoConfirmSas(session);
}
const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks();
if (maybeReciprocateQr) {
session.reciprocateQrCallbacks = maybeReciprocateQr;
}
const verifierObj = verifier as unknown as object;
if (this.trackedVerificationVerifiers.has(verifierObj)) {
this.ensureVerificationStarted(session);
return;
}
this.trackedVerificationVerifiers.add(verifierObj);
verifier.on(VerifierEvent.ShowSas, (sas) => {
session.sasCallbacks = sas as MatrixShowSasCallbacks;
this.touchVerificationSession(session);
this.maybeAutoConfirmSas(session);
});
verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => {
session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks;
this.touchVerificationSession(session);
});
verifier.on(VerifierEvent.Cancel, (err) => {
session.error = err instanceof Error ? err.message : String(err);
this.touchVerificationSession(session);
});
this.ensureVerificationStarted(session);
}
private maybeAutoConfirmSas(session: MatrixVerificationSession): void {
if (session.sasAutoConfirmStarted) {
return;
}
if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) {
return;
}
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
if (!callbacks) {
return;
}
session.sasCallbacks = callbacks;
session.sasAutoConfirmStarted = true;
void callbacks
.confirm()
.then(() => {
this.touchVerificationSession(session);
})
.catch((err) => {
session.error = err instanceof Error ? err.message : String(err);
this.touchVerificationSession(session);
});
}
private ensureVerificationStarted(session: MatrixVerificationSession): void {
if (!session.activeVerifier || session.verifyStarted) {
return;
}
session.verifyStarted = true;
const verifier = session.activeVerifier;
session.verifyPromise = verifier
.verify()
.then(() => {
this.touchVerificationSession(session);
})
.catch((err) => {
session.error = err instanceof Error ? err.message : String(err);
this.touchVerificationSession(session);
});
}
trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary {
this.pruneVerificationSessions(Date.now());
const txId = this.readRequestValue(request, () => request.transactionId?.trim(), "");
if (txId) {
for (const existing of this.verificationSessions.values()) {
const existingTxId = this.readRequestValue(
existing.request,
() => existing.request.transactionId,
"",
);
if (existingTxId === txId) {
existing.request = request;
this.ensureVerificationRequestTracked(existing);
const verifier = this.readRequestValue(request, () => request.verifier, null);
if (verifier) {
this.attachVerifierToVerificationSession(existing, verifier);
}
this.touchVerificationSession(existing);
return this.buildVerificationSummary(existing);
}
}
}
const now = Date.now();
const id = `verification-${++this.verificationSessionCounter}`;
const session: MatrixVerificationSession = {
id,
request,
createdAtMs: now,
updatedAtMs: now,
verifyStarted: false,
startRequested: false,
sasAutoConfirmStarted: false,
};
this.verificationSessions.set(session.id, session);
this.ensureVerificationRequestTracked(session);
const verifier = this.readRequestValue(request, () => request.verifier, null);
if (verifier) {
this.attachVerifierToVerificationSession(session, verifier);
}
this.maybeAutoStartInboundSas(session);
return this.buildVerificationSummary(session);
}
async requestOwnUserVerification(
crypto: MatrixVerificationCryptoApi | undefined,
): Promise<MatrixVerificationSummary | null> {
if (!crypto) {
return null;
}
const request =
(await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
if (!request) {
return null;
}
return this.trackVerificationRequest(request);
}
listVerifications(): MatrixVerificationSummary[] {
this.pruneVerificationSessions(Date.now());
const summaries = Array.from(this.verificationSessions.values()).map((session) =>
this.buildVerificationSummary(session),
);
return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
async requestVerification(
crypto: MatrixVerificationCryptoApi | undefined,
params: {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
},
): Promise<MatrixVerificationSummary> {
if (!crypto) {
throw new Error("Matrix crypto is not available");
}
let request: MatrixVerificationRequestLike | null = null;
if (params.ownUser) {
request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null;
} else if (params.userId && params.deviceId && crypto.requestDeviceVerification) {
request = await crypto.requestDeviceVerification(params.userId, params.deviceId);
} else if (params.userId && params.roomId && crypto.requestVerificationDM) {
request = await crypto.requestVerificationDM(params.userId, params.roomId);
} else {
throw new Error(
"Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId",
);
}
if (!request) {
throw new Error("Matrix verification request could not be created");
}
return this.trackVerificationRequest(request);
}
async acceptVerification(id: string): Promise<MatrixVerificationSummary> {
const session = this.findVerificationSession(id);
await session.request.accept();
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}
async cancelVerification(
id: string,
params?: { reason?: string; code?: string },
): Promise<MatrixVerificationSummary> {
const session = this.findVerificationSession(id);
await session.request.cancel(params);
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}
async startVerification(
id: string,
method: MatrixVerificationMethod = "sas",
): Promise<MatrixVerificationSummary> {
const session = this.findVerificationSession(id);
if (method !== "sas") {
throw new Error("Matrix startVerification currently supports only SAS directly");
}
const verifier = await session.request.startVerification(VerificationMethod.Sas);
this.attachVerifierToVerificationSession(session, verifier);
this.ensureVerificationStarted(session);
return this.buildVerificationSummary(session);
}
async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> {
const session = this.findVerificationSession(id);
const qr = await session.request.generateQRCode();
if (!qr) {
throw new Error("Matrix verification QR data is not available yet");
}
return { qrDataBase64: Buffer.from(qr).toString("base64") };
}
async scanVerificationQr(id: string, qrDataBase64: string): Promise<MatrixVerificationSummary> {
const session = this.findVerificationSession(id);
const trimmed = qrDataBase64.trim();
if (!trimmed) {
throw new Error("Matrix verification QR payload is required");
}
const qrBytes = Buffer.from(trimmed, "base64");
if (qrBytes.length === 0) {
throw new Error("Matrix verification QR payload is invalid base64");
}
const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes));
this.attachVerifierToVerificationSession(session, verifier);
this.ensureVerificationStarted(session);
return this.buildVerificationSummary(session);
}
async confirmVerificationSas(id: string): Promise<MatrixVerificationSummary> {
const session = this.findVerificationSession(id);
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
if (!callbacks) {
throw new Error("Matrix SAS confirmation is not available for this verification request");
}
session.sasCallbacks = callbacks;
await callbacks.confirm();
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}
mismatchVerificationSas(id: string): MatrixVerificationSummary {
const session = this.findVerificationSession(id);
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
if (!callbacks) {
throw new Error("Matrix SAS mismatch is not available for this verification request");
}
session.sasCallbacks = callbacks;
callbacks.mismatch();
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}
confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary {
const session = this.findVerificationSession(id);
const callbacks =
session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks();
if (!callbacks) {
throw new Error(
"Matrix reciprocate-QR confirmation is not available for this verification request",
);
}
session.reciprocateQrCallbacks = callbacks;
callbacks.confirm();
this.touchVerificationSession(session);
return this.buildVerificationSummary(session);
}
getVerificationSas(id: string): {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
} {
const session = this.findVerificationSession(id);
const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks();
if (!callbacks) {
throw new Error("Matrix SAS data is not available for this verification request");
}
session.sasCallbacks = callbacks;
return {
decimal: callbacks.sas.decimal,
emoji: callbacks.sas.emoji,
};
}
}

View File

@@ -0,0 +1,228 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
const resizeToJpegMock = vi.fn();
const runtimeStub = {
config: {
loadConfig: () => ({}),
},
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue("evt1");
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const client = {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("./sdk.js").MatrixClient;
return { client, sendMessage, uploadContent };
};
describe("sendMessageMatrix media", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
});
beforeEach(() => {
loadWebMediaMock.mockReset().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
getImageMetadataMock.mockReset().mockResolvedValue(null);
resizeToJpegMock.mockReset();
setMatrixRuntime(runtimeStub);
});
it("uploads media with url payloads", async () => {
const { client, sendMessage, uploadContent } = makeClient();
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
msgtype?: string;
format?: string;
formatted_body?: string;
};
expect(content.msgtype).toBe("m.image");
expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file");
});
it("uploads encrypted media with file payloads", async () => {
const { client, sendMessage, 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",
},
}),
};
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
expect(uploadArg?.toString()).toBe("encrypted");
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
file?: { url?: string };
};
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", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("includes thread relation metadata when threadId is set", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello thread", {
client,
threadId: "$thread",
});
const content = sendMessage.mock.calls[0]?.[1] as {
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
expect(content["m.relates_to"]).toMatchObject({
rel_type: "m.thread",
event_id: "$thread",
"m.in_reply_to": { event_id: "$thread" },
});
});
});

View File

@@ -0,0 +1,264 @@
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import type { MatrixClient } from "./sdk.js";
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
import {
buildReplyRelation,
buildTextContent,
buildThreadRelation,
resolveMatrixMsgType,
resolveMatrixVoiceDecision,
} from "./send/formatting.js";
import {
buildMediaContent,
prepareImageInfo,
resolveMediaDurationMs,
uploadMediaMaybeEncrypted,
} from "./send/media.js";
import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
import {
EventType,
MsgType,
RelationType,
type MatrixOutboundContent,
type MatrixSendOpts,
type MatrixSendResult,
type ReactionEventContent,
} from "./send/types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
export { resolveMatrixRoomId } from "./send/targets.js";
export async function sendMessageMatrix(
to: string,
message: string,
opts: MatrixSendOpts = {},
): Promise<MatrixSendResult> {
const trimmedMessage = message?.trim() ?? "";
if (!trimmedMessage && !opts.mediaUrl) {
throw new Error("Matrix send requires text or media");
}
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix-js", opts.accountId);
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
convertedMessage,
chunkLimit,
chunkMode,
);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(opts.accountId);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,
filename: media.fileName,
});
const durationMs = await resolveMediaDurationMs({
buffer: media.buffer,
contentType: media.contentType,
fileName: media.fileName,
kind: media.kind,
});
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage
? await prepareImageInfo({
buffer: media.buffer,
client,
encrypted: Boolean(uploaded.file),
})
: undefined;
const [firstChunk, ...rest] = chunks;
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({
msgtype,
body,
url: uploaded.url,
file: uploaded.file,
filename: media.fileName,
mimetype: media.contentType,
size: media.buffer.byteLength,
durationMs,
relation,
isVoice: useVoice,
imageInfo,
});
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
const followupRelation = threadId ? relation : undefined;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) {
continue;
}
const followup = buildTextContent(text, followupRelation);
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim();
if (!text) {
continue;
}
const content = buildTextContent(text, relation);
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
}
}
return {
messageId: lastMessageId || "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function sendPollMatrix(
to: string,
poll: PollInput,
opts: MatrixSendOpts = {},
): Promise<{ eventId: string; roomId: string }> {
if (!poll.question?.trim()) {
throw new Error("Matrix poll requires a question");
}
if (!poll.options?.length) {
throw new Error("Matrix poll requires options");
}
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId);
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
eventId: eventId ?? "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function sendTypingMatrix(
roomId: string,
typing: boolean,
timeoutMs?: number,
client?: MatrixClient,
): Promise<void> {
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
timeoutMs,
});
try {
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}
export async function sendReadReceiptMatrix(
roomId: string,
eventId: string,
client?: MatrixClient,
): Promise<void> {
if (!eventId?.trim()) {
return;
}
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
});
try {
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}
export async function reactMatrixMessage(
roomId: string,
messageId: string,
emoji: string,
client?: MatrixClient,
): Promise<void> {
if (!emoji.trim()) {
throw new Error("Matrix reaction requires an emoji");
}
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
});
try {
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
const reaction: ReactionEventContent = {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: messageId,
key: emoji,
},
};
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const getActiveMatrixClientMock = vi.fn();
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
const resolveMatrixAuthMock = vi.fn();
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
vi.mock("../client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
}));
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),
} as unknown as MatrixClient;
}
describe("resolveMatrixClient", () => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
getActiveMatrixClientMock.mockReturnValue(null);
isBunRuntimeMock.mockReturnValue(false);
resolveMatrixAuthMock.mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
password: undefined,
deviceId: "DEVICE123",
encryption: false,
});
createMatrixClientMock.mockResolvedValue(createMockMatrixClient());
({ resolveMatrixClient } = await import("./client.js"));
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await resolveMatrixClient({ accountId: "default" });
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
expect(createMatrixClientMock).toHaveBeenCalledWith(
expect.objectContaining({
autoBootstrapCrypto: false,
}),
);
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
expect(result.stopOnDone).toBe(true);
});
it("reuses active monitor client when available", async () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await resolveMatrixClient({ accountId: "default" });
expect(result).toEqual({ client: activeClient, stopOnDone: false });
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
expect(createMatrixClientMock).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More