Matrix: rename extension folder to matrix-js

This commit is contained in:
Gustavo Madeira Santana
2026-02-22 20:38:55 -05:00
parent 1bd75c1744
commit eb10490c4c
103 changed files with 13918 additions and 0 deletions

BIN
extensions/matrix-js/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,20 @@
# Changelog
## 2026.2.22
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.14
### Features
- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name).
- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support.
- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts.
- Threads: replyToMode controls and thread replies (off/inbound/always).
- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes.
- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info.
- Auto-join invites with allowlist support.
- Status + probe reporting for health checks.

View File

@@ -0,0 +1,17 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { matrixPlugin } from "./src/channel.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "matrix",
"channels": ["matrix"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,37 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.6-3",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "matrix",
"label": "Matrix",
"selectionLabel": "Matrix (plugin)",
"docsPath": "/channels/matrix",
"docsLabel": "matrix",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/matrix",
"localPath": "extensions/matrix",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,237 @@
import {
createActionGate,
readNumberParam,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelToolSend,
} from "openclaw/plugin-sdk";
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?.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-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.`);
},
};

View File

@@ -0,0 +1,154 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
MatrixClient: class {},
LogService: {
setLogger: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
SimpleFsStorageProvider: class {},
RustSdkCryptoStorageProvider: class {},
}));
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: {
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: {
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: {
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);
});
});

View File

@@ -0,0 +1,489 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import { matrixMessageActions } from "./actions.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 { resolveMatrixAuth } from "./matrix/client.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.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",
label: "Matrix",
selectionLabel: "Matrix (plugin)",
docsPath: "/channels/matrix",
docsLabel: "matrix",
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 buildMatrixConfigUpdate(
cfg: CoreConfig,
input: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
register?: boolean;
deviceName?: string;
initialSyncLimit?: number;
},
): CoreConfig {
const existing = cfg.channels?.matrix ?? {};
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...existing,
enabled: true,
...(input.homeserver ? { homeserver: input.homeserver } : {}),
...(input.userId ? { userId: input.userId } : {}),
...(input.accessToken ? { accessToken: input.accessToken } : {}),
...(input.password ? { password: input.password } : {}),
...(typeof input.register === "boolean" ? { register: input.register } : {}),
...(input.deviceName ? { deviceName: input.deviceName } : {}),
...(typeof input.initialSyncLimit === "number"
? { initialSyncLimit: input.initialSyncLimit }
: {}),
},
},
};
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
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"] },
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",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix",
accountId,
clearBaseFields: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"register",
"deviceName",
"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.accounts.${accountId}.dm`
: "channels.matrix.dm";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${prefix}.policy`,
allowFromPath: `${prefix}.allowFrom`,
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== 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.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.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 }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix",
accountId,
name,
}),
validateInput: ({ input }) => {
if (input.useEnv) {
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, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix",
accountId: DEFAULT_ACCOUNT_ID,
name: input.name,
});
if (input.useEnv) {
return {
...namedConfig,
channels: {
...namedConfig.channels,
matrix: {
...namedConfig.channels?.matrix,
enabled: true,
},
},
} as CoreConfig;
}
return buildMatrixConfigUpdate(namedConfig as CoreConfig, {
homeserver: input.homeserver?.trim(),
userId: input.userId?.trim(),
accessToken: input.accessToken?.trim(),
password: input.password?.trim(),
deviceName: input.deviceName?.trim(),
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",
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,66 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
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(),
register: z.boolean().optional(),
deviceId: z.string().optional(),
deviceName: 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: {} } };
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";
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";
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,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: {
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: {
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: {
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,137 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.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 = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
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 {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
// Direct lookup first (fast path for already-normalized keys)
if (accounts[accountId]) {
return accounts[accountId] as MatrixConfig;
}
// Fall back to case-insensitive match (user may have mixed-case keys in config)
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
return accounts[key] as MatrixConfig;
}
}
return undefined;
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = params.cfg.channels?.matrix ?? {};
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 = params.cfg.channels?.matrix ?? {};
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,29 @@
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 {
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
scanMatrixVerificationQr,
startMatrixVerification,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -0,0 +1,59 @@
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import type { CoreConfig } from "../types.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();
if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
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,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}

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,126 @@
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { resolveActionClient } 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");
}
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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 };
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}

View File

@@ -0,0 +1,74 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { describe, expect, it, vi } from "vitest";
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,84 @@
import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } 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> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
} finally {
if (stopOnDone) {
client.stop();
}
}
}
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,109 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { describe, expect, it, vi } from "vitest";
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,96 @@
import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } 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[]> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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());
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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 };
} finally {
if (stopOnDone) {
client.stop();
}
}
}

View File

@@ -0,0 +1,82 @@
import { resolveMatrixRoomId } from "../send.js";
import { resolveActionClient } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
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,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}

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,74 @@
import type { MatrixClient, MatrixRawEvent, MessageEventContent } 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,220 @@
import { resolveActionClient } 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.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 = {}) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.listVerifications();
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function requestMatrixVerification(
params: MatrixActionClientOpts & {
ownUser?: boolean;
userId?: string;
deviceId?: string;
roomId?: string;
} = {},
) {
const { client, stopOnDone } = await resolveActionClient(params);
try {
const crypto = requireCrypto(client);
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
return await crypto.requestVerification({
ownUser,
userId: params.userId?.trim() || undefined,
deviceId: params.deviceId?.trim() || undefined,
roomId: params.roomId?.trim() || undefined,
});
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function acceptMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.acceptVerification(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function cancelMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.cancelVerification(resolveVerificationId(requestId), {
reason: opts.reason?.trim() || undefined,
code: opts.code?.trim() || undefined,
});
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function startMatrixVerification(
requestId: string,
opts: MatrixActionClientOpts & { method?: "sas" } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function generateMatrixVerificationQr(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function scanMatrixVerificationQr(
requestId: string,
qrDataBase64: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
const payload = qrDataBase64.trim();
if (!payload) {
throw new Error("Matrix QR data is required");
}
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function getMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.getVerificationSas(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function confirmMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function mismatchMatrixVerificationSas(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function confirmMatrixVerificationReciprocateQr(
requestId: string,
opts: MatrixActionClientOpts = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function getMatrixEncryptionStatus(
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
) {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const crypto = requireCrypto(client);
const recoveryKey = await crypto.getRecoveryKey();
return {
encryptionEnabled: true,
recoveryKeyStored: Boolean(recoveryKey),
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
pendingVerifications: (await crypto.listVerifications()).length,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}

View File

@@ -0,0 +1,11 @@
import type { MatrixClient } from "./sdk.js";
let activeClient: MatrixClient | null = null;
export function setActiveMatrixClient(client: MatrixClient | null): void {
activeClient = client;
}
export function getActiveMatrixClient(): MatrixClient | null {
return activeClient;
}

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,399 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.fn();
const prepareMatrixRegisterModeMock = vi.fn(async () => null);
const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false);
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args),
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
vi.mock("./client/register-mode.js", () => ({
prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args),
finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) =>
finalizeMatrixRegisterConfigAfterSuccessMock(...args),
resetPreparedMatrixRegisterModesForTests: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
matrix: {
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",
register: false,
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.register).toBe(false);
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("reads register flag from config and env", () => {
const cfg = {
channels: {
matrix: {
register: true,
},
},
} as CoreConfig;
const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv);
expect(resolvedFromCfg.register).toBe(true);
const resolvedFromEnv = resolveMatrixConfig(
{} as CoreConfig,
{
MATRIX_REGISTER: "1",
} as NodeJS.ProcessEnv,
);
expect(resolvedFromEnv.register).toBe(true);
});
});
describe("resolveMatrixAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
prepareMatrixRegisterModeMock.mockReset();
finalizeMatrixRegisterConfigAfterSuccessMock.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: {
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",
}),
);
});
it("can register account when password login fails and register mode is enabled", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy
.mockRejectedValueOnce(new Error("Invalid username or password"))
.mockResolvedValueOnce({
access_token: "tok-registered",
user_id: "@newbot:example.org",
device_id: "REGDEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
password: "secret",
register: true,
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenNthCalledWith(
1,
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
device_id: undefined,
}),
);
expect(doRequestSpy).toHaveBeenNthCalledWith(
2,
"POST",
"/_matrix/client/v3/register",
undefined,
expect.objectContaining({
username: "newbot",
auth: { type: "m.login.dummy" },
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
accessToken: "tok-registered",
deviceId: "REGDEVICE123",
encryption: true,
});
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({
cfg,
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
env: {} as NodeJS.ProcessEnv,
});
expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
deviceId: "REGDEVICE123",
});
});
it("ignores cached credentials when matrix.register=true", 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 doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
register: 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.accessToken).toBe("tok-123");
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1);
});
it("requires matrix.password when matrix.register=true", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix password is required when matrix.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
});
it("requires matrix.userId when matrix.register=true", async () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
password: "secret",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix userId is required when matrix.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).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: {
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",
}),
);
});
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: {
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: {
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,14 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export {
resolveMatrixConfig,
resolveMatrixConfigForAccount,
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,327 @@
import { getMatrixRuntime } from "../../runtime.js";
import { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
} from "./register-mode.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
function resolveMatrixLocalpart(userId: string): string {
const trimmed = userId.trim();
const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
const localpart = noPrefix.split(":")[0]?.trim() || "";
if (!localpart) {
throw new Error(`Invalid Matrix userId for registration: ${userId}`);
}
return localpart;
}
async function registerMatrixPasswordAccount(params: {
homeserver: string;
userId: string;
password: string;
deviceId?: string;
deviceName?: string;
}): Promise<{
access_token?: string;
user_id?: string;
device_id?: string;
}> {
const registerClient = new MatrixClient(params.homeserver, "");
const payload = {
username: resolveMatrixLocalpart(params.userId),
password: params.password,
inhibit_login: false,
device_id: params.deviceId,
initial_device_display_name: params.deviceName ?? "OpenClaw Gateway",
};
let firstError: unknown = null;
try {
return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, {
...payload,
auth: { type: "m.login.dummy" },
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
firstError = err;
}
try {
return (await registerClient.doRequest(
"POST",
"/_matrix/client/v3/register",
undefined,
payload,
)) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
const secondMessage = err instanceof Error ? err.message : String(err);
throw new Error(
`Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`,
);
}
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const register =
parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false;
const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || 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,
register,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
const registerFromConfig = cfg.channels?.matrix?.register === true;
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
if (registerFromConfig) {
if (!resolved.userId) {
throw new Error("Matrix userId is required when matrix.register=true");
}
if (!resolved.password) {
throw new Error("Matrix password is required when matrix.register=true");
}
await prepareMatrixRegisterMode({
cfg,
homeserver: resolved.homeserver,
userId: resolved.userId,
env,
});
}
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken && !registerFromConfig) {
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) {
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
});
} else if (hasMatchingCachedToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
password: resolved.password,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials && !registerFromConfig) {
touchMatrixCredentials(env);
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.userId)");
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
);
}
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
let login: {
access_token?: string;
user_id?: string;
device_id?: string;
};
try {
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;
};
} catch (loginErr) {
if (!resolved.register) {
throw loginErr;
}
try {
login = await registerMatrixPasswordAccount({
homeserver: resolved.homeserver,
userId: resolved.userId,
password: resolved.password,
deviceId: resolved.deviceId,
deviceName: resolved.deviceName,
});
} catch (registerErr) {
const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr);
const registerMessage =
registerErr instanceof Error ? registerErr.message : String(registerErr);
throw new Error(
`Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`,
);
}
}
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login/registration 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,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
});
if (registerFromConfig) {
await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: auth.homeserver,
userId: auth.userId,
deviceId: auth.deviceId,
});
}
return auth;
}

View File

@@ -0,0 +1,56 @@
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;
}): 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-${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,
});
}

View File

@@ -0,0 +1,36 @@
import { ConsoleLogger, LogService } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (module !== "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) {
return;
}
matrixSdkLoggingConfigured = true;
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);
},
});
}

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as runtimeModule from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
resetPreparedMatrixRegisterModesForTests,
} from "./register-mode.js";
describe("matrix register mode helpers", () => {
const tempDirs: string[] = [];
afterEach(() => {
resetPreparedMatrixRegisterModesForTests();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
it("moves existing matrix state into a .bak snapshot before fresh registration", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-"));
tempDirs.push(stateDir);
const credentialsDir = path.join(stateDir, "credentials", "matrix");
const accountsDir = path.join(credentialsDir, "accounts");
fs.mkdirSync(accountsDir, { recursive: true });
fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n');
fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n");
const cfg = {
channels: {
matrix: {
userId: "@pinguini:matrix.gumadeiras.com",
register: true,
encryption: true,
},
},
} as CoreConfig;
const backupDir = await prepareMatrixRegisterMode({
cfg,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
});
expect(backupDir).toBeTruthy();
expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false);
expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false);
expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true);
});
it("updates matrix config after successful register mode auth", async () => {
const writeConfigFile = vi.fn(async () => {});
vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({
config: {
loadConfig: () =>
({
channels: {
matrix: {
register: true,
accessToken: "stale-token",
userId: "@pinguini:matrix.gumadeiras.com",
},
},
}) as CoreConfig,
writeConfigFile,
},
} as never);
const updated = await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
});
expect(updated).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: expect.objectContaining({
matrix: expect.objectContaining({
register: false,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
}),
}),
}),
);
const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig;
expect(written.channels?.matrix?.accessToken).toBeUndefined();
});
});

View File

@@ -0,0 +1,125 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixCredentialsDir } from "../credentials.js";
const preparedRegisterKeys = new Set<string>();
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string {
try {
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
} catch {
// fall through to deterministic fallback for tests/early init
}
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
if (override.startsWith("~")) {
const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir());
return path.resolve(expanded);
}
return path.resolve(override);
}
return path.join(os.homedir(), ".openclaw");
}
function buildRegisterKey(params: { homeserver: string; userId: string }): string {
return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`;
}
function buildBackupDirName(now = new Date()): string {
const ts = now.toISOString().replace(/[:.]/g, "-");
const suffix = Math.random().toString(16).slice(2, 8);
return `${ts}-${suffix}`;
}
export async function prepareMatrixRegisterMode(params: {
cfg: CoreConfig;
homeserver: string;
userId: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | null> {
const env = params.env ?? process.env;
const registerKey = buildRegisterKey({
homeserver: params.homeserver,
userId: params.userId,
});
if (preparedRegisterKeys.has(registerKey)) {
return null;
}
const stateDir = resolveStateDirFromEnv(env);
const credentialsDir = resolveMatrixCredentialsDir(env, stateDir);
if (!fs.existsSync(credentialsDir)) {
return null;
}
const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak");
if (entries.length === 0) {
return null;
}
const backupRoot = path.join(credentialsDir, ".bak");
fs.mkdirSync(backupRoot, { recursive: true });
const backupDir = path.join(backupRoot, buildBackupDirName());
fs.mkdirSync(backupDir, { recursive: true });
const matrixConfig = params.cfg.channels?.matrix ?? {};
fs.writeFileSync(
path.join(backupDir, "matrix-config.json"),
JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"),
"utf-8",
);
for (const entry of entries) {
fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry));
}
preparedRegisterKeys.add(registerKey);
return backupDir;
}
export async function finalizeMatrixRegisterConfigAfterSuccess(params: {
homeserver: string;
userId: string;
deviceId?: string;
}): Promise<boolean> {
let runtime: ReturnType<typeof getMatrixRuntime> | null = null;
try {
runtime = getMatrixRuntime();
} catch {
return false;
}
const cfg = runtime.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.register !== true) {
return false;
}
const matrixCfg = cfg.channels?.matrix ?? {};
const nextMatrix: Record<string, unknown> = {
...matrixCfg,
register: false,
homeserver: params.homeserver,
userId: params.userId,
...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}),
};
// Registration mode should continue relying on password + cached credentials, not stale inline token.
delete nextMatrix.accessToken;
const next: CoreConfig = {
...cfg,
channels: {
...(cfg.channels ?? {}),
matrix: nextMatrix as CoreConfig["channels"]["matrix"],
},
};
await runtime.config.writeConfigFile(next as never);
return true;
}
export function resetPreparedMatrixRegisterModesForTests(): void {
preparedRegisterKeys.clear();
}

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,173 @@
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import type { CoreConfig } from "../types.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;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
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,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) {
return;
}
if (sharedClientStartPromise) {
await sharedClientStartPromise;
return;
}
sharedClientStartPromise = (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 sharedClientStartPromise;
} finally {
sharedClientStartPromise = 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 }));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
try {
const created = await sharedClientPromise;
sharedClientState = created;
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromise = null;
}
}
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 {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}

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", "bot-storage.json"),
cryptoPath: path.join(stateDir, "credentials", "matrix", "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",
"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,40 @@
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
register?: boolean;
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,125 @@
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 { 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");
}
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 function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true });
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,
};
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
}
export function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const existing = loadMatrixCredentials(env, accountId);
if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env, accountId);
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
}
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";
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";
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";
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: {
autoJoin: "always",
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").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: {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").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: {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").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";
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?.autoJoin ?? "always";
const autoJoinAllowlist = new Set(
(cfg.channels?.matrix?.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,101 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
export function registerMatrixMonitorEvents(params: {
client: MatrixClient;
auth: MatrixAuth;
logVerboseMessage: (message: string) => void;
warnedEncryptedRooms: Set<string>;
warnedCryptoMissingRooms: Set<string>;
logger: { warn: (meta: Record<string, unknown>, message: string) => void };
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
const {
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint,
onRoomMessage,
} = params;
client.on("room.message", onRoomMessage);
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(
{ roomId, eventId: event.event_id, error: error.message },
"Failed to decrypt 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.encryption=true and verify the device to decrypt";
logger.warn({ roomId }, warning);
}
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({ roomId }, warning);
}
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"}`,
);
}
});
}

