feat: Add multi-account Matrix support

- Modified shared.ts to maintain a Map of clients per accountId
- Each account gets its own Matrix client instance
- Backwards compatible with single-account usage
- resolveMatrixAuth now accepts accountId parameter
- stopSharedClient can stop specific or all accounts

Files changed:
- src/matrix/client/shared.ts (main changes)
- src/matrix/client/config.ts (accountId support)
- src/matrix/accounts.ts (list enabled accounts)
- src/matrix/monitor/index.ts (pass accountId)
- src/types.ts (accounts config type)
This commit is contained in:
Claudia 2026-02-01 11:06:37 +01:00
commit 8790ba5dad
70 changed files with 9407 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store

59
CHANGELOG.md Normal file
View file

@ -0,0 +1,59 @@
# Changelog
## 2026.1.29
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.23
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.22
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.21
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.20
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.17-1
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.17
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.16
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.15
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.1.14
### Features
- Version alignment with core OpenClaw release numbers.
- 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.

18
index.ts Normal file
View file

@ -0,0 +1,18 @@
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;

11
openclaw.plugin.json Normal file
View file

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

2194
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@openclaw/matrix",
"version": "2026.1.29",
"type": "module",
"description": "OpenClaw Matrix channel plugin",
"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"
}
},
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"markdown-it": "14.1.0",
"music-metadata": "^11.10.6",
"zod": "^4.3.6"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

185
src/actions.ts Normal file
View file

@ -0,0 +1,185 @@
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");
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,
);
}
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,
);
}
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,
);
}
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,
);
}
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,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleMatrixAction(
{
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
},
cfg,
);
}
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,
);
}
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,
);
}
if (action === "channel-info") {
return await handleMatrixAction(
{
action: "channelInfo",
roomId: resolveRoomId(),
},
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider matrix.`);
},
};

View file

@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: (_env, homeDir) => homeDir(),
},
} 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 }),
).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 }),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
});

417
src/channel.ts Normal file
View file

@ -0,0 +1,417 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
import type { CoreConfig } from "./types.js";
import {
listMatrixAccountIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import { resolveMatrixAuth } from "./matrix/client.js";
import { normalizeAllowListLower } 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 {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
} from "./directory-live.js";
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;
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 } : {}),
...(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",
"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 }) =>
((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => ({
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: "channels.matrix.dm.policy",
allowFromPath: "channels.matrix.dm.allowFrom",
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => raw.replace(/^matrix:/i, "").trim().toLowerCase(),
}),
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy =
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
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 }) =>
(cfg as CoreConfig).channels?.matrix?.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, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, query, limit }),
listGroupsLive: async ({ cfg, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, 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 });
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"})`,
);
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorMatrixProvider } = await import("./matrix/index.js");
return monitorMatrixProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},
};

62
src/config-schema.ts Normal file
View file

@ -0,0 +1,62 @@
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(),
})
.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(),
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(),
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,
});

175
src/directory-live.ts Normal file
View file

@ -0,0 +1,175 @@
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;
};
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() ?? "";
}
export async function listMatrixDirectoryPeersLive(params: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
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: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20,
},
});
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: {
cfg: unknown;
query?: string | null;
limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query);
if (!query) return [];
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) return [];
return [
{
kind: "group",
id: roomId,
name: query,
handle: query,
} satisfies ChannelDirectoryEntry,
];
}
if (query.startsWith("!")) {
return [
{
kind: "group",
id: query,
name: query,
} satisfies ChannelDirectoryEntry,
];
}
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;
}

61
src/group-mentions.ts Normal file
View file

@ -0,0 +1,61 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
const rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
const lower = roomId.toLowerCase();
if (lower.startsWith("matrix:")) {
roomId = roomId.slice("matrix:".length).trim();
}
if (roomId.toLowerCase().startsWith("channel:")) {
roomId = roomId.slice("channel:".length).trim();
}
if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim();
}
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).config;
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 rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
const lower = roomId.toLowerCase();
if (lower.startsWith("matrix:")) {
roomId = roomId.slice("matrix:".length).trim();
}
if (roomId.toLowerCase().startsWith("channel:")) {
roomId = roomId.slice("channel:".length).trim();
}
if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim();
}
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).config;
return resolved?.tools;
}

View file

@ -0,0 +1,83 @@
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);
});
});

164
src/matrix/accounts.ts Normal file
View file

@ -0,0 +1,164 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
accessToken?: string;
config: MatrixAccountConfig;
};
/**
* List account IDs explicitly configured in channels.matrix.accounts
*/
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (!key) continue;
ids.add(normalizeAccountId(key));
}
return [...ids];
}
/**
* List account IDs referenced in bindings for matrix channel
*/
function listBoundAccountIds(cfg: CoreConfig): string[] {
const bindings = cfg.bindings;
if (!Array.isArray(bindings)) return [];
const ids = new Set<string>();
for (const binding of bindings) {
if (binding.match?.channel === "matrix" && binding.match?.accountId) {
ids.add(normalizeAccountId(binding.match.accountId));
}
}
return [...ids];
}
/**
* List all Matrix account IDs (configured + bound)
*/
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = Array.from(
new Set([
DEFAULT_ACCOUNT_ID,
...listConfiguredAccountIds(cfg),
...listBoundAccountIds(cfg),
]),
);
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;
}
/**
* Get account-specific config from channels.matrix.accounts[accountId]
*/
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find(
(key) => normalizeAccountId(key) === normalized
);
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
}
/**
* Merge base matrix config with account-specific overrides
*/
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
const base = cfg.channels?.matrix ?? {};
// Extract base config without 'accounts' key
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
// Account config overrides base config
return { ...baseConfig, ...accountConfig };
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeMatrixAccountConfig(params.cfg, accountId);
// Check if this is a non-default account - use account-specific auth
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID || accountId === "default";
// For non-default accounts, use account-specific credentials
// For default account, use base config or env
let homeserver = merged.homeserver;
let userId = merged.userId;
let accessToken = merged.accessToken;
if (isDefaultAccount) {
// Default account can fall back to env vars
const resolved = resolveMatrixConfig(params.cfg, process.env);
homeserver = homeserver || resolved.homeserver;
userId = userId || resolved.userId;
accessToken = accessToken || resolved.accessToken;
}
const baseEnabled = params.cfg.channels?.matrix?.enabled !== false;
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const hasHomeserver = Boolean(homeserver);
const hasAccessToken = Boolean(accessToken);
const hasPassword = Boolean(merged.password);
const hasUserId = Boolean(userId);
const hasPasswordAuth = hasUserId && hasPassword;
// Check for stored credentials (only for default account)
const stored = isDefaultAccount ? loadMatrixCredentials(process.env) : null;
const hasStored =
stored && homeserver
? credentialsMatchConfig(stored, {
homeserver: homeserver,
userId: userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
configured,
homeserver: homeserver || undefined,
userId: userId || undefined,
accessToken: accessToken || undefined,
config: merged,
};
}
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.configured);
}

15
src/matrix/actions.ts Normal file
View file

@ -0,0 +1,15 @@
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 { reactMatrixMessage } from "./send.js";

View file

@ -0,0 +1,53 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
const active = getActiveMatrixClient();
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,
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,120 @@
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.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 =
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// @vector-im/matrix-bot-sdk uses doRequest for room messages
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,70 @@
import {
EventType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import { resolveMatrixRoomId } from "../send.js";
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
}
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
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 };
} finally {
if (stopOnDone) client.stop();
}
}

View file