View File

@@ -0,0 +1,665 @@
import {
createReplyPrefixOptions,
createTypingCallbacks,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { CoreConfig, 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";
export type MatrixMonitorHandlerParams = {
client: MatrixClient;
core: {
logging: {
shouldLogVerbose: () => boolean;
};
channel: (typeof import("openclaw/plugin-sdk"))["channel"];
system: {
enqueueSystemEvent: (
text: string,
meta: { sessionKey?: string | null; contextKey?: string | null },
) => void;
};
};
cfg: CoreConfig;
runtime: RuntimeEnv;
logger: {
info: (message: string | Record<string, unknown>, ...meta: unknown[]) => void;
warn: (meta: Record<string, unknown>, message: string) => void;
};
logVerboseMessage: (message: string) => void;
allowFrom: string[];
roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig }
? MatrixConfig extends { groups?: infer Groups }
? Groups
: Record<string, unknown> | undefined
: Record<string, unknown> | undefined;
mentionRegexes: ReturnType<
(typeof import("openclaw/plugin-sdk"))["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,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
} = params;
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;
}
}
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 core.channel.pairing
.readAllowFromStore("matrix")
.catch(() => []);
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.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",
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
[
"OpenClaw: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"openclaw pairing approve matrix <code>",
].join("\n"),
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
}
}
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",
});
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",
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({ roomId, reason: "no-mention" }, "skipping room message");
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",
peer: {
kind: isDirectMessage ? "dm" : "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" as const,
Surface: "matrix" 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" 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",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn(
{
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
},
"failed updating session meta",
);
},
});
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",
accountId: route.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "matrix",
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",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
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) => {
await deliverMatrixReplies({
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
onError: (err, info) => {
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,365 @@
import { format } from "node:util";
import {
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
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 { 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?.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: {
...cfg.channels?.matrix,
dm: {
...cfg.channels?.matrix?.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,
});
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.matrix !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix",
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");
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,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
accountId: opts.accountId,
});
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}`);
// If E2EE is enabled, trigger device verification
if (auth.encryption && client.crypto) {
try {
// Request verification from other sessions
const verificationRequest = await (
client.crypto as { requestOwnUserVerification?: () => Promise<unknown> }
).requestOwnUserVerification?.();
if (verificationRequest) {
logger.info("matrix: device verification requested - please verify in another client");
}
} catch (err) {
logger.debug?.("Device verification request failed (may already be verified)", {
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";
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";
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,118 @@
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; headerType?: string } | 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 result = await params.client.downloadContent(params.mxcUrl);
const raw = result.data ?? result;
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, headerType: result.contentType };
} 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 = fetched.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,62 @@
import { getMatrixRuntime } from "../../runtime.js";
// Type for room message content with mentions
type MessageContentWithMentions = {
msgtype: string;
body: string;
formatted_body?: string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
};
/**
* 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: MessageContentWithMentions;
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 { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
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";
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",
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", 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,55 @@
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);
name = nameState?.name;
} catch {
// ignore
}
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
canonicalAlias = aliasState?.alias;
altAliases = aliasState?.alt_aliases ?? [];
} 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);
return memberState?.displayname ?? 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";
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,68 @@
// Type for raw Matrix event payload consumed by thread helpers.
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
};
type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
const RelationType = {
Thread: "m.thread",
} as const;
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,27 @@
import type { EncryptedFile, MatrixRawEvent, MessageEventContent } 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,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";
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";
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,751 @@
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
private readonly sender: string;
private readonly type: string;
private readonly ts: number;
private readonly content: Record<string, unknown>;
private readonly stateKey?: string;
private readonly unsigned?: {
age?: number;
redacted_because?: unknown;
};
private readonly decryptionFailure: boolean;
constructor(params: {
roomId: string;
eventId: string;
sender: string;
type: string;
ts: number;
content: Record<string, unknown>;
stateKey?: string;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
decryptionFailure?: boolean;
}) {
super();
this.roomId = params.roomId;
this.eventId = params.eventId;
this.sender = params.sender;
this.type = params.type;
this.ts = params.ts;
this.content = params.content;
this.stateKey = params.stateKey;
this.unsigned = params.unsigned;
this.decryptionFailure = params.decryptionFailure === true;
}
getRoomId(): string {
return this.roomId;
}
getId(): string {
return this.eventId;
}
getSender(): string {
return this.sender;
}
getType(): string {
return this.type;
}
getTs(): number {
return this.ts;
}
getContent(): Record<string, unknown> {
return this.content;
}
getUnsigned(): { age?: number; redacted_because?: unknown } {
return this.unsigned ?? {};
}
getStateKey(): string | undefined {
return this.stateKey;
}
isDecryptionFailure(): boolean {
return this.decryptionFailure;
}
}
type MatrixJsClientStub = EventEmitter & {
startClient: ReturnType<typeof vi.fn>;
stopClient: ReturnType<typeof vi.fn>;
initRustCrypto: ReturnType<typeof vi.fn>;
getUserId: ReturnType<typeof vi.fn>;
getDeviceId: ReturnType<typeof vi.fn>;
getJoinedRooms: ReturnType<typeof vi.fn>;
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
getStateEvent: ReturnType<typeof vi.fn>;
getAccountData: ReturnType<typeof vi.fn>;
setAccountData: ReturnType<typeof vi.fn>;
getRoomIdForAlias: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
sendStateEvent: ReturnType<typeof vi.fn>;
redactEvent: ReturnType<typeof vi.fn>;
getProfileInfo: ReturnType<typeof vi.fn>;
joinRoom: ReturnType<typeof vi.fn>;
mxcUrlToHttp: ReturnType<typeof vi.fn>;
uploadContent: ReturnType<typeof vi.fn>;
fetchRoomEvent: ReturnType<typeof vi.fn>;
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getRooms: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
decryptEventIfNeeded: ReturnType<typeof vi.fn>;
};
function createMatrixJsClientStub(): MatrixJsClientStub {
const client = new EventEmitter() as MatrixJsClientStub;
client.startClient = vi.fn(async () => {});
client.stopClient = vi.fn();
client.initRustCrypto = vi.fn(async () => {});
client.getUserId = vi.fn(() => "@bot:example.org");
client.getDeviceId = vi.fn(() => "DEVICE123");
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
client.getStateEvent = vi.fn(async () => ({}));
client.getAccountData = vi.fn(() => undefined);
client.setAccountData = vi.fn(async () => {});
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
client.getProfileInfo = vi.fn(async () => ({}));
client.joinRoom = vi.fn(async () => ({}));
client.mxcUrlToHttp = vi.fn(() => null);
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
client.fetchRoomEvent = vi.fn(async () => ({}));
client.sendTyping = vi.fn(async () => {});
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
client.getRooms = vi.fn(() => []);
client.getCrypto = vi.fn(() => undefined);
client.decryptEventIfNeeded = vi.fn(async () => {});
return client;
}
let matrixJsClient = createMatrixJsClientStub();
let lastCreateClientOpts: Record<string, unknown> | null = null;
vi.mock("matrix-js-sdk", () => ({
ClientEvent: { Event: "event", Room: "Room" },
MatrixEventEvent: { Decrypted: "decrypted" },
createClient: vi.fn((opts: Record<string, unknown>) => {
lastCreateClientOpts = opts;
return matrixJsClient;
}),
}));
import { MatrixClient } from "./sdk.js";
describe("MatrixClient request hardening", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("blocks absolute endpoints unless explicitly allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
"Absolute Matrix endpoint is blocked by default",
);
expect(fetchMock).not.toHaveBeenCalled();
});
it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("", {
status: 302,
headers: {
location: "http://evil.example.org/next",
},
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(
client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
allowAbsoluteEndpoint: true,
}),
).rejects.toThrow("Blocked cross-protocol redirect");
});
it("strips authorization when redirect crosses origin", async () => {
const calls: Array<{ url: string; headers: Headers }> = [];
const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({
url: String(url),
headers: new Headers(init?.headers),
});
if (calls.length === 1) {
return new Response("", {
status: 302,
headers: { location: "https://cdn.example.org/next" },
});
}
return new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, {
allowAbsoluteEndpoint: true,
});
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
expect(calls[1]?.headers.get("authorization")).toBeNull();
});
it("aborts requests after timeout", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => {
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
localTimeoutMs: 25,
});
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
const assertion = expect(pending).rejects.toThrow("aborted");
await vi.advanceTimersByTimeAsync(30);
await assertion;
});
});
describe("MatrixClient event bridge", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("emits room.message only after encrypted events decrypt", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const messageEvents: Array<{ roomId: string; type: string }> = [];
client.on("room.message", (roomId, event) => {
messageEvents.push({ roomId, type: event.type });
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.emit("event", encrypted);
expect(messageEvents).toHaveLength(0);
encrypted.emit("decrypted", decrypted);
// Simulate a second normal event emission from the SDK after decryption.
matrixJsClient.emit("event", decrypted);
expect(messageEvents).toEqual([
{
roomId: "!room:example.org",
type: "m.room.message",
},
]);
});
it("emits room.failed_decryption when decrypting fails", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
const delivered: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", decrypted, new Error("decrypt failed"));
expect(failed).toEqual(["decrypt failed"]);
expect(delivered).toHaveLength(0);
});
it("retries failed decryption and emits room.message after late key availability", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
const delivered: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
encrypted.emit("decrypted", decrypted);
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(failed).toEqual(["missing room key"]);
expect(delivered).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1_600);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(failed).toEqual(["missing room key"]);
expect(delivered).toEqual(["m.room.message"]);
});
it("retries failed decryptions immediately on crypto key update signals", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const failed: string[] = [];
const delivered: string[] = [];
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
}));
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
encrypted.emit("decrypted", decrypted);
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(failed).toEqual(["missing room key"]);
expect(delivered).toHaveLength(0);
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
expect(trigger).toBeTypeOf("function");
trigger?.();
await Promise.resolve();
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(delivered).toEqual(["m.room.message"]);
});
it("stops decryption retries after hitting retry cap", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
throw new Error("still missing key");
});
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
expect(failed).toEqual(["missing room key"]);
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
await vi.advanceTimersByTimeAsync(200_000);
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
});
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
const delivered: string[] = [];
const cryptoListeners = new Map<string, (...args: unknown[]) => void>();
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
cryptoListeners.set(eventName, listener);
}),
bootstrapCrossSigning: vi.fn(async () => {}),
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
}));
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
decryptionFailure: true,
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
let releaseRetry: (() => void) | null = null;
matrixJsClient.decryptEventIfNeeded = vi.fn(
async () =>
await new Promise<void>((resolve) => {
releaseRetry = () => {
encrypted.emit("decrypted", decrypted);
resolve();
};
}),
);
await client.start();
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached");
expect(trigger).toBeTypeOf("function");
trigger?.();
trigger?.();
await Promise.resolve();
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
releaseRetry?.();
await Promise.resolve();
expect(delivered).toEqual(["m.room.message"]);
});
it("emits room.invite when a membership invite targets the current user", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
const inviteMembership = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$invite",
sender: "@alice:example.org",
type: "m.room.member",
ts: Date.now(),
stateKey: "@bot:example.org",
content: {
membership: "invite",
},
});
matrixJsClient.emit("event", inviteMembership);
expect(invites).toEqual(["!room:example.org"]);
});
it("emits room.invite when SDK emits Room event with invite membership", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
matrixJsClient.emit("Room", {
roomId: "!invite:example.org",
getMyMembership: () => "invite",
});
expect(invites).toEqual(["!invite:example.org"]);
});
it("replays outstanding invite rooms at startup", async () => {
matrixJsClient.getRooms = vi.fn(() => [
{
roomId: "!pending:example.org",
getMyMembership: () => "invite",
},
{
roomId: "!joined:example.org",
getMyMembership: () => "join",
},
]);
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
expect(invites).toEqual(["!pending:example.org"]);
});
});
describe("MatrixClient crypto bootstrapping", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
lastCreateClientOpts = null;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("passes cryptoDatabasePrefix into initRustCrypto", async () => {
matrixJsClient.getCrypto = vi.fn(() => undefined);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
cryptoDatabasePrefix: "openclaw-matrix-test",
});
await client.start();
expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({
cryptoDatabasePrefix: "openclaw-matrix-test",
});
});
it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => {
const bootstrapCrossSigning = vi.fn(async () => {});
matrixJsClient.getCrypto = vi.fn(() => ({
on: vi.fn(),
bootstrapCrossSigning,
bootstrapSecretStorage: vi.fn(async () => {}),
requestOwnUserVerification: vi.fn(async () => null),
}));
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
});
await client.start();
expect(bootstrapCrossSigning).toHaveBeenCalledWith(
expect.objectContaining({
authUploadDeviceSigningKeys: expect.any(Function),
}),
);
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
privateKeyBase64,
}),
"utf8",
);
new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
recoveryKeyPath,
});
const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as {
getSecretStorageKey?: (
params: { keys: Record<string, unknown> },
name: string,
) => Promise<[string, Uint8Array] | null>;
} | null;
expect(callbacks?.getSecretStorageKey).toBeTypeOf("function");
const resolved = await callbacks?.getSecretStorageKey?.(
{ keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } },
"m.cross_signing.master",
);
expect(resolved?.[0]).toBe("SSSSKEY");
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
});
it("schedules periodic crypto snapshot persistence with fake timers", async () => {
vi.useFakeTimers();
const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
encryption: true,
idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"),
cryptoDatabasePrefix: "openclaw-matrix-interval",
});
await client.start();
const callsAfterStart = databasesSpy.mock.calls.length;
await vi.advanceTimersByTimeAsync(60_000);
expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart);
client.stop();
const callsAfterStop = databasesSpy.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
});
});

View File

@@ -0,0 +1,527 @@
// Polyfill IndexedDB for WASM crypto in Node.js
import "fake-indexeddb/auto";
import { EventEmitter } from "node:events";
import {
ClientEvent,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
} from "matrix-js-sdk";
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
import { type HttpMethod, type QueryParams } from "./sdk/transport.js";
import type {
MatrixClientEventMap,
MatrixCryptoBootstrapApi,
MatrixRawEvent,
MessageEventContent,
} from "./sdk/types.js";
import { MatrixVerificationManager } from "./sdk/verification-manager.js";
export { ConsoleLogger, LogService };
export type {
DimensionalFileInfo,
FileWithThumbnailInfo,
TimedFileInfo,
VideoFileInfo,
} from "./sdk/types.js";
export type {
EncryptedFile,
LocationMessageEventContent,
MessageEventContent,
TextualMessageEventContent,
} from "./sdk/types.js";
export class MatrixClient {
private readonly client: MatrixJsClient;
private readonly emitter = new EventEmitter();
private readonly httpClient: MatrixAuthedHttpClient;
private readonly localTimeoutMs: number;
private readonly initialSyncLimit?: number;
private readonly encryptionEnabled: boolean;
private readonly idbSnapshotPath?: string;
private readonly cryptoDatabasePrefix?: string;
private bridgeRegistered = false;
private started = false;
private selfUserId: string | null;
private readonly dmRoomIds = new Set<string>();
private cryptoInitialized = false;
private readonly decryptBridge: MatrixDecryptBridge<MatrixRawEvent>;
private readonly verificationManager = new MatrixVerificationManager();
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
readonly dms = {
update: async (): Promise<void> => {
await this.refreshDmCache();
},
isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId),
};
crypto?: MatrixCryptoFacade;
constructor(
homeserver: string,
accessToken: string,
_storage?: unknown,
_cryptoStorage?: unknown,
opts: {
userId?: string;
password?: string;
deviceId?: string;
localTimeoutMs?: number;
encryption?: boolean;
initialSyncLimit?: number;
recoveryKeyPath?: string;
idbSnapshotPath?: string;
cryptoDatabasePrefix?: string;
} = {},
) {
this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken);
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
this.initialSyncLimit = opts.initialSyncLimit;
this.encryptionEnabled = opts.encryption === true;
this.idbSnapshotPath = opts.idbSnapshotPath;
this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix;
this.selfUserId = opts.userId?.trim() || null;
this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath);
const cryptoCallbacks = this.encryptionEnabled
? this.recoveryKeyStore.buildCryptoCallbacks()
: undefined;
this.client = createMatrixJsClient({
baseUrl: homeserver,
accessToken,
userId: opts.userId,
deviceId: opts.deviceId,
localTimeoutMs: this.localTimeoutMs,
cryptoCallbacks,
verificationMethods: [
VerificationMethod.Sas,
VerificationMethod.ShowQrCode,
VerificationMethod.ScanQrCode,
VerificationMethod.Reciprocate,
],
});
this.decryptBridge = new MatrixDecryptBridge<MatrixRawEvent>({
client: this.client,
toRaw: (event) => matrixEventToRaw(event),
emitDecryptedEvent: (roomId, event) => {
this.emitter.emit("room.decrypted_event", roomId, event);
},
emitMessage: (roomId, event) => {
this.emitter.emit("room.message", roomId, event);
},
emitFailedDecryption: (roomId, event, error) => {
this.emitter.emit("room.failed_decryption", roomId, event, error);
},
});
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => opts.password,
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
decryptBridge: this.decryptBridge,
});
if (this.encryptionEnabled) {
this.crypto = createMatrixCryptoFacade({
client: this.client,
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
this.getRoomStateEvent(roomId, eventType, stateKey),
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
});
}
}
on<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
on(eventName: string, listener: (...args: unknown[]) => void): this;
on(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.on(eventName, listener as (...args: unknown[]) => void);
return this;
}
off<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
off(eventName: string, listener: (...args: unknown[]) => void): this;
off(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.off(eventName, listener as (...args: unknown[]) => void);
return this;
}
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
async start(): Promise<void> {
if (this.started) {
return;
}
this.registerBridge();
await this.initializeCryptoIfNeeded();
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
this.started = true;
this.emitOutstandingInviteEvents();
await this.refreshDmCache().catch(noop);
}
stop(): void {
if (this.idbPersistTimer) {
clearInterval(this.idbPersistTimer);
this.idbPersistTimer = null;
}
this.decryptBridge.stop();
// Final persist on shutdown
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop);
this.client.stopClient();
this.started = false;
}
private async initializeCryptoIfNeeded(): Promise<void> {
if (!this.encryptionEnabled || this.cryptoInitialized) {
return;
}
// Restore persisted IndexedDB crypto store before initializing WASM crypto.
await restoreIdbFromDisk(this.idbSnapshotPath);
try {
await this.client.initRustCrypto({
cryptoDatabasePrefix: this.cryptoDatabasePrefix,
});
this.cryptoInitialized = true;
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (crypto) {
await this.cryptoBootstrapper.bootstrap(crypto);
}
// Persist the crypto store after successful init (captures fresh keys on first run).
await persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
});
// Periodically persist to capture new Olm sessions and room keys.
this.idbPersistTimer = setInterval(() => {
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop);
}, 60_000);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err);
}
}
async getUserId(): Promise<string> {
const fromClient = this.client.getUserId();
if (fromClient) {
this.selfUserId = fromClient;
return fromClient;
}
if (this.selfUserId) {
return this.selfUserId;
}
const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
};
const resolved = whoami.user_id?.trim();
if (!resolved) {
throw new Error("Matrix whoami did not return user_id");
}
this.selfUserId = resolved;
return resolved;
}
async getJoinedRooms(): Promise<string[]> {
const joined = await this.client.getJoinedRooms();
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
}
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
const members = await this.client.getJoinedRoomMembers(roomId);
const joined = members?.joined;
if (!joined || typeof joined !== "object") {
return [];
}
return Object.keys(joined);
}
async getRoomStateEvent(
roomId: string,
eventType: string,
stateKey = "",
): Promise<Record<string, unknown>> {
const state = await this.client.getStateEvent(roomId, eventType, stateKey);
return (state ?? {}) as Record<string, unknown>;
}
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
const event = this.client.getAccountData(eventType);
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
}
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
await this.client.setAccountData(eventType as never, content as never);
await this.refreshDmCache().catch(noop);
}
async resolveRoom(aliasOrRoomId: string): Promise<string | null> {
if (aliasOrRoomId.startsWith("!")) {
return aliasOrRoomId;
}
if (!aliasOrRoomId.startsWith("#")) {
return aliasOrRoomId;
}
try {
const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId);
return resolved.room_id ?? null;
} catch {
return null;
}
}
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
const sent = await this.client.sendMessage(roomId, content as never);
return sent.event_id;
}
async sendEvent(
roomId: string,
eventType: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
return sent.event_id;
}
async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendStateEvent(
roomId,
eventType as never,
content as never,
stateKey,
);
return sent.event_id;
}
async redactEvent(roomId: string, eventId: string, reason?: string): Promise<string> {
const sent = await this.client.redactEvent(
roomId,
eventId,
undefined,
reason?.trim() ? { reason } : undefined,
);
return sent.event_id;
}
async doRequest(
method: HttpMethod,
endpoint: string,
qs?: QueryParams,
body?: unknown,
opts?: { allowAbsoluteEndpoint?: boolean },
): Promise<unknown> {
return await this.httpClient.requestJson({
method,
endpoint,
qs,
body,
timeoutMs: this.localTimeoutMs,
allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint,
});
}
async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> {
return await this.client.getProfileInfo(userId);
}
async joinRoom(roomId: string): Promise<void> {
await this.client.joinRoom(roomId);
}
mxcToHttp(mxcUrl: string): string | null {
return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true);
}
async downloadContent(mxcUrl: string, allowRemote = true): Promise<Buffer> {
const parsed = parseMxc(mxcUrl);
if (!parsed) {
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
}
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
const response = await this.httpClient.requestRaw({
method: "GET",
endpoint,
qs: { allow_remote: allowRemote },
timeoutMs: this.localTimeoutMs,
});
return response;
}
async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise<string> {
const uploaded = await this.client.uploadContent(file, {
type: contentType || "application/octet-stream",
name: filename,
includeFilename: Boolean(filename),
});
return uploaded.content_uri;
}
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
}
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
await this.client.sendTyping(roomId, typing, timeoutMs);
}
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
await this.httpClient.requestJson({
method: "POST",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
eventId,
)}`,
body: {},
timeoutMs: this.localTimeoutMs,
});
}
private registerBridge(): void {
if (this.bridgeRegistered) {
return;
}
this.bridgeRegistered = true;
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (!roomId) {
return;
}
const raw = matrixEventToRaw(event);
const isEncryptedEvent = raw.type === "m.room.encrypted";
this.emitter.emit("room.event", roomId, raw);
if (isEncryptedEvent) {
this.emitter.emit("room.encrypted_event", roomId, raw);
} else {
if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
this.emitter.emit("room.message", roomId, raw);
}
}
const stateKey = raw.state_key ?? "";
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
const membership =
raw.type === "m.room.member"
? (raw.content as { membership?: string }).membership
: undefined;
if (stateKey && selfUserId && stateKey === selfUserId) {
if (membership === "invite") {
this.emitter.emit("room.invite", roomId, raw);
} else if (membership === "join") {
this.emitter.emit("room.join", roomId, raw);
}
}
if (isEncryptedEvent) {
this.decryptBridge.attachEncryptedEvent(event, roomId);
}
});
// Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events.
this.client.on(ClientEvent.Room, (room) => {
this.emitMembershipForRoom(room);
});
}
private emitMembershipForRoom(room: unknown): void {
const roomObj = room as {
roomId?: string;
getMyMembership?: () => string | null | undefined;
selfMembership?: string | null | undefined;
};
const roomId = roomObj.roomId?.trim();
if (!roomId) {
return;
}
const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined;
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
if (!selfUserId) {
return;
}
const raw: MatrixRawEvent = {
type: "m.room.member",
room_id: roomId,
sender: selfUserId,
state_key: selfUserId,
content: { membership },
origin_server_ts: Date.now(),
unsigned: { age: 0 },
};
if (membership === "invite") {
this.emitter.emit("room.invite", roomId, raw);
return;
}
if (membership === "join") {
this.emitter.emit("room.join", roomId, raw);
}
}
private emitOutstandingInviteEvents(): void {
const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms;
if (typeof listRooms !== "function") {
return;
}
const rooms = listRooms.call(this.client);
if (!Array.isArray(rooms)) {
return;
}
for (const room of rooms) {
this.emitMembershipForRoom(room);
}
}
private async refreshDmCache(): Promise<void> {
const direct = await this.getAccountData("m.direct");
this.dmRoomIds.clear();
if (!direct || typeof direct !== "object") {
return;
}
for (const value of Object.values(direct)) {
if (!Array.isArray(value)) {
continue;
}
for (const roomId of value) {
if (typeof roomId === "string" && roomId.trim()) {
this.dmRoomIds.add(roomId);
}
}
}
}
}