@ -0,0 +1,84 @@
import {
EventType,
RelationType,
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
type ReactionEventContent,
} from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.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;
// @vector-im/matrix-bot-sdk uses doRequest for relations
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,88 @@
import { EventType, type MatrixActionClientOpts } from "./types.js";
import { resolveActionClient } from "./client.js";
import { resolveMatrixRoomId } from "../send.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;
// @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room 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);
// @vector-im/matrix-bot-sdk uses getRoomState for state events
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 = nameState?.name ?? null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(
resolvedRoom,
"m.room.canonical_alias",
"",
);
canonicalAlias = 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,77 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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 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,84 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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 = {
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 MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
};
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,11 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
let activeClient: MatrixClient | null = null;
export function setActiveMatrixClient(client: MatrixClient | null): void {
activeClient = client;
}
export function getActiveMatrixClient(): MatrixClient | null {
return activeClient;
}

57
src/matrix/client.test.ts Normal file
View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
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",
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_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.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
});

9
src/matrix/client.ts Normal file
View file

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

216
src/matrix/client/config.ts Normal file
View file

@ -0,0 +1,216 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
/**
* Get account-specific config from channels.matrix.accounts[accountId]
*/
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find(
(key) => normalizeAccountId(key) === normalized
);
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
}
/**
* Merge base matrix config with account-specific overrides
*/
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
const base = cfg.channels?.matrix ?? {};
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
return { ...baseConfig, ...accountConfig };
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
accountId?: string,
): MatrixResolvedConfig {
const normalizedAccountId = normalizeAccountId(accountId);
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
// Get merged config for this account
const merged = mergeMatrixAccountConfig(cfg, normalizedAccountId);
// For default account, allow env var fallbacks
const homeserver = clean(merged.homeserver) || (isDefaultAccount ? clean(env.MATRIX_HOMESERVER) : "");
const userId = clean(merged.userId) || (isDefaultAccount ? clean(env.MATRIX_USER_ID) : "");
const accessToken = clean(merged.accessToken) || (isDefaultAccount ? clean(env.MATRIX_ACCESS_TOKEN) : "") || undefined;
const password = clean(merged.password) || (isDefaultAccount ? clean(env.MATRIX_PASSWORD) : "") || undefined;
const deviceName = clean(merged.deviceName) || (isDefaultAccount ? clean(env.MATRIX_DEVICE_NAME) : "") || undefined;
const initialSyncLimit =
typeof merged.initialSyncLimit === "number"
? Math.max(0, Math.floor(merged.initialSyncLimit))
: undefined;
const encryption = merged.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const accountId = params?.accountId;
const resolved = resolveMatrixConfig(cfg, env, accountId);
if (!resolved.homeserver) {
throw new Error(`Matrix homeserver is required for account ${accountId ?? "default"} (matrix.homeserver)`);
}
const normalizedAccountId = normalizeAccountId(accountId);
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
// Only use cached credentials for default account
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = isDefaultAccount ? loadMatrixCredentials(env) : null;
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Only save credentials for default account
if (isDefaultAccount) {
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
}
} else if (isDefaultAccount && cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
// Try cached credentials (only for default account)
if (isDefaultAccount && cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
`Matrix userId is required for account ${accountId ?? "default"} when no access token is configured`,
);
}
if (!resolved.password) {
throw new Error(
`Matrix password is required for account ${accountId ?? "default"} when no access token is configured`,
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed for account ${accountId ?? "default"}: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error(`Matrix login did not return an access token for account ${accountId ?? "default"}`);
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
// Only save credentials for default account
if (isDefaultAccount) {
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
}
return auth;
}

View file

@ -0,0 +1,127 @@
import fs from "node:fs";
import {
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "@vector-im/matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(
storagePaths.cryptoPath,
StoreType.Sqlite,
);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId,
});
const client = new MatrixClient(
params.homeserver,
params.accessToken,
storage,
cryptoStorage,
);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
}

View file

@ -0,0 +1,35 @@
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
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,4 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}

277
src/matrix/client/shared.ts Normal file
View file

@ -0,0 +1,277 @@
import { LogService } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js";
import { resolveMatrixAuth } from "./config.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
};
// Multi-account support: Map of accountKey -> client state
const sharedClients = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
const sharedClientStartPromises = new Map<string, Promise<void>>();
// Legacy single-client references (for backwards compatibility)
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("|");
}
function getAccountKey(accountId?: string | null): string {
return accountId ?? DEFAULT_ACCOUNT_KEY;
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
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;
accountId?: string | null;
}): Promise<void> {
if (params.state.started) return;
const accountKey = getAccountKey(params.accountId);
const existingPromise = sharedClientStartPromises.get(accountKey);
if (existingPromise) {
await existingPromise;
return;
}
// Legacy compatibility
if (sharedClientStartPromise && !params.accountId) {
await sharedClientStartPromise;
return;
}
const startPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
sharedClientStartPromises.set(accountKey, startPromise);
if (!params.accountId) {
sharedClientStartPromise = startPromise;
}
try {
await startPromise;
} finally {
sharedClientStartPromises.delete(accountKey);
if (!params.accountId) {
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, accountId: params.accountId ?? undefined }));
const key = buildSharedClientKey(auth, params.accountId);
const accountKey = getAccountKey(params.accountId);
const shouldStart = params.startClient !== false;
// Check if we already have this client in the multi-account map
const existingClient = sharedClients.get(accountKey);
if (existingClient?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: existingClient,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
accountId: params.accountId,
});
}
// Update legacy reference for default account
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
sharedClientState = existingClient;
}
return existingClient.client;
}
// Legacy compatibility: check old single-client state
if (!params.accountId && sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
accountId: params.accountId,
});
}
return sharedClientState.client;
}
// Check for pending creation promise for this account
const pendingPromise = sharedClientPromises.get(accountKey);
if (pendingPromise) {
const pending = await pendingPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
accountId: params.accountId,
});
}
return pending.client;
}
// Key mismatch - stop old client
pending.client.stop();
sharedClients.delete(accountKey);
}
// Legacy: check old single-client promise
if (!params.accountId && sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
accountId: params.accountId,
});
}
return pending.client;
}
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
// Create new client
const createPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId ?? undefined,
});
sharedClientPromises.set(accountKey, createPromise);
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
sharedClientPromise = createPromise;
}
try {
const created = await createPromise;
sharedClients.set(accountKey, created);
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
sharedClientState = created;
}
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
accountId: params.accountId,
});
}
return created.client;
} finally {
sharedClientPromises.delete(accountKey);
if (!params.accountId || params.accountId === DEFAULT_ACCOUNT_KEY) {
sharedClientPromise = null;
}
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// @vector-im/matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(accountId?: string | null): void {
if (accountId) {
// Stop specific account
const accountKey = getAccountKey(accountId);
const client = sharedClients.get(accountKey);
if (client) {
client.client.stop();
sharedClients.delete(accountKey);
}
// Also clear legacy reference if it matches
if (sharedClientState?.key === client?.key) {
sharedClientState = null;
}
} else {
// Stop all clients (legacy behavior + all multi-account clients)
for (const [key, client] of sharedClients) {
client.client.stop();
sharedClients.delete(key);
}
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}
}

View file

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

103
src/matrix/credentials.ts Normal file
View file

@ -0,0 +1,103 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
const CREDENTIALS_FILENAME = "credentials.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): string {
const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, CREDENTIALS_FILENAME);
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env);
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,
): void {
const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true });
const credPath = resolveMatrixCredentialsPath(env);
const existing = loadMatrixCredentials(env);
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): void {
const existing = loadMatrixCredentials(env);
if (!existing) return;
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env);
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
}
export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
const credPath = resolveMatrixCredentialsPath(env);
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;
}

57
src/matrix/deps.ts Normal file
View file

@ -0,0 +1,57 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
const req = createRequire(import.meta.url);
req.resolve(MATRIX_SDK_PACKAGE);
return true;
} catch {
return false;
}
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
}
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 @vector-im/matrix-bot-sdk. Install now?");
if (!ok) {
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (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 getMatrixRuntime().system.runCommandWithTimeout(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()) {
throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
}
}

34
src/matrix/format.test.ts Normal file
View file

@ -0,0 +1,34 @@
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");
});
});