View File

@@ -0,0 +1,241 @@
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("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 firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as {
authUploadDeviceSigningKeys?: <T>(
makeRequest: (authData: Record<string, unknown> | null) => Promise<T>,
) => Promise<T>;
};
expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function");
const seenAuthStages: Array<Record<string, unknown> | null> = [];
const result = await firstCall.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);
});
});

View File

@@ -0,0 +1,226 @@
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.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 class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
constructor(private readonly deps: MatrixCryptoBootstrapperDeps<TRawEvent>) {}
async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise<void> {
await this.bootstrapSecretStorage(crypto);
await this.bootstrapCrossSigning(crypto);
await this.bootstrapSecretStorage(crypto);
await this.ensureOwnDeviceTrust(crypto);
this.registerVerificationRequestHandler(crypto);
}
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.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): Promise<void> {
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;
}
};
// 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);
return;
}
}
const firstPassReady = await isCrossSigningReady();
const firstPassPublished = await hasPublishedCrossSigningKeys();
if (firstPassReady && firstPassPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
}
// 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);
return;
}
const finalReady = await isCrossSigningReady();
const finalPublished = await hasPublishedCrossSigningKeys();
if (finalReady && finalPublished) {
LogService.info("MatrixClientLite", "Cross-signing bootstrap complete");
return;
}
LogService.warn(
"MatrixClientLite",
"Cross-signing bootstrap finished but server keys are still not published",
);
}
private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): 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);
}
}
private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void {
// Auto-accept incoming verification requests from other users/devices.
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
const verificationRequest = request as MatrixVerificationRequestLike;
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
const otherUserId = verificationRequest.otherUserId;
const isSelfVerification = verificationRequest.isSelfVerification;
const initiatedByMe = verificationRequest.initiatedByMe;
if (isSelfVerification || initiatedByMe) {
LogService.debug(
"MatrixClientLite",
`Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`,
);
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): Promise<void> {
const deviceId = this.deps.getDeviceId()?.trim();
if (!deviceId) {
return;
}
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;
}
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);
}
}
}
}

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", "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,57 @@
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
}
export class ConsoleLogger {
trace(module: string, ...messageOrObject: unknown[]): void {
console.debug(`[${module}]`, ...messageOrObject);
}
debug(module: string, ...messageOrObject: unknown[]): void {
console.debug(`[${module}]`, ...messageOrObject);
}
info(module: string, ...messageOrObject: unknown[]): void {
console.info(`[${module}]`, ...messageOrObject);
}
warn(module: string, ...messageOrObject: unknown[]): void {
console.warn(`[${module}]`, ...messageOrObject);
}
error(module: string, ...messageOrObject: unknown[]): void {
console.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,176 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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",
});
});
});