22
src/matrix/format.ts Normal file
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();
}

11
src/matrix/index.ts Normal file
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,58 @@
import type { AllowlistMatch } from "openclaw/plugin-sdk";
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
export function normalizeAllowListLower(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
}
function normalizeMatrixUser(raw?: string | null): string {
return (raw ?? "").trim().toLowerCase();
}
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart"
>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
userName?: 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 userName = normalizeMatrixUser(params.userName);
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
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" },
{ value: userName, source: "name" },
{ value: localPart, source: "localpart" },
];
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;
userName?: string;
}) {
return resolveMatrixAllowListMatch(params).allowed;
}

View file

@ -0,0 +1,68 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.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 = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;
}
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState?.alias;
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(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,105 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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,103 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { MatrixAuth } from "../client.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,645 @@
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixContext,
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 { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js";
import {
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
normalizeAllowListLower,
} from "./allowlist.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 { resolveMatrixLocation, type MatrixLocationPayload } from "./location.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 messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
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 = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeAllowListLower([
...groupAllowFrom,
...storeAllowFrom,
]);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") return;
if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
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: normalizeAllowListLower(roomUsers),
userId: senderId,
userName: senderName,
});
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,
userName: senderName,
});
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,
userName: senderName,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
userName: senderName,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeAllowListLower(roomUsers),
userId: senderId,
userName: senderName,
})
: 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, // @vector-im/matrix-bot-sdk doesn't have this info readily available
});
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 prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
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({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
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: prefixContext.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)}`);
}
};
}

279
src/matrix/monitor/index.ts Normal file
View file

@ -0,0 +1,279 @@
import { format } from "node:util";
import {
mergeAllowlist,
summarizeMapping,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClient,
} from "../client.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";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.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 allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
if (allowFrom.length > 0) {
const entries = allowFrom
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(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) {
additions.push(entry.id);
mapping.push(`${entry.input}${entry.id}`);
} else {
unresolved.push(entry.input);
}
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("matrix users", mapping, unresolved, runtime);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const entries = Object.keys(roomsConfig).filter((key) => key !== "*");
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms = { ...roomsConfig };
const pending: Array<{ input: string; query: string }> = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed) continue;
const cleaned = normalizeRoomEntry(trimmed);
if (cleaned.startsWith("!") && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomsConfig[entry];
}
mapping.push(`${entry}${cleaned}`);
continue;
}
pending.push({ input: entry, query: trimmed });
}
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] = roomsConfig[source.input];
}
mapping.push(`${source.input}${entry.id}`);
} else {
unresolved.push(source.input);
}
});
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
}
cfg = {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
dm: {
...cfg.channels?.matrix?.dm,
allowFrom,
},
...(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);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off";
const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound";
const dmConfig = cfg.channels?.matrix?.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 ?? cfg.channels?.matrix?.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,
});
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");
// @vector-im/matrix-bot-sdk 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.requestOwnUserVerification();
if (verificationRequest) {
logger.info("matrix: device verification requested - please verify in another client");
}
} catch (err) {
logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)");
}
}
await new Promise<void>((resolve) => {
const onAbort = () => {
try {
logVerboseMessage(`matrix: stopping client for account ${opts.accountId ?? "default"}`);
stopSharedClient(opts.accountId);
} finally {
setActiveMatrixClient(null);
resolve();
}
};
if (opts.abortSignal?.aborted) {
onAbort();
return;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}

View file

@ -0,0 +1,83 @@
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk";
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,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "openclaw/plugin-sdk";
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("@vector-im/matrix-bot-sdk").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("@vector-im/matrix-bot-sdk").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();
});
});

113
src/matrix/monitor/media.ts Normal file
View file

@ -0,0 +1,113 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.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> {
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
// Use the client's download method which handles auth
try {
const buffer = await params.client.downloadContent(params.mxcUrl);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: Buffer.from(buffer) };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`);
}
}
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
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);
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,31 @@
import { getMatrixRuntime } from "../../runtime.js";
// Type for room message content with mentions
type MessageContentWithMentions = {
msgtype: string;
body: string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
};
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>();
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View file