View File

@@ -0,0 +1,253 @@
import fs from "node:fs";
import path from "node:path";
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,
};
}
async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): 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 = 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: false,
};
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,183 @@
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 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>;
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,170 @@
import { EventEmitter } from "node:events";
import { VerificationPhase } 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("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);
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(1);
manager.mismatchVerificationSas(tracked.id);
expect(mismatch).toHaveBeenCalledTimes(1);
});
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,464 @@
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;
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;
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 pruneVerificationSessions(nowMs: number): void {
for (const [id, session] of this.verificationSessions) {
const phase = session.request.phase;
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 = request.phase;
const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining;
return {
id: session.id,
transactionId: request.transactionId,
roomId: request.roomId,
otherUserId: request.otherUserId,
otherDeviceId: request.otherDeviceId,
isSelfVerification: request.isSelfVerification,
initiatedByMe: request.initiatedByMe,
phase,
phaseName: this.getVerificationPhaseName(phase),
pending: request.pending,
methods: Array.isArray(request.methods) ? request.methods : [],
chosenMethod: request.chosenMethod ?? null,
canAccept,
hasSas: Boolean(session.sasCallbacks),
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()) {
if (session.request.transactionId === 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);
if (session.request.verifier) {
this.attachVerifierToVerificationSession(session, session.request.verifier);
}
});
}
private attachVerifierToVerificationSession(
session: MatrixVerificationSession,
verifier: MatrixVerifierLike,
): void {
session.activeVerifier = verifier;
this.touchVerificationSession(session);
const maybeSas = verifier.getShowSasCallbacks();
if (maybeSas) {
session.sasCallbacks = maybeSas;
}
const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks();
if (maybeReciprocateQr) {
session.reciprocateQrCallbacks = maybeReciprocateQr;
}
const verifierObj = verifier as unknown as object;
if (this.trackedVerificationVerifiers.has(verifierObj)) {
return;
}
this.trackedVerificationVerifiers.add(verifierObj);
verifier.on(VerifierEvent.ShowSas, (sas) => {
session.sasCallbacks = sas as MatrixShowSasCallbacks;
this.touchVerificationSession(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);
});
}
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 = request.transactionId?.trim();
if (txId) {
for (const existing of this.verificationSessions.values()) {
if (existing.request.transactionId === txId) {
existing.request = request;
this.ensureVerificationRequestTracked(existing);
if (request.verifier) {
this.attachVerifierToVerificationSession(existing, request.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,
};
this.verificationSessions.set(session.id, session);
this.ensureVerificationRequestTracked(session);
if (request.verifier) {
this.attachVerifierToVerificationSession(session, request.verifier);
}
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,155 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
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(() => {
vi.clearAllMocks();
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");
});
});
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,260 @@
import type { PollInput } from "openclaw/plugin-sdk";
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",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", 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 })
: 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,67 @@
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
const getCore = () => getMatrixRuntime();
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export function resolveMediaMaxBytes(): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig;
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
}
return undefined;
}
export async function resolveMatrixClient(opts: {
client?: MatrixClient;
timeoutMs?: number;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient();
if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth();
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,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await client.crypto.prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -0,0 +1,93 @@
import { getMatrixRuntime } from "../../runtime.js";
import { markdownToMatrixHtml } from "../format.js";
import {
MsgType,
RelationType,
type MatrixFormattedContent,
type MatrixMediaMsgType,
type MatrixRelation,
type MatrixReplyRelation,
type MatrixTextContent,
type MatrixThreadRelation,
} from "./types.js";
const getCore = () => getMatrixRuntime();
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
body,
"m.relates_to": relation,
}
: {
msgtype: MsgType.Text,
body,
};
applyMatrixFormatting(content, body);
return content;
}
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) {
return;
}
content.format = "org.matrix.custom.html";
content.formatted_body = formatted;
}
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
const trimmed = replyToId?.trim();
if (!trimmed) {
return undefined;
}
return { "m.in_reply_to": { event_id: trimmed } };
}
export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
const trimmed = threadId.trim();
return {
rel_type: RelationType.Thread,
event_id: trimmed,
is_falling_back: true,
"m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
};
}
export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
export function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) {
return { useVoice: false };
}
if (isMatrixVoiceCompatibleAudio(opts)) {
return { useVoice: true };
}
return { useVoice: false };
}
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
// Matrix currently shares the core voice compatibility policy.
// Keep this wrapper as the seam if Matrix policy diverges later.
return getCore().media.isVoiceCompatibleAudio({
contentType: opts.contentType,
fileName: opts.fileName,
});
}