@ -0,0 +1,96 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.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);
if (mediaList.length === 0) {
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: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
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: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
}
first = false;
}
}
}

View file

@ -0,0 +1,58 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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,43 @@
import type { MatrixRoomConfig } from "../../types.js";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk";
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,
params.name ?? "",
);
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,64 @@
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
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,39 @@
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
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 MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
};
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,22 @@
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"]);
});
});

157
src/matrix/poll-types.ts Normal file
View file

@ -0,0 +1,157 @@
/**
* 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.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 maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return {
[M_POLL_START]: {
question: buildTextContent(question),
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}

70
src/matrix/probe.ts Normal file
View file

@ -0,0 +1,70 @@
import { createMatrixClient, isBunRuntime } from "./client.js";
export type MatrixProbe = {
ok: boolean;
status?: number | null;
error?: string | 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 client = await createMatrixClient({
homeserver: params.homeserver,
userId: params.userId ?? "",
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
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,
};
}
}

172
src/matrix/send.test.ts Normal file
View file

@ -0,0 +1,172 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { setMatrixRuntime } from "../runtime.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();
},
LogService: {
setLogger: vi.fn(),
},
MatrixClient: vi.fn(),
SimpleFsStorageProvider: vi.fn(),
RustSdkCryptoStorageProvider: vi.fn(),
}));
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("@vector-im/matrix-bot-sdk").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" },
});
});
});

255
src/matrix/send.ts Normal file
View file

@ -0,0 +1,255 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.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,
});
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) => {
// @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
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,
});
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;
// @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
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();
}
}
}

63
src/matrix/send/client.ts Normal file
View file

@ -0,0 +1,63 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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";
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,
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.
}
}
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}

View file

@ -0,0 +1,92 @@
import { markdownToMatrixHtml } from "../format.js";
import { getMatrixRuntime } from "../../runtime.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 (
getCore().media.isVoiceCompatibleAudio({
contentType: opts.contentType,
fileName: opts.fileName,
})
) {
return { useVoice: true };
}
return { useVoice: false };
}

220
src/matrix/send/media.ts Normal file
View file

@ -0,0 +1,220 @@
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
import {
type MatrixMediaContent,
type MatrixMediaInfo,
type MatrixMediaMsgType,
type MatrixRelation,
type MediaKind,
} from "./types.js";
import { applyMatrixFormatting } from "./formatting.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,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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");
expect(client.getJoinedRooms).not.toHaveBeenCalled();
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");
});
});

144
src/matrix/send/targets.ts Normal file
View file

@ -0,0 +1,144 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
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;
}
const directRoomCache = new Map<string, string>();
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
} 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 MatrixDirectAccountData | null;
const list = Array.isArray(directContent?.[trimmed])
? directContent[trimmed]
: [];
if (list.length > 0) {
directRoomCache.set(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) {
directRoomCache.set(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
directRoomCache.set(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;
}

109
src/matrix/send/types.ts Normal file
View file

@ -0,0 +1,109 @@
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MessageEventContent,
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
// 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("@vector-im/matrix-bot-sdk").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;
};

432
src/onboarding.ts Normal file
View file

@ -0,0 +1,432 @@
import {
addWildcardAllowFrom,
formatDocsLink,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { CoreConfig, DmPolicy } 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) or a password (logs in and stores a token).",
"With access token: user ID is fetched automatically.",
"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 (username or user id)",
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[] = [];
let unresolved: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
resolvedIds.push(part);
continue;
}
if (!canResolve) {
unresolved.push(part);
continue;
}
const results = await listMatrixDirectoryPeersLive({
cfg,
query: part,
limit: 5,
}).catch(() => []);
const match = results.find((result) => result.id);
if (match?.id) {
resolvedIds.push(match.id);
if (results.length > 1) {
await prompter.note(
`Multiple matches for "${part}", using ${match.id}.`,
"Matrix allowlist",
);
}
} else {
unresolved.push(part);
}
}
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
"Matrix allowlist",
);
continue;
}
const unique = [
...new Set([
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
...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 @vector-im/matrix-bot-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 ?? "";
if (accessToken || password) {
const keep = await prompter.confirm({
message: "Matrix credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
accessToken = "";
password = "";
userId = "";
}
}
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)" },
],
})) as "token" | "password";
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 = "";
} else {
// Password auth requires 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();
}
}
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,
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 },
},
}),
};

53
src/outbound.ts Normal file
View file

@ -0,0 +1,53 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "./runtime.js";
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.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 }) => {
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,
});
return {
channel: "matrix",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId }) => {
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,
});
return {
channel: "matrix",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendPoll: async ({ to, poll, threadId }) => {
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await sendPollMatrix(to, poll, {
threadId: resolvedThreadId,
});
return {
channel: "matrix",
messageId: result.eventId,
roomId: result.roomId,
pollId: result.eventId,
};
},
};

89
src/resolve-targets.ts Normal file
View file

@ -0,0 +1,89 @@
import type {
ChannelDirectoryEntry,
ChannelResolveKind,
ChannelResolveResult,
RuntimeEnv,
} from "openclaw/plugin-sdk";
import {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
} from "./directory-live.js";
function pickBestGroupMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) return undefined;
const normalized = query.trim().toLowerCase();
if (normalized) {
const exact = matches.find((match) => {
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
const id = match.id.trim().toLowerCase();
return name === normalized || handle === normalized || id === normalized;
});
if (exact) return exact;
}
return matches[0];
}
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 = matches[0];
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" });
}
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;
}

14
src/runtime.ts Normal file
View file

@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setMatrixRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMatrixRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Matrix runtime not initialized");
}
return runtime;
}

160
src/tool-actions.ts Normal file
View file

@ -0,0 +1,160 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { CoreConfig } from "./types.js";
import {
deleteMatrixMessage,
editMatrixMessage,
getMatrixMemberInfo,
getMatrixRoomInfo,
listMatrixPins,
listMatrixReactions,
pinMatrixMessage,
readMatrixMessages,
removeMatrixReactions,
sendMatrixMessage,
unpinMatrixMessage,
} from "./matrix/actions.js";
import { reactMatrixMessage } from "./matrix/send.js";
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "openclaw/plugin-sdk";
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
const reactionActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
function readRoomId(params: Record<string, unknown>, required = true): string {
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
if (direct) return direct;
if (!required) return readStringParam(params, "to") ?? "";
return readStringParam(params, "to", { required: true });
}
export async function handleMatrixAction(
params: Record<string, unknown>,
cfg: CoreConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions);
if (reactionActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Matrix reactions are disabled.");
}
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Matrix reaction.",
});
if (remove || isEmpty) {
const result = await removeMatrixReactions(roomId, messageId, {
emoji: remove ? emoji : undefined,
});
return jsonResult({ ok: true, removed: result.removed });
}
await reactMatrixMessage(roomId, messageId, emoji);
return jsonResult({ ok: true, added: emoji });
}
const reactions = await listMatrixReactions(roomId, messageId);
return jsonResult({ ok: true, reactions });
}
if (messageActions.has(action)) {
if (!isActionEnabled("messages")) {
throw new Error("Matrix messages are disabled.");
}
switch (action) {
case "sendMessage": {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyToId = readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const result = await sendMatrixMessage(to, content, {
mediaUrl: mediaUrl ?? undefined,
replyToId: replyToId ?? undefined,
threadId: threadId ?? undefined,
});
return jsonResult({ ok: true, result });
}
case "editMessage": {
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "content", { required: true });
const result = await editMatrixMessage(roomId, messageId, content);
return jsonResult({ ok: true, result });
}
case "deleteMessage": {
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const reason = readStringParam(params, "reason");
await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined });
return jsonResult({ ok: true, deleted: true });
}
case "readMessages": {
const roomId = readRoomId(params);
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readMatrixMessages(roomId, {
limit: limit ?? undefined,
before: before ?? undefined,
after: after ?? undefined,
});
return jsonResult({ ok: true, ...result });
}
default:
break;
}
}
if (pinActions.has(action)) {
if (!isActionEnabled("pins")) {
throw new Error("Matrix pins are disabled.");
}
const roomId = readRoomId(params);
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await pinMatrixMessage(roomId, messageId);
return jsonResult({ ok: true, pinned: result.pinned });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await unpinMatrixMessage(roomId, messageId);
return jsonResult({ ok: true, pinned: result.pinned });
}
const result = await listMatrixPins(roomId);
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
}
if (action === "memberInfo") {
if (!isActionEnabled("memberInfo")) {
throw new Error("Matrix member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
const result = await getMatrixMemberInfo(userId, {
roomId: roomId ?? undefined,
});
return jsonResult({ ok: true, member: result });
}
if (action === "channelInfo") {
if (!isActionEnabled("channelInfo")) {
throw new Error("Matrix room info is disabled.");
}
const roomId = readRoomId(params);
const result = await getMatrixRoomInfo(roomId);
return jsonResult({ ok: true, room: result });
}
throw new Error(`Unsupported Matrix action: ${action}`);
}

108
src/types.ts Normal file
View file

@ -0,0 +1,108 @@
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type MatrixDmConfig = {
/** If false, ignore all incoming Matrix DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */
allowFrom?: Array<string | number>;
};
export type MatrixRoomConfig = {
/** If false, disable the bot in this room (alias for allow: false). */
enabled?: boolean;
/** Legacy room allow toggle; prefer enabled. */
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Optional tool policy overrides for this room. */
tools?: { allow?: string[]; deny?: string[] };
/** If true, reply without mention requirements. */
autoReply?: boolean;
/** Optional allowlist for room senders (user IDs or localparts). */
users?: Array<string | number>;
/** Optional skill filter for this room. */
skills?: string[];
/** Optional system prompt snippet for this room. */
systemPrompt?: string;
};
export type MatrixActionConfig = {
reactions?: boolean;
messages?: boolean;
pins?: boolean;
memberInfo?: boolean;
channelInfo?: boolean;
};
export type MatrixAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start this account. Default: true. */
enabled?: boolean;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
/** Matrix user id (@user:server). */
userId?: string;
/** Matrix access token. */
accessToken?: string;
/** Matrix password (used only to fetch access token). */
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
/** If true, enforce allowlists for groups + DMs regardless of policy. */
allowlistOnly?: boolean;
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Allowlist for group senders (user IDs or localparts). */
groupAllowFrom?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/** How to handle thread replies (off|inbound|always). */
threadReplies?: "off" | "inbound" | "always";
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */
autoJoin?: "always" | "allowlist" | "off";
/** Allowlist for auto-join invites (room IDs, aliases). */
autoJoinAllowlist?: Array<string | number>;
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** Room config allowlist keyed by room ID, alias, or name. */
groups?: Record<string, MatrixRoomConfig>;
/** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
rooms?: Record<string, MatrixRoomConfig>;
/** Per-action tool gating (default: true for all). */
actions?: MatrixActionConfig;
};
export type MatrixConfig = {
/** Optional per-account Matrix configuration (multi-account). */
accounts?: Record<string, MatrixAccountConfig>;
} & MatrixAccountConfig;
export type CoreConfig = {
channels?: {
matrix?: MatrixConfig;
};
bindings?: Array<{
agentId?: string;
match?: {
channel?: string;
accountId?: string;
peer?: { kind?: string; id?: string };
};
}>;
[key: string]: unknown;
};

14
tsconfig.check.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}