View File

@@ -0,0 +1,229 @@
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "../sdk.js";
import { applyMatrixFormatting } from "./formatting.js";
import {
type MatrixMediaContent,
type MatrixMediaInfo,
type MatrixMediaMsgType,
type MatrixRelation,
type MediaKind,
} from "./types.js";
const getCore = () => getMatrixRuntime();
export function buildMatrixMediaInfo(params: {
size: number;
mimetype?: string;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
}): MatrixMediaInfo | undefined {
const base: FileWithThumbnailInfo = {};
if (Number.isFinite(params.size)) {
base.size = params.size;
}
if (params.mimetype) {
base.mimetype = params.mimetype;
}
if (params.imageInfo) {
const dimensional: DimensionalFileInfo = {
...base,
...params.imageInfo,
};
if (typeof params.durationMs === "number") {
const videoInfo: VideoFileInfo = {
...dimensional,
duration: params.durationMs,
};
return videoInfo;
}
return dimensional;
}
if (typeof params.durationMs === "number") {
const timedInfo: TimedFileInfo = {
...base,
duration: params.durationMs,
};
return timedInfo;
}
if (Object.keys(base).length === 0) {
return undefined;
}
return base;
}
export function buildMediaContent(params: {
msgtype: MatrixMediaMsgType;
body: string;
url?: string;
filename?: string;
mimetype?: string;
size: number;
relation?: MatrixRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
file?: EncryptedFile;
}): MatrixMediaContent {
const info = buildMatrixMediaInfo({
size: params.size,
mimetype: params.mimetype,
durationMs: params.durationMs,
imageInfo: params.imageInfo,
});
const base: MatrixMediaContent = {
msgtype: params.msgtype,
body: params.body,
filename: params.filename,
info: info ?? undefined,
};
// Encrypted media should only include the "file" payload, not top-level "url".
if (!params.file && params.url) {
base.url = params.url;
}
// For encrypted files, add the file object
if (params.file) {
base.file = params.file;
}
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
if (typeof params.durationMs === "number") {
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
applyMatrixFormatting(base, params.body);
return base;
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
export async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore()
.media.getImageMetadata(params.buffer)
.catch(() => null);
if (!meta) {
return undefined;
}
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await getCore().media.resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getCore()
.media.getImageMetadata(thumbBuffer)
.catch(() => null);
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
export async function resolveMediaDurationMs(params: {
buffer: Buffer;
contentType?: string;
fileName?: string;
kind: MediaKind;
}): Promise<number | undefined> {
if (params.kind !== "audio" && params.kind !== "video") {
return undefined;
}
try {
const fileInfo: IFileInfo | string | undefined =
params.contentType || params.fileName
? {
mimeType: params.contentType,
size: params.buffer.byteLength,
path: params.fileName,
}
: undefined;
const metadata = await parseBuffer(params.buffer, fileInfo, {
duration: true,
skipCovers: true,
});
const durationSeconds = metadata.format.duration;
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
return Math.max(0, Math.round(durationSeconds * 1000));
}
} catch {
// Duration is optional; ignore parse failures.
}
return undefined;
}
async function uploadFile(
client: MatrixClient,
file: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<string> {
return await client.uploadContent(file, params.contentType, params.filename);
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
export async function uploadMediaMaybeEncrypted(
client: MatrixClient,
roomId: string,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
if (isEncrypted && client.crypto) {
// Encrypt the media before uploading
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
// Upload unencrypted
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
beforeEach(async () => {
vi.resetModules();
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
});
describe("resolveMatrixRoomId", () => {
it("uses m.direct when available", async () => {
const userId = "@user:example.org";
const client = {
getAccountData: vi.fn().mockResolvedValue({
[userId]: ["!room:example.org"],
}),
getJoinedRooms: vi.fn(),
getJoinedRoomMembers: vi.fn(),
setAccountData: vi.fn(),
} as unknown as MatrixClient;
const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org");
// oxlint-disable-next-line typescript/unbound-method
expect(client.getJoinedRooms).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("falls back to joined rooms and persists m.direct", async () => {
const userId = "@fallback:example.org";
const roomId = "!room:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]),
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({ [userId]: [roomId] }),
);
});
it("continues when a room member lookup fails", async () => {
const userId = "@continue:example.org";
const roomId = "!good:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const getJoinedRoomMembers = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(["@bot:example.org", userId]);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
getJoinedRoomMembers,
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalled();
});
it("allows larger rooms when no 1:1 match exists", async () => {
const userId = "@group:example.org";
const roomId = "!group:example.org";
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi
.fn()
.mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]),
setAccountData: vi.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
});
});
describe("normalizeThreadId", () => {
it("returns null for empty thread ids", () => {
expect(normalizeThreadId(" ")).toBeNull();
expect(normalizeThreadId("$thread")).toBe("$thread");
});
});

View File

@@ -0,0 +1,150 @@
import type { MatrixClient } from "../sdk.js";
import { EventType, type MatrixDirectAccountData } from "./types.js";
function normalizeTarget(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Matrix target is required (room:<id> or #alias)");
}
return trimmed;
}
export function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
// Size-capped to prevent unbounded growth (#4948)
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
const directRoomCache = new Map<string, string>();
function setDirectRoomCached(key: string, value: string): void {
directRoomCache.set(key, value);
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
const oldest = directRoomCache.keys().next().value;
if (oldest !== undefined) {
directRoomCache.delete(oldest);
}
}
}
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = await client.getAccountData(EventType.Direct);
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) {
return;
}
const next = [roomId, ...current.filter((id) => id !== roomId)];
try {
await client.setAccountData(EventType.Direct, {
...existing,
[userId]: next,
});
} catch {
// Ignore persistence errors.
}
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim();
if (!trimmed.startsWith("@")) {
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
}
const cached = directRoomCache.get(trimmed);
if (cached) {
return cached;
}
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try {
const directContent = (await client.getAccountData(EventType.Direct)) as Record<
string,
string[] | undefined
>;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list && list.length > 0) {
setDirectRoomCached(trimmed, list[0]);
return list[0];
}
} catch {
// Ignore and fall back.
}
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
let fallbackRoom: string | null = null;
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
let members: string[];
try {
members = await client.getJoinedRoomMembers(roomId);
} catch {
continue;
}
if (!members.includes(trimmed)) {
continue;
}
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
setDirectRoomCached(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
setDirectRoomCached(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
}
export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
const target = normalizeTarget(raw);
const lowered = target.toLowerCase();
if (lowered.startsWith("matrix:")) {
return await resolveMatrixRoomId(client, target.slice("matrix:".length));
}
if (lowered.startsWith("room:")) {
return await resolveMatrixRoomId(client, target.slice("room:".length));
}
if (lowered.startsWith("channel:")) {
return await resolveMatrixRoomId(client, target.slice("channel:".length));
}
if (lowered.startsWith("user:")) {
return await resolveDirectRoomId(client, target.slice("user:".length));
}
if (target.startsWith("@")) {
return await resolveDirectRoomId(client, target);
}
if (target.startsWith("#")) {
const resolved = await client.resolveRoom(target);
if (!resolved) {
throw new Error(`Matrix alias ${target} could not be resolved`);
}
return resolved;
}
return target;
}

View File

@@ -0,0 +1,109 @@
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MessageEventContent,
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "../sdk.js";
// Message types
export const MsgType = {
Text: "m.text",
Image: "m.image",
Audio: "m.audio",
Video: "m.video",
File: "m.file",
Notice: "m.notice",
} as const;
// Relation types
export const RelationType = {
Annotation: "m.annotation",
Replace: "m.replace",
Thread: "m.thread",
} as const;
// Event types
export const EventType = {
Direct: "m.direct",
Reaction: "m.reaction",
RoomMessage: "m.room.message",
} as const;
export type MatrixDirectAccountData = Record<string, string[]>;
export type MatrixReplyRelation = {
"m.in_reply_to": { event_id: string };
};
export type MatrixThreadRelation = {
rel_type: typeof RelationType.Thread;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: { event_id: string };
};
export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation;
export type MatrixReplyMeta = {
"m.relates_to"?: MatrixRelation;
};
export type MatrixMediaInfo =
| FileWithThumbnailInfo
| DimensionalFileInfo
| TimedFileInfo
| VideoFileInfo;
export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta;
export type MatrixMediaContent = MessageEventContent &
MatrixReplyMeta & {
info?: MatrixMediaInfo;
url?: string;
file?: EncryptedFile;
filename?: string;
"org.matrix.msc3245.voice"?: Record<string, never>;
"org.matrix.msc1767.audio"?: { duration: number };
};
export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
export type ReactionEventContent = {
"m.relates_to": {
rel_type: typeof RelationType.Annotation;
event_id: string;
key: string;
};
};
export type MatrixSendResult = {
messageId: string;
roomId: string;
};
export type MatrixSendOpts = {
client?: import("../sdk.js").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
};
export type MatrixMediaMsgType =
| typeof MsgType.Image
| typeof MsgType.Audio
| typeof MsgType.Video
| typeof MsgType.File;
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
export type MatrixFormattedContent = MessageEventContent & {
format?: string;
formatted_body?: string;
};

View File

@@ -0,0 +1,452 @@
import type { DmPolicy } from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
dm: {
...cfg.channels?.matrix?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Matrix requires a homeserver URL.",
"Use an access token (recommended), password login, or account registration.",
"With access token: user ID is fetched automatically.",
"Password + register mode can create an account on homeservers with open registration.",
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
].join("\n"),
"Matrix setup",
);
}
async function promptMatrixAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
}): Promise<CoreConfig> {
const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
const account = resolveMatrixAccount({ cfg });
const canResolve = Boolean(account.configured);
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
while (true) {
const entry = await prompter.text({
message: "Matrix allowFrom (full @user:server; display name only if unique)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedIds: string[] = [];
const pending: string[] = [];
const unresolved: string[] = [];
const unresolvedNotes: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
resolvedIds.push(part);
continue;
}
if (!canResolve) {
unresolved.push(part);
continue;
}
pending.push(part);
}
if (pending.length > 0) {
const results = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
}).catch(() => []);
for (const result of results) {
if (result?.resolved && result.id) {
resolvedIds.push(result.id);
continue;
}
if (result?.input) {
unresolved.push(result.input);
if (result.note) {
unresolvedNotes.push(`${result.input}: ${result.note}`);
}
}
}
}
if (unresolved.length > 0) {
const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
await prompter.note(
`Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
"Matrix allowlist",
);
continue;
}
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
dm: {
...cfg.channels?.matrix?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
},
};
}
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
groupPolicy,
},
},
};
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
groups,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Matrix",
channel,
policyKey: "channels.matrix.dm.policy",
allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
};
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
const configured = account.configured;
const sdkReady = isMatrixSdkAvailable();
return {
channel,
configured,
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth",
};
},
configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => {
let next = cfg as CoreConfig;
await ensureMatrixSdkInstalled({
runtime,
confirm: async (message) =>
await prompter.confirm({
message,
initialValue: true,
}),
});
const existing = next.channels?.matrix ?? {};
const account = resolveMatrixAccount({ cfg: next });
if (!account.configured) {
await noteMatrixAuthHelp(prompter);
}
const envHomeserver = process.env.MATRIX_HOMESERVER?.trim();
const envUserId = process.env.MATRIX_USER_ID?.trim();
const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim();
const envPassword = process.env.MATRIX_PASSWORD?.trim();
const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword)));
if (
envReady &&
!existing.homeserver &&
!existing.userId &&
!existing.accessToken &&
!existing.password
) {
const useEnv = await prompter.confirm({
message: "Matrix env vars detected. Use env values?",
initialValue: true,
});
if (useEnv) {
next = {
...next,
channels: {
...next.channels,
matrix: {
...next.channels?.matrix,
enabled: true,
},
},
};
if (forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
return { cfg: next };
}
}
const homeserver = String(
await prompter.text({
message: "Matrix homeserver URL",
initialValue: existing.homeserver ?? envHomeserver,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
if (!/^https?:\/\//i.test(raw)) {
return "Use a full URL (https://...)";
}
return undefined;
},
}),
).trim();
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let userId = existing.userId ?? "";
let register = existing.register === true;
if (accessToken || password) {
const keep = await prompter.confirm({
message: "Matrix credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
accessToken = "";
password = "";
userId = "";
register = false;
}
}
if (!accessToken && !password) {
// Ask auth method FIRST before asking for user ID
const authMode = await prompter.select({
message: "Matrix auth method",
options: [
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
{
value: "register",
label: "Register account (open homeserver registration required)",
},
],
});
if (authMode === "token") {
accessToken = String(
await prompter.text({
message: "Matrix access token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
// With access token, we can fetch the userId automatically - don't prompt for it
// The client.ts will use whoami() to get it
userId = "";
register = false;
} else {
// Password auth and registration mode require user ID upfront
userId = String(
await prompter.text({
message: "Matrix user ID",
initialValue: existing.userId ?? envUserId,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
if (!raw.startsWith("@")) {
return "Matrix user IDs should start with @";
}
if (!raw.includes(":")) {
return "Matrix user IDs should include a server (:server)";
}
return undefined;
},
}),
).trim();
password = String(
await prompter.text({
message: "Matrix password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
register = authMode === "register";
}
}
const deviceName = String(
await prompter.text({
message: "Matrix device name (optional)",
initialValue: existing.deviceName ?? "OpenClaw Gateway",
}),
).trim();
// Ask about E2EE encryption
const enableEncryption = await prompter.confirm({
message: "Enable end-to-end encryption (E2EE)?",
initialValue: existing.encryption ?? false,
});
next = {
...next,
channels: {
...next.channels,
matrix: {
...next.channels?.matrix,
enabled: true,
homeserver,
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
register,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},
},
};
if (forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Matrix rooms",
);
}
} catch (err) {
await prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixGroupRooms(next, roomKeys);
}
}
return { cfg: next };
},
dmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {
...(cfg as CoreConfig).channels,
matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false },
},
}),
};

View File

@@ -0,0 +1,55 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
import { getMatrixRuntime } from "./runtime.js";
export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
mediaUrl,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendPoll: async ({ to, poll, threadId, accountId }) => {
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await sendPollMatrix(to, poll, {
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix",
messageId: result.eventId,
roomId: result.roomId,
pollId: result.eventId,
};
},
};

View File

@@ -0,0 +1,67 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
vi.mock("./directory-live.js", () => ({
listMatrixDirectoryPeersLive: vi.fn(),
listMatrixDirectoryGroupsLive: vi.fn(),
}));
describe("resolveMatrixTargets (users)", () => {
beforeEach(() => {
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
vi.mocked(listMatrixDirectoryGroupsLive).mockReset();
});
it("resolves exact unique display name matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("@alice:example.org");
});
it("does not resolve ambiguous or non-exact matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
{ kind: "user", id: "@alice:evil.example", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(false);
expect(result?.note).toMatch(/use full Matrix ID/i);
});
it("prefers exact group matches over first partial result", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "group", id: "!one:example.org", name: "General", handle: "#general" },
{ kind: "group", id: "!two:example.org", name: "Team", handle: "#team" },
];
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["#team"],
kind: "group",
});
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("!two:example.org");
expect(result?.note).toBe("multiple matches; chose first");
});
});

View File

@@ -0,0 +1,126 @@
import type {
ChannelDirectoryEntry,
ChannelResolveKind,
ChannelResolveResult,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
function findExactDirectoryMatches(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry[] {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return [];
}
return matches.filter((match) => {
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
return normalized === id || normalized === name || normalized === handle;
});
}
function pickBestGroupMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) {
return undefined;
}
const [exact] = findExactDirectoryMatches(matches, query);
return exact ?? matches[0];
}
function pickBestUserMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) {
return undefined;
}
const exact = findExactDirectoryMatches(matches, query);
if (exact.length === 1) {
return exact[0];
}
return undefined;
}
function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
if (matches.length === 0) {
return "no matches";
}
const normalized = query.trim().toLowerCase();
if (!normalized) {
return "empty input";
}
const exact = findExactDirectoryMatches(matches, normalized);
if (exact.length === 0) {
return "no exact match; use full Matrix ID";
}
if (exact.length > 1) {
return "multiple exact matches; use full Matrix ID";
}
return "no exact match; use full Matrix ID";
}
export async function resolveMatrixTargets(params: {
cfg: unknown;
inputs: string[];
kind: ChannelResolveKind;
runtime?: RuntimeEnv;
}): Promise<ChannelResolveResult[]> {
const results: ChannelResolveResult[] = [];
for (const input of params.inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (params.kind === "user") {
if (trimmed.startsWith("@") && trimmed.includes(":")) {
results.push({ input, resolved: true, id: trimmed });
continue;
}
try {
const matches = await listMatrixDirectoryPeersLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestUserMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
continue;
}
try {
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query: trimmed,
limit: 5,
});
const best = pickBestGroupMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
}

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