Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
38
openclaw/extensions/matrix/CHANGELOG.md
Normal file
38
openclaw/extensions/matrix/CHANGELOG.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.26
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.25
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Features
|
||||
|
||||
- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name).
|
||||
- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support.
|
||||
- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts.
|
||||
- Threads: replyToMode controls and thread replies (off/inbound/always).
|
||||
- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes.
|
||||
- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info.
|
||||
- Auto-join invites with allowlist support.
|
||||
- Status + probe reporting for health checks.
|
||||
17
openclaw/extensions/matrix/index.ts
Normal file
17
openclaw/extensions/matrix/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setMatrixRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: matrixPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
openclaw/extensions/matrix/openclaw.plugin.json
Normal file
9
openclaw/extensions/matrix/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "matrix",
|
||||
"channels": ["matrix"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
33
openclaw/extensions/matrix/package.json
Normal file
33
openclaw/extensions/matrix/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.26",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
|
||||
"markdown-it": "14.1.1",
|
||||
"music-metadata": "^11.12.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
195
openclaw/extensions/matrix/src/actions.ts
Normal file
195
openclaw/extensions/matrix/src/actions.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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 as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
roomId: resolveRoomId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "channelInfo",
|
||||
roomId: resolveRoomId(),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider matrix.`);
|
||||
},
|
||||
};
|
||||
154
openclaw/extensions/matrix/src/channel.directory.test.ts
Normal file
154
openclaw/extensions/matrix/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
MatrixClient: class {},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
SimpleFsStorageProvider: class {},
|
||||
RustSdkCryptoStorageProvider: class {},
|
||||
}));
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
|
||||
groupAllowFrom: ["@dana:example.org"],
|
||||
groups: {
|
||||
"!room1:example.org": { users: ["@carol:example.org"] },
|
||||
"#alias:example.org": { users: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.directory).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listPeers!({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "user:@alice:example.org" },
|
||||
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
|
||||
{ kind: "user", id: "user:@carol:example.org" },
|
||||
{ kind: "user", id: "user:@dana:example.org" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
matrixPlugin.directory!.listGroups!({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "room:!room1:example.org" },
|
||||
{ kind: "group", id: "#alias:example.org" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves replyToMode from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
replyToMode: "off",
|
||||
accounts: {
|
||||
Assistant: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("resolves group mention policy from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
accounts: {
|
||||
Assistant: {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
groupId: "!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
486
openclaw/extensions/matrix/src/channel.ts
Normal file
486
openclaw/extensions/matrix/src/channel.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import {
|
||||
resolveMatrixGroupRequireMention,
|
||||
resolveMatrixGroupToolPolicy,
|
||||
} from "./group-mentions.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveMatrixAccountConfig,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
let matrixStartupLock: Promise<void> = Promise.resolve();
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
selectionLabel: "Matrix (plugin)",
|
||||
docsPath: "/channels/matrix",
|
||||
docsLabel: "matrix",
|
||||
blurb: "open protocol; configure a homeserver + access token.",
|
||||
order: 70,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||
let normalized = raw.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const lowered = normalized.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
normalized = normalized.slice("matrix:".length).trim();
|
||||
}
|
||||
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
|
||||
return stripped || undefined;
|
||||
}
|
||||
|
||||
function buildMatrixConfigUpdate(
|
||||
cfg: CoreConfig,
|
||||
input: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
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, accountId }) => {
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
|
||||
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ account }) => {
|
||||
const accountId = account.accountId;
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.matrix.accounts.${accountId}.dm`
|
||||
: "channels.matrix.dm";
|
||||
return {
|
||||
policy: account.config.dm?.policy ?? "pairing",
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
policyPath: `${prefix}.policy`,
|
||||
allowFromPath: `${prefix}.allowFrom`,
|
||||
approveHint: formatPairingApproveHint("matrix"),
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
|
||||
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
|
||||
];
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const currentTarget = context.To;
|
||||
return {
|
||||
currentChannelId: currentTarget?.trim() || undefined,
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return trimmed.includes(":");
|
||||
},
|
||||
hint: "<room|alias|user>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
for (const room of Object.values(groups)) {
|
||||
for (const entry of room.users ?? []) {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw || raw === "*") {
|
||||
continue;
|
||||
}
|
||||
ids.add(raw.replace(/^matrix:/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||
if (cleaned.startsWith("@")) {
|
||||
return `user:${cleaned}`;
|
||||
}
|
||||
return cleaned;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => {
|
||||
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
|
||||
const incomplete = !raw.startsWith("@") || !raw.includes(":");
|
||||
return {
|
||||
kind: "user",
|
||||
id,
|
||||
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const groups = account.config.groups ?? account.config.rooms ?? {};
|
||||
const ids = Object.keys(groups)
|
||||
.map((raw) => raw.trim())
|
||||
.filter((raw) => Boolean(raw) && raw !== "*")
|
||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||
.map((raw) => {
|
||||
const lowered = raw.toLowerCase();
|
||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith("!")) {
|
||||
return `room:${raw}`;
|
||||
}
|
||||
return raw;
|
||||
})
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
listPeersLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
|
||||
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
|
||||
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (input.useEnv) {
|
||||
return null;
|
||||
}
|
||||
if (!input.homeserver?.trim()) {
|
||||
return "Matrix requires --homeserver";
|
||||
}
|
||||
const accessToken = input.accessToken?.trim();
|
||||
const password = input.password?.trim();
|
||||
const userId = input.userId?.trim();
|
||||
if (!accessToken && !password) {
|
||||
return "Matrix requires --access-token or --password";
|
||||
}
|
||||
if (!accessToken) {
|
||||
if (!userId) {
|
||||
return "Matrix requires --user-id when using --password";
|
||||
}
|
||||
if (!password) {
|
||||
return "Matrix requires --password when using --user-id";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "matrix",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
name: input.name,
|
||||
});
|
||||
if (input.useEnv) {
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
matrix: {
|
||||
...namedConfig.channels?.matrix,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
return buildMatrixConfigUpdate(namedConfig as CoreConfig, {
|
||||
homeserver: input.homeserver?.trim(),
|
||||
userId: input.userId?.trim(),
|
||||
accessToken: input.accessToken?.trim(),
|
||||
password: input.password?.trim(),
|
||||
deviceName: input.deviceName?.trim(),
|
||||
initialSyncLimit: input.initialSyncLimit,
|
||||
});
|
||||
},
|
||||
},
|
||||
outbound: matrixOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||
if (!lastError) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: "matrix",
|
||||
accountId: account.accountId,
|
||||
kind: "runtime",
|
||||
message: `Channel error: ${lastError}`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
baseUrl: snapshot.baseUrl ?? null,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
||||
try {
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return await probeMatrix({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.homeserver,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastProbeAt: runtime?.lastProbeAt ?? null,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
baseUrl: account.homeserver,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
|
||||
|
||||
// Serialize startup: wait for any previous startup to complete import phase.
|
||||
// This works around a race condition with concurrent dynamic imports.
|
||||
//
|
||||
// INVARIANT: The import() below cannot hang because:
|
||||
// 1. It only loads local ESM modules with no circular awaits
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
|
||||
// 3. The lock only serializes the import phase, not the provider startup
|
||||
const previousLock = matrixStartupLock;
|
||||
let releaseLock: () => void = () => {};
|
||||
matrixStartupLock = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
await previousLock;
|
||||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
// Wrap in try/finally to ensure lock is released even if import fails.
|
||||
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
|
||||
try {
|
||||
const module = await import("./matrix/index.js");
|
||||
monitorMatrixProvider = module.monitorMatrixProvider;
|
||||
} finally {
|
||||
// Release lock after import completes or fails
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
return monitorMatrixProvider({
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
63
openclaw/extensions/matrix/src/config-schema.ts
Normal file
63
openclaw/extensions/matrix/src/config-schema.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
dm: matrixDmSchema,
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
});
|
||||
74
openclaw/extensions/matrix/src/directory-live.test.ts
Normal file
74
openclaw/extensions/matrix/src/directory-live.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("matrix directory live", () => {
|
||||
const cfg = { channels: { matrix: {} } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveMatrixAuth).mockReset();
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [] }),
|
||||
text: async () => "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("passes accountId to peer directory auth resolution", async () => {
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "alice",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("passes accountId to group directory auth resolution", async () => {
|
||||
await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "!room:example.org",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("returns no peer results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: " ",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no group results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
208
openclaw/extensions/matrix/src/directory-live.ts
Normal file
208
openclaw/extensions/matrix/src/directory-live.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
type MatrixUserResult = {
|
||||
user_id?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
|
||||
type MatrixUserDirectoryResponse = {
|
||||
results?: MatrixUserResult[];
|
||||
};
|
||||
|
||||
type MatrixJoinedRoomsResponse = {
|
||||
joined_rooms?: string[];
|
||||
};
|
||||
|
||||
type MatrixRoomNameState = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type MatrixAliasLookup = {
|
||||
room_id?: string;
|
||||
};
|
||||
|
||||
type MatrixDirectoryLiveParams = {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
};
|
||||
|
||||
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
|
||||
|
||||
async function fetchMatrixJson<T>(params: {
|
||||
homeserver: string;
|
||||
path: string;
|
||||
accessToken: string;
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
}): Promise<T> {
|
||||
const res = await fetch(`${params.homeserver}${params.path}`, {
|
||||
method: params.method ?? "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: params.body ? JSON.stringify(params.body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function resolveMatrixDirectoryLimit(limit?: number | null): number {
|
||||
return typeof limit === "number" && limit > 0 ? limit : 20;
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
|
||||
const query = normalizeQuery(params.query);
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
|
||||
return { query, auth };
|
||||
}
|
||||
|
||||
function createGroupDirectoryEntry(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
handle?: string;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
handle: params.handle,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const context = await resolveMatrixDirectoryContext(params);
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { query, auth } = context;
|
||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/user_directory/search",
|
||||
method: "POST",
|
||||
body: {
|
||||
search_term: query,
|
||||
limit: resolveMatrixDirectoryLimit(params.limit),
|
||||
},
|
||||
});
|
||||
const results = res.results ?? [];
|
||||
return results
|
||||
.map((entry) => {
|
||||
const userId = entry.user_id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "user",
|
||||
id: userId,
|
||||
name: entry.display_name?.trim() || undefined,
|
||||
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
|
||||
raw: entry,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
})
|
||||
.filter(Boolean) as ChannelDirectoryEntry[];
|
||||
}
|
||||
|
||||
async function resolveMatrixRoomAlias(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
alias: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixAliasLookup>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
});
|
||||
return res.room_id?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatrixRoomName(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
roomId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixRoomNameState>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
});
|
||||
return res.name?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryGroupsLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const context = await resolveMatrixDirectoryContext(params);
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { query, auth } = context;
|
||||
const limit = resolveMatrixDirectoryLimit(params.limit);
|
||||
|
||||
if (query.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||
if (!roomId) {
|
||||
return [];
|
||||
}
|
||||
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
|
||||
}
|
||||
|
||||
if (query.startsWith("!")) {
|
||||
return [createGroupDirectoryEntry({ id: query, name: query })];
|
||||
}
|
||||
|
||||
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/joined_rooms",
|
||||
});
|
||||
const rooms = joined.joined_rooms ?? [];
|
||||
const results: ChannelDirectoryEntry[] = [];
|
||||
|
||||
for (const roomId of rooms) {
|
||||
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
if (!name.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
name,
|
||||
handle: `#${name}`,
|
||||
});
|
||||
if (results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
52
openclaw/extensions/matrix/src/group-mentions.ts
Normal file
52
openclaw/extensions/matrix/src/group-mentions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
|
||||
return value.toLowerCase().startsWith(prefix.toLowerCase())
|
||||
? value.slice(prefix.length).trim()
|
||||
: value;
|
||||
}
|
||||
|
||||
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
|
||||
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
|
||||
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
return resolveMatrixRoomConfig({
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) {
|
||||
return false;
|
||||
}
|
||||
if (resolved.autoReply === false) {
|
||||
return true;
|
||||
}
|
||||
if (typeof resolved.requireMention === "boolean") {
|
||||
return resolved.requireMention;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const resolved = resolveMatrixRoomConfigForGroup(params);
|
||||
return resolved?.tools;
|
||||
}
|
||||
82
openclaw/extensions/matrix/src/matrix/accounts.test.ts
Normal file
82
openclaw/extensions/matrix/src/matrix/accounts.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAccount } from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
const envKeys = [
|
||||
"MATRIX_HOMESERVER",
|
||||
"MATRIX_USER_ID",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_DEVICE_NAME",
|
||||
];
|
||||
|
||||
describe("resolveMatrixAccount", () => {
|
||||
let prevEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
prevEnv = {};
|
||||
for (const key of envKeys) {
|
||||
prevEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = prevEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("treats access-token-only config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-access",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("requires userId + password when no access token is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("marks password auth as configured when userId is present", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
137
openclaw/extensions/matrix/src/matrix/accounts.ts
Normal file
137
openclaw/extensions/matrix/src/matrix/accounts.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { resolveMatrixConfigForAccount } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
/** Merge account config with top-level defaults, preserving nested objects. */
|
||||
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
|
||||
const merged = { ...base, ...account };
|
||||
// Deep-merge known nested objects so partial overrides inherit base fields
|
||||
for (const key of ["dm", "actions"] as const) {
|
||||
const b = base[key];
|
||||
const o = account[key];
|
||||
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
|
||||
(merged as Record<string, unknown>)[key] = { ...b, ...o };
|
||||
}
|
||||
}
|
||||
// Don't propagate the accounts map into the merged per-account config
|
||||
delete (merged as Record<string, unknown>).accounts;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export type ResolvedMatrixAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
config: MatrixConfig;
|
||||
};
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return [];
|
||||
}
|
||||
// Normalize and de-duplicate keys so listing and resolution use the same semantics
|
||||
return [
|
||||
...new Set(
|
||||
Object.keys(accounts)
|
||||
.filter(Boolean)
|
||||
.map((id) => normalizeAccountId(id)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) {
|
||||
// Fall back to default if no accounts configured (legacy top-level config)
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
const ids = listMatrixAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
// Direct lookup first (fast path for already-normalized keys)
|
||||
if (accounts[accountId]) {
|
||||
return accounts[accountId] as MatrixConfig;
|
||||
}
|
||||
// Fall back to case-insensitive match (user may have mixed-case keys in config)
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalized) {
|
||||
return accounts[key] as MatrixConfig;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedMatrixAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
|
||||
const enabled = base.enabled !== false && matrixBase.enabled !== false;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
|
||||
const hasHomeserver = Boolean(resolved.homeserver);
|
||||
const hasUserId = Boolean(resolved.userId);
|
||||
const hasAccessToken = Boolean(resolved.accessToken);
|
||||
const hasPassword = Boolean(resolved.password);
|
||||
const hasPasswordAuth = hasUserId && hasPassword;
|
||||
const stored = loadMatrixCredentials(process.env, accountId);
|
||||
const hasStored =
|
||||
stored && resolved.homeserver
|
||||
? credentialsMatchConfig(stored, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
: false;
|
||||
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: base.name?.trim() || undefined,
|
||||
configured,
|
||||
homeserver: resolved.homeserver || undefined,
|
||||
userId: resolved.userId || undefined,
|
||||
config: base,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): MatrixConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
const accountConfig = resolveAccountConfig(params.cfg, accountId);
|
||||
if (!accountConfig) {
|
||||
return matrixBase;
|
||||
}
|
||||
// Merge account-specific config with top-level defaults so settings like
|
||||
// groupPolicy and blockStreaming inherit when not overridden.
|
||||
return mergeAccountConfig(matrixBase, accountConfig);
|
||||
}
|
||||
|
||||
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
||||
return listMatrixAccountIds(cfg)
|
||||
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
15
openclaw/extensions/matrix/src/matrix/actions.ts
Normal file
15
openclaw/extensions/matrix/src/matrix/actions.ts
Normal 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";
|
||||
47
openclaw/extensions/matrix/src/matrix/actions/client.ts
Normal file
47
openclaw/extensions/matrix/src/matrix/actions/client.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||
import { 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 };
|
||||
}
|
||||
// Normalize accountId early to ensure consistent keying across all lookups
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
const active = getActiveMatrixClient(accountId);
|
||||
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,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId,
|
||||
});
|
||||
const client = await createPreparedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
15
openclaw/extensions/matrix/src/matrix/actions/limits.test.ts
Normal file
15
openclaw/extensions/matrix/src/matrix/actions/limits.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
|
||||
describe("resolveMatrixActionLimit", () => {
|
||||
it("uses fallback for non-finite values", () => {
|
||||
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
|
||||
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("normalizes finite numbers to positive integers", () => {
|
||||
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
|
||||
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
|
||||
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
|
||||
});
|
||||
});
|
||||
6
openclaw/extensions/matrix/src/matrix/actions/limits.ts
Normal file
6
openclaw/extensions/matrix/src/matrix/actions/limits.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(1, Math.floor(raw));
|
||||
}
|
||||
126
openclaw/extensions/matrix/src/matrix/actions/messages.ts
Normal file
126
openclaw/extensions/matrix/src/matrix/actions/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
mediaUrl: opts.mediaUrl,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix edit requires content");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
} satisfies RoomMessageEventContent;
|
||||
const payload: RoomMessageEventContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: `* ${trimmed}`,
|
||||
"m.new_content": newContent,
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Replace,
|
||||
event_id: messageId,
|
||||
},
|
||||
};
|
||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: eventId ?? null };
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 20);
|
||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||
const dir = opts.after ? "f" : "b";
|
||||
// @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();
|
||||
}
|
||||
}
|
||||
}
|
||||
74
openclaw/extensions/matrix/src/matrix/actions/pins.test.ts
Normal file
74
openclaw/extensions/matrix/src/matrix/actions/pins.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
|
||||
|
||||
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
|
||||
let pinned = [...seedPinned];
|
||||
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
|
||||
const sendStateEvent = vi.fn(
|
||||
async (_roomId: string, _type: string, _key: string, payload: any) => {
|
||||
pinned = [...payload.pinned];
|
||||
},
|
||||
);
|
||||
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
|
||||
const body = knownBodies[eventId];
|
||||
if (!body) {
|
||||
throw new Error("missing");
|
||||
}
|
||||
return {
|
||||
event_id: eventId,
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: { msgtype: "m.text", body },
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
client: {
|
||||
getRoomStateEvent,
|
||||
sendStateEvent,
|
||||
getEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getPinned: () => pinned,
|
||||
sendStateEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix pins actions", () => {
|
||||
it("pins a message once even when asked twice", async () => {
|
||||
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
|
||||
|
||||
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
|
||||
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
|
||||
|
||||
expect(first.pinned).toEqual(["$a", "$b"]);
|
||||
expect(second.pinned).toEqual(["$a", "$b"]);
|
||||
expect(getPinned()).toEqual(["$a", "$b"]);
|
||||
expect(sendStateEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("unpinds only the selected message id", async () => {
|
||||
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
|
||||
|
||||
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
|
||||
|
||||
expect(result.pinned).toEqual(["$a", "$c"]);
|
||||
expect(getPinned()).toEqual(["$a", "$c"]);
|
||||
});
|
||||
|
||||
it("lists pinned ids and summarizes only resolvable events", async () => {
|
||||
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
|
||||
|
||||
const result = await listMatrixPins("!room:example.org", { client });
|
||||
|
||||
expect(result.pinned).toEqual(["$a", "$missing"]);
|
||||
expect(result.events).toEqual([
|
||||
expect.objectContaining({
|
||||
eventId: "$a",
|
||||
body: "hello",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
84
openclaw/extensions/matrix/src/matrix/actions/pins.ts
Normal file
84
openclaw/extensions/matrix/src/matrix/actions/pins.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixActionClient,
|
||||
type MatrixMessageSummary,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
type ActionClient = MatrixActionClient["client"];
|
||||
|
||||
async function withResolvedPinRoom<T>(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await run(client, resolvedRoom);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMatrixPins(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
update: (current: string[]) => string[],
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = update(current);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||
return { pinned: next };
|
||||
});
|
||||
}
|
||||
|
||||
export async function pinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await updateMatrixPins(roomId, messageId, opts, (current) =>
|
||||
current.includes(messageId) ? current : [...current, messageId],
|
||||
);
|
||||
}
|
||||
|
||||
export async function unpinMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await updateMatrixPins(roomId, messageId, opts, (current) =>
|
||||
current.filter((id) => id !== messageId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
pinned.map(async (eventId) => {
|
||||
try {
|
||||
return await fetchEventSummary(client, resolvedRoom, eventId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||
return { pinned, events };
|
||||
});
|
||||
}
|
||||
109
openclaw/extensions/matrix/src/matrix/actions/reactions.test.ts
Normal file
109
openclaw/extensions/matrix/src/matrix/actions/reactions.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
|
||||
|
||||
function createReactionsClient(params: {
|
||||
chunk: Array<{
|
||||
event_id?: string;
|
||||
sender?: string;
|
||||
key?: string;
|
||||
}>;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const doRequest = vi.fn(async (_method: string, _path: string, _query: any) => ({
|
||||
chunk: params.chunk.map((item) => ({
|
||||
event_id: item.event_id ?? "",
|
||||
sender: item.sender ?? "",
|
||||
content: item.key
|
||||
? {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.annotation",
|
||||
event_id: "$target",
|
||||
key: item.key,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
})),
|
||||
}));
|
||||
const getUserId = vi.fn(async () => params.userId ?? null);
|
||||
const redactEvent = vi.fn(async () => undefined);
|
||||
|
||||
return {
|
||||
client: {
|
||||
doRequest,
|
||||
getUserId,
|
||||
redactEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
doRequest,
|
||||
redactEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix reaction actions", () => {
|
||||
it("aggregates reactions by key and unique sender", async () => {
|
||||
const { client, doRequest } = createReactionsClient({
|
||||
chunk: [
|
||||
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
|
||||
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
|
||||
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
|
||||
{ event_id: "$4", sender: "@bot:example.org" },
|
||||
],
|
||||
userId: "@bot:example.org",
|
||||
});
|
||||
|
||||
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
|
||||
|
||||
expect(doRequest).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
|
||||
expect.objectContaining({ limit: 2 }),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "👍",
|
||||
count: 2,
|
||||
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "👎",
|
||||
count: 1,
|
||||
users: ["@alice:example.org"],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only current-user reactions matching emoji filter", async () => {
|
||||
const { client, redactEvent } = createReactionsClient({
|
||||
chunk: [
|
||||
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
|
||||
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
|
||||
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
|
||||
],
|
||||
userId: "@me:example.org",
|
||||
});
|
||||
|
||||
const result = await removeMatrixReactions("!room:example.org", "$msg", {
|
||||
client,
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ removed: 1 });
|
||||
expect(redactEvent).toHaveBeenCalledTimes(1);
|
||||
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
|
||||
});
|
||||
|
||||
it("returns removed=0 when current user id is unavailable", async () => {
|
||||
const { client, redactEvent } = createReactionsClient({
|
||||
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
|
||||
|
||||
expect(result).toEqual({ removed: 0 });
|
||||
expect(redactEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
102
openclaw/extensions/matrix/src/matrix/actions/reactions.ts
Normal file
102
openclaw/extensions/matrix/src/matrix/actions/reactions.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixRawEvent,
|
||||
type MatrixReactionSummary,
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
function getReactionsPath(roomId: string, messageId: string): string {
|
||||
return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`;
|
||||
}
|
||||
|
||||
async function listReactionEvents(
|
||||
client: NonNullable<MatrixActionClientOpts["client"]>,
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
limit: number,
|
||||
): Promise<MatrixRawEvent[]> {
|
||||
const res = (await client.doRequest("GET", getReactionsPath(roomId, messageId), {
|
||||
dir: "b",
|
||||
limit,
|
||||
})) as { chunk: MatrixRawEvent[] };
|
||||
return res.chunk;
|
||||
}
|
||||
|
||||
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 = resolveMatrixActionLimit(opts.limit, 100);
|
||||
const chunk = await listReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of 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 chunk = await listReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
85
openclaw/extensions/matrix/src/matrix/actions/room.ts
Normal file
85
openclaw/extensions/matrix/src/matrix/actions/room.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
// @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();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
openclaw/extensions/matrix/src/matrix/actions/summary.ts
Normal file
75
openclaw/extensions/matrix/src/matrix/actions/summary.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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 unknown as MatrixRawEvent;
|
||||
if (raw.unsigned?.redacted_because) {
|
||||
return null;
|
||||
}
|
||||
return summarizeMatrixRawEvent(raw);
|
||||
} catch {
|
||||
// Event not found, redacted, or inaccessible - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
85
openclaw/extensions/matrix/src/matrix/actions/types.ts
Normal file
85
openclaw/extensions/matrix/src/matrix/actions/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
32
openclaw/extensions/matrix/src/matrix/active-client.ts
Normal file
32
openclaw/extensions/matrix/src/matrix/active-client.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
// Support multiple active clients for multi-account
|
||||
const activeClients = new Map<string, MatrixClient>();
|
||||
|
||||
export function setActiveMatrixClient(
|
||||
client: MatrixClient | null,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const key = normalizeAccountId(accountId);
|
||||
if (client) {
|
||||
activeClients.set(key, client);
|
||||
} else {
|
||||
activeClients.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||
const key = normalizeAccountId(accountId);
|
||||
return activeClients.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function getAnyActiveMatrixClient(): MatrixClient | null {
|
||||
// Return any available client (for backward compatibility)
|
||||
const first = activeClients.values().next();
|
||||
return first.done ? null : first.value;
|
||||
}
|
||||
|
||||
export function clearAllActiveMatrixClients(): void {
|
||||
activeClients.clear();
|
||||
}
|
||||
39
openclaw/extensions/matrix/src/matrix/client-bootstrap.ts
Normal file
39
openclaw/extensions/matrix/src/matrix/client-bootstrap.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createMatrixClient } from "./client.js";
|
||||
|
||||
type MatrixClientBootstrapAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
encryption?: boolean;
|
||||
};
|
||||
|
||||
type MatrixCryptoPrepare = {
|
||||
prepare: (rooms?: string[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
|
||||
|
||||
export async function createPreparedMatrixClient(opts: {
|
||||
auth: MatrixClientBootstrapAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string;
|
||||
}): Promise<MatrixBootstrapClient> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: opts.auth.homeserver,
|
||||
userId: opts.auth.userId,
|
||||
accessToken: opts.auth.accessToken,
|
||||
encryption: opts.auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
if (opts.auth.encryption && client.crypto) {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
|
||||
} catch {
|
||||
// Ignore crypto prep failures for one-off requests.
|
||||
}
|
||||
}
|
||||
await client.start();
|
||||
return client;
|
||||
}
|
||||
56
openclaw/extensions/matrix/src/matrix/client.test.ts
Normal file
56
openclaw/extensions/matrix/src/matrix/client.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
14
openclaw/extensions/matrix/src/matrix/client.ts
Normal file
14
openclaw/extensions/matrix/src/matrix/client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export {
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "./client/shared.js";
|
||||
219
openclaw/extensions/matrix/src/matrix/client/config.ts
Normal file
219
openclaw/extensions/matrix/src/matrix/client/config.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
function clean(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
|
||||
function deepMergeConfig<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
|
||||
const merged = { ...base, ...override } as Record<string, unknown>;
|
||||
// Merge known nested objects (dm, actions) so partial overrides keep base fields
|
||||
for (const key of ["dm", "actions"] as const) {
|
||||
const b = base[key];
|
||||
const o = override[key];
|
||||
if (typeof b === "object" && b !== null && typeof o === "object" && o !== null) {
|
||||
merged[key] = { ...(b as Record<string, unknown>), ...(o as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Matrix config for a specific account, with fallback to top-level config.
|
||||
* This supports both multi-account (channels.matrix.accounts.*) and
|
||||
* single-account (channels.matrix.*) configurations.
|
||||
*/
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId?: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const matrixBase = cfg.channels?.matrix ?? {};
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
|
||||
// Try to get account-specific config first (direct lookup, then case-insensitive fallback)
|
||||
let accountConfig = accounts?.[normalizedAccountId];
|
||||
if (!accountConfig && accounts) {
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalizedAccountId) {
|
||||
accountConfig = accounts[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep merge: account-specific values override top-level values, preserving
|
||||
// nested object inheritance (dm, actions, groups) so partial overrides work.
|
||||
const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
|
||||
|
||||
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
|
||||
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
|
||||
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
|
||||
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
|
||||
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
const encryption = matrix.encryption ?? false;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceName,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-account function for backward compatibility - resolves default account config.
|
||||
*/
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env);
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const accountId = params?.accountId;
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: resolved.userId || "",
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (resolved.accessToken) {
|
||||
let userId = resolved.userId;
|
||||
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;
|
||||
// Save the credentials with the fetched userId
|
||||
saveMatrixCredentials(
|
||||
{
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
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 when no access token is configured (matrix.userId)");
|
||||
}
|
||||
|
||||
if (!resolved.password) {
|
||||
throw new Error(
|
||||
"Matrix password is required when no access token is configured (matrix.password)",
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using 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: ${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");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
|
||||
saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: login.device_id,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return auth;
|
||||
}
|
||||
123
openclaw/extensions/matrix/src/matrix/client/create-client.ts
Normal file
123
openclaw/extensions/matrix/src/matrix/client/create-client.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import fs from "node:fs";
|
||||
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
LogService,
|
||||
MatrixClient,
|
||||
SimpleFsStorageProvider,
|
||||
RustSdkCryptoStorageProvider,
|
||||
} 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;
|
||||
}
|
||||
36
openclaw/extensions/matrix/src/matrix/client/logging.ts
Normal file
36
openclaw/extensions/matrix/src/matrix/client/logging.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
4
openclaw/extensions/matrix/src/matrix/client/runtime.ts
Normal file
4
openclaw/extensions/matrix/src/matrix/client/runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isBunRuntime(): boolean {
|
||||
const versions = process.versions as { bun?: string };
|
||||
return typeof versions.bun === "string";
|
||||
}
|
||||
201
openclaw/extensions/matrix/src/matrix/client/shared.ts
Normal file
201
openclaw/extensions/matrix/src/matrix/client/shared.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
client: MatrixClient;
|
||||
key: string;
|
||||
started: boolean;
|
||||
cryptoReady: boolean;
|
||||
};
|
||||
|
||||
// Support multiple accounts with separate clients
|
||||
const sharedClientStates = new Map<string, SharedMatrixClientState>();
|
||||
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
|
||||
const sharedClientStartPromises = new Map<string, Promise<void>>();
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
normalizedAccountId || DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
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;
|
||||
}): Promise<void> {
|
||||
if (params.state.started) {
|
||||
return;
|
||||
}
|
||||
const key = params.state.key;
|
||||
const existingStartPromise = sharedClientStartPromises.get(key);
|
||||
if (existingStartPromise) {
|
||||
await existingStartPromise;
|
||||
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 as { prepare: (rooms?: string[]) => Promise<void> }).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(key, startPromise);
|
||||
try {
|
||||
await startPromise;
|
||||
} finally {
|
||||
sharedClientStartPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const auth =
|
||||
params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
// Check if we already have a client for this key
|
||||
const existingState = sharedClientStates.get(key);
|
||||
if (existingState) {
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: existingState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return existingState.client;
|
||||
}
|
||||
|
||||
// Check if there's a pending creation for this key
|
||||
const existingPromise = sharedClientPromises.get(key);
|
||||
if (existingPromise) {
|
||||
const pending = await existingPromise;
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: pending,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return pending.client;
|
||||
}
|
||||
|
||||
// Create a new client for this account
|
||||
const createPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
sharedClientPromises.set(key, createPromise);
|
||||
try {
|
||||
const created = await createPromise;
|
||||
sharedClientStates.set(key, created);
|
||||
if (shouldStart) {
|
||||
await ensureSharedClientStarted({
|
||||
state: created,
|
||||
timeoutMs: params.timeoutMs,
|
||||
initialSyncLimit: auth.initialSyncLimit,
|
||||
encryption: auth.encryption,
|
||||
});
|
||||
}
|
||||
return created.client;
|
||||
} finally {
|
||||
sharedClientPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// @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(key?: string): void {
|
||||
if (key) {
|
||||
// Stop a specific client
|
||||
const state = sharedClientStates.get(key);
|
||||
if (state) {
|
||||
state.client.stop();
|
||||
sharedClientStates.delete(key);
|
||||
}
|
||||
} else {
|
||||
// Stop all clients (backward compatible behavior)
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
}
|
||||
sharedClientStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the shared client for a specific account.
|
||||
* Use this instead of stopSharedClient() when shutting down a single account
|
||||
* to avoid stopping all accounts.
|
||||
*/
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
|
||||
const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
|
||||
stopSharedClient(key);
|
||||
}
|
||||
131
openclaw/extensions/matrix/src/matrix/client/storage.ts
Normal file
131
openclaw/extensions/matrix/src/matrix/client/storage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
34
openclaw/extensions/matrix/src/matrix/client/types.ts
Normal file
34
openclaw/extensions/matrix/src/matrix/client/types.ts
Normal 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;
|
||||
};
|
||||
125
openclaw/extensions/matrix/src/matrix/credentials.ts
Normal file
125
openclaw/extensions/matrix/src/matrix/credentials.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function credentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return "credentials.json";
|
||||
}
|
||||
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
|
||||
// Different raw IDs that normalize to the same value are the same logical account.
|
||||
return `credentials-${normalized}.json`;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
return path.join(dir, credentialsFilename(accountId));
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) {
|
||||
return null;
|
||||
}
|
||||
const raw = fs.readFileSync(credPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const toSave: MatrixStoredCredentials = {
|
||||
...credentials,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
lastUsedAt: now,
|
||||
};
|
||||
|
||||
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function touchMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
fs.unlinkSync(credPath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string },
|
||||
): boolean {
|
||||
// If userId is empty (token-based auth), only match homeserver
|
||||
if (!config.userId) {
|
||||
return stored.homeserver === config.homeserver;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
60
openclaw/extensions/matrix/src/matrix/deps.ts
Normal file
60
openclaw/extensions/matrix/src/matrix/deps.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
|
||||
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 runPluginCommandWithTimeout({
|
||||
argv: command,
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
throw new Error(
|
||||
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
33
openclaw/extensions/matrix/src/matrix/format.test.ts
Normal file
33
openclaw/extensions/matrix/src/matrix/format.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToMatrixHtml } from "./format.js";
|
||||
|
||||
describe("markdownToMatrixHtml", () => {
|
||||
it("renders basic inline formatting", () => {
|
||||
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
|
||||
expect(html).toContain("<em>there</em>");
|
||||
expect(html).toContain("<strong>boss</strong>");
|
||||
expect(html).toContain("<code>code</code>");
|
||||
});
|
||||
|
||||
it("renders links as HTML", () => {
|
||||
const html = markdownToMatrixHtml("see [docs](https://example.com)");
|
||||
expect(html).toContain('<a href="https://example.com">docs</a>');
|
||||
});
|
||||
|
||||
it("escapes raw HTML", () => {
|
||||
const html = markdownToMatrixHtml("<b>nope</b>");
|
||||
expect(html).toContain("<b>nope</b>");
|
||||
expect(html).not.toContain("<b>nope</b>");
|
||||
});
|
||||
|
||||
it("flattens images into alt text", () => {
|
||||
const html = markdownToMatrixHtml("");
|
||||
expect(html).toContain("alt");
|
||||
expect(html).not.toContain("<img");
|
||||
});
|
||||
|
||||
it("preserves line breaks", () => {
|
||||
const html = markdownToMatrixHtml("line1\nline2");
|
||||
expect(html).toContain("<br");
|
||||
});
|
||||
});
|
||||
22
openclaw/extensions/matrix/src/matrix/format.ts
Normal file
22
openclaw/extensions/matrix/src/matrix/format.ts
Normal 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
openclaw/extensions/matrix/src/matrix/index.ts
Normal file
11
openclaw/extensions/matrix/src/matrix/index.ts
Normal 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";
|
||||
127
openclaw/extensions/matrix/src/matrix/monitor/access-policy.ts
Normal file
127
openclaw/extensions/matrix/src/matrix/monitor/access-policy.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
formatAllowlistMatchMeta,
|
||||
issuePairingChallenge,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
} from "./allowlist.js";
|
||||
|
||||
type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type MatrixGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
export async function resolveMatrixAccessState(params: {
|
||||
isDirectMessage: boolean;
|
||||
resolvedAccountId: string;
|
||||
dmPolicy: MatrixDmPolicy;
|
||||
groupPolicy: MatrixGroupPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
readStoreForDmPolicy: (provider: string, accountId: string) => Promise<string[]>;
|
||||
}) {
|
||||
const storeAllowFrom = params.isDirectMessage
|
||||
? await readStoreAllowFromForDmPolicy({
|
||||
provider: "matrix",
|
||||
accountId: params.resolvedAccountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
readStore: params.readStoreForDmPolicy,
|
||||
})
|
||||
: [];
|
||||
const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
|
||||
const senderGroupPolicy =
|
||||
params.groupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: normalizedGroupAllowFrom.length > 0
|
||||
? "allowlist"
|
||||
: "open";
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: !params.isDirectMessage,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: normalizedGroupAllowFrom,
|
||||
storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveMatrixAllowListMatches({
|
||||
allowList: normalizeMatrixAllowList(allowFrom),
|
||||
userId: params.senderId,
|
||||
}),
|
||||
});
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
|
||||
return {
|
||||
access,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enforceMatrixDirectMessageAccess(params: {
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: MatrixDmPolicy;
|
||||
accessDecision: "allow" | "block" | "pairing";
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
effectiveAllowFrom: string[];
|
||||
upsertPairingRequest: (input: {
|
||||
id: string;
|
||||
meta?: Record<string, string | undefined>;
|
||||
}) => Promise<{
|
||||
code: string;
|
||||
created: boolean;
|
||||
}>;
|
||||
sendPairingReply: (text: string) => Promise<void>;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.dmEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (params.accessDecision === "allow") {
|
||||
return true;
|
||||
}
|
||||
const allowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: params.effectiveAllowFrom,
|
||||
userId: params.senderId,
|
||||
});
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (params.accessDecision === "pairing") {
|
||||
await issuePairingChallenge({
|
||||
channel: "matrix",
|
||||
senderId: params.senderId,
|
||||
senderIdLine: `Matrix user id: ${params.senderId}`,
|
||||
meta: { name: params.senderName },
|
||||
upsertPairingRequest: params.upsertPairingRequest,
|
||||
buildReplyText: ({ code }) =>
|
||||
[
|
||||
"OpenClaw: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"openclaw pairing approve matrix <code>",
|
||||
].join("\n"),
|
||||
sendPairingReply: params.sendPairingReply,
|
||||
onCreated: () => {
|
||||
params.logVerboseMessage(
|
||||
`matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
params.logVerboseMessage(
|
||||
`matrix pairing reply failed for ${params.senderId}: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
params.logVerboseMessage(
|
||||
`matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
describe("resolveMatrixAllowListMatch", () => {
|
||||
it("matches full user IDs and prefixes", () => {
|
||||
const userId = "@Alice:Example.org";
|
||||
const direct = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(direct.matchSource).toBe("id");
|
||||
|
||||
const prefixedMatrix = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedMatrix.allowed).toBe(true);
|
||||
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
|
||||
|
||||
const prefixedUser = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
|
||||
userId,
|
||||
});
|
||||
expect(prefixedUser.allowed).toBe(true);
|
||||
expect(prefixedUser.matchSource).toBe("prefixed-user");
|
||||
});
|
||||
|
||||
it("ignores display names and localparts", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("matches wildcard", () => {
|
||||
const match = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(["*"]),
|
||||
userId: "@alice:example.org",
|
||||
});
|
||||
expect(match.allowed).toBe(true);
|
||||
expect(match.matchSource).toBe("wildcard");
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/matrix/src/matrix/monitor/allowlist.ts
Normal file
103
openclaw/extensions/matrix/src/matrix/monitor/allowlist.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { AllowlistMatch } from "openclaw/plugin-sdk";
|
||||
|
||||
function normalizeAllowList(list?: Array<string | number>) {
|
||||
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeMatrixUser(raw?: string | null): string {
|
||||
const value = (raw ?? "").trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (!value.startsWith("@") || !value.includes(":")) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const withoutAt = value.slice(1);
|
||||
const splitIndex = withoutAt.indexOf(":");
|
||||
if (splitIndex === -1) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
|
||||
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
|
||||
if (!server) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
return `@${localpart}:${server.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function normalizeMatrixUserId(raw?: string | null): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("matrix:".length));
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return normalizeMatrixUser(trimmed.slice("user:".length));
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
function normalizeMatrixAllowListEntry(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return trimmed;
|
||||
}
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
|
||||
}
|
||||
return normalizeMatrixUser(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeMatrixAllowList(list?: Array<string | number>) {
|
||||
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
|
||||
}
|
||||
|
||||
export type MatrixAllowListMatch = AllowlistMatch<
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
|
||||
>;
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowList.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
|
||||
{ value: userId, source: "id" },
|
||||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) {
|
||||
continue;
|
||||
}
|
||||
if (allowList.includes(candidate.value)) {
|
||||
return {
|
||||
allowed: true,
|
||||
matchKey: candidate.value,
|
||||
matchSource: candidate.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
||||
return resolveMatrixAllowListMatch(params).allowed;
|
||||
}
|
||||
71
openclaw/extensions/matrix/src/matrix/monitor/auto-join.ts
Normal file
71
openclaw/extensions/matrix/src/matrix/monitor/auto-join.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
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 { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.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)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
104
openclaw/extensions/matrix/src/matrix/monitor/direct.ts
Normal file
104
openclaw/extensions/matrix/src/matrix/monitor/direct.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
141
openclaw/extensions/matrix/src/matrix/monitor/events.test.ts
Normal file
141
openclaw/extensions/matrix/src/matrix/monitor/events.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
|
||||
const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args),
|
||||
}));
|
||||
|
||||
describe("registerMatrixMonitorEvents", () => {
|
||||
beforeEach(() => {
|
||||
sendReadReceiptMatrixMock.mockClear();
|
||||
});
|
||||
|
||||
function createHarness(options?: { getUserId?: ReturnType<typeof vi.fn> }) {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org");
|
||||
const client = {
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler);
|
||||
}),
|
||||
getUserId,
|
||||
crypto: undefined,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const onRoomMessage = vi.fn();
|
||||
const logVerboseMessage = vi.fn();
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
} as unknown as RuntimeLogger;
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth: { encryption: false } as MatrixAuth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms: new Set<string>(),
|
||||
warnedCryptoMissingRooms: new Set<string>(),
|
||||
logger,
|
||||
formatNativeDependencyHint: (() =>
|
||||
"") as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
onRoomMessage,
|
||||
});
|
||||
|
||||
const roomMessageHandler = handlers.get("room.message");
|
||||
if (!roomMessageHandler) {
|
||||
throw new Error("missing room.message handler");
|
||||
}
|
||||
|
||||
return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage };
|
||||
}
|
||||
|
||||
it("sends read receipt immediately for non-self messages", async () => {
|
||||
const { client, onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
event_id: "$e1",
|
||||
sender: "@alice:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send read receipts for self messages", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
event_id: "$e2",
|
||||
sender: "@bot:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips receipt when message lacks sender or event id", async () => {
|
||||
const { onRoomMessage, roomMessageHandler } = createHarness();
|
||||
const event = {
|
||||
sender: "@alice:example.org",
|
||||
} as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches self user id across messages", async () => {
|
||||
const { getUserId, roomMessageHandler } = createHarness();
|
||||
const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", first);
|
||||
roomMessageHandler("!room:example.org", second);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(getUserId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs and continues when sending read receipt fails", async () => {
|
||||
sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom"));
|
||||
const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness();
|
||||
const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
expect(logVerboseMessage).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: early read receipt failed"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips read receipts if self-user lookup fails", async () => {
|
||||
const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({
|
||||
getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")),
|
||||
});
|
||||
const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent;
|
||||
|
||||
roomMessageHandler("!room:example.org", event);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
expect(getUserId).toHaveBeenCalledTimes(1);
|
||||
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
148
openclaw/extensions/matrix/src/matrix/monitor/events.ts
Normal file
148
openclaw/extensions/matrix/src/matrix/monitor/events.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
||||
import type { MatrixAuth } from "../client.js";
|
||||
import { sendReadReceiptMatrix } from "../send.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
|
||||
let selfUserId: string | undefined;
|
||||
let selfUserIdLookup: Promise<string | undefined> | undefined;
|
||||
|
||||
return async (): Promise<string | undefined> => {
|
||||
if (selfUserId) {
|
||||
return selfUserId;
|
||||
}
|
||||
if (!selfUserIdLookup) {
|
||||
selfUserIdLookup = client
|
||||
.getUserId()
|
||||
.then((userId) => {
|
||||
selfUserId = userId;
|
||||
return userId;
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!selfUserId) {
|
||||
selfUserIdLookup = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
return await selfUserIdLookup;
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMatrixMonitorEvents(params: {
|
||||
client: MatrixClient;
|
||||
auth: MatrixAuth;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
warnedEncryptedRooms: Set<string>;
|
||||
warnedCryptoMissingRooms: Set<string>;
|
||||
logger: RuntimeLogger;
|
||||
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
|
||||
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
|
||||
}): void {
|
||||
const {
|
||||
client,
|
||||
auth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms,
|
||||
warnedCryptoMissingRooms,
|
||||
logger,
|
||||
formatNativeDependencyHint,
|
||||
onRoomMessage,
|
||||
} = params;
|
||||
|
||||
const resolveSelfUserId = createSelfUserIdResolver(client);
|
||||
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id;
|
||||
const senderId = event?.sender;
|
||||
if (eventId && senderId) {
|
||||
void (async () => {
|
||||
const currentSelfUserId = await resolveSelfUserId();
|
||||
if (!currentSelfUserId || senderId === currentSelfUserId) {
|
||||
return;
|
||||
}
|
||||
await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => {
|
||||
logVerboseMessage(
|
||||
`matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
onRoomMessage(roomId, event);
|
||||
});
|
||||
|
||||
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const eventType = event?.type ?? "unknown";
|
||||
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const eventType = event?.type ?? "unknown";
|
||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on(
|
||||
"room.failed_decryption",
|
||||
async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
||||
logger.warn("Failed to decrypt message", {
|
||||
roomId,
|
||||
eventId: event.event_id,
|
||||
error: error.message,
|
||||
});
|
||||
logVerboseMessage(
|
||||
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const sender = event?.sender ?? "unknown";
|
||||
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
|
||||
logVerboseMessage(
|
||||
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
|
||||
);
|
||||
});
|
||||
|
||||
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
|
||||
});
|
||||
|
||||
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
|
||||
const eventType = event?.type ?? "unknown";
|
||||
if (eventType === EventType.RoomMessageEncrypted) {
|
||||
logVerboseMessage(
|
||||
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
|
||||
);
|
||||
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
|
||||
warnedEncryptedRooms.add(roomId);
|
||||
const warning =
|
||||
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
|
||||
logger.warn(warning, { roomId });
|
||||
}
|
||||
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
|
||||
warnedCryptoMissingRooms.add(roomId);
|
||||
const hint = formatNativeDependencyHint({
|
||||
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
manager: "pnpm",
|
||||
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
|
||||
});
|
||||
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
|
||||
logger.warn(warning, { roomId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (eventType === EventType.RoomMember) {
|
||||
const membership = (event?.content as { membership?: string } | undefined)?.membership;
|
||||
const stateKey = (event as { state_key?: string }).state_key ?? "";
|
||||
logVerboseMessage(
|
||||
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { EventType, type MatrixRawEvent } from "./types.js";
|
||||
|
||||
describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
||||
it("stores sender-labeled BodyForAgent for group thread messages", async () => {
|
||||
const recordInboundSession = vi.fn().mockResolvedValue(undefined);
|
||||
const formatInboundEnvelope = vi
|
||||
.fn()
|
||||
.mockImplementation((params: { senderLabel?: string; body: string }) => params.body);
|
||||
const finalizeInboundContext = vi
|
||||
.fn()
|
||||
.mockImplementation((ctx: Record<string, unknown>) => ctx);
|
||||
|
||||
const core = {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn().mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: undefined,
|
||||
sessionKey: "agent:main:matrix:channel:!room:example.org",
|
||||
mainSessionKey: "agent:main:main",
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"),
|
||||
readSessionUpdatedAt: vi.fn().mockReturnValue(123),
|
||||
recordInboundSession,
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}),
|
||||
formatInboundEnvelope,
|
||||
formatAgentEnvelope: vi
|
||||
.fn()
|
||||
.mockImplementation((params: { body: string }) => params.body),
|
||||
finalizeInboundContext,
|
||||
resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
|
||||
createReplyDispatcherWithTyping: vi.fn().mockReturnValue({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
}),
|
||||
withReplyDispatcher: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }),
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: vi.fn().mockReturnValue(false),
|
||||
resolveMarkdownTableMode: vi.fn().mockReturnValue("code"),
|
||||
},
|
||||
},
|
||||
system: {
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as unknown as RuntimeLogger;
|
||||
const logVerboseMessage = vi.fn();
|
||||
|
||||
const client = {
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const handler = createMatrixRoomMessageHandler({
|
||||
client,
|
||||
core,
|
||||
cfg: {},
|
||||
runtime,
|
||||
logger,
|
||||
logVerboseMessage,
|
||||
allowFrom: [],
|
||||
roomsConfig: undefined,
|
||||
mentionRegexes: [],
|
||||
groupPolicy: "open",
|
||||
replyToMode: "first",
|
||||
threadReplies: "inbound",
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
textLimit: 4000,
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now(),
|
||||
startupGraceMs: 60_000,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(false),
|
||||
},
|
||||
getRoomInfo: vi.fn().mockResolvedValue({
|
||||
name: "Dev Room",
|
||||
canonicalAlias: "#dev:matrix.example.org",
|
||||
altAliases: [],
|
||||
}),
|
||||
getMemberDisplayName: vi.fn().mockResolvedValue("Bu"),
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
const event = {
|
||||
type: EventType.RoomMessage,
|
||||
event_id: "$event1",
|
||||
sender: "@bu:matrix.example.org",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "show me my commits",
|
||||
"m.mentions": { user_ids: ["@bot:matrix.example.org"] },
|
||||
"m.relates_to": {
|
||||
rel_type: "m.thread",
|
||||
event_id: "$thread-root",
|
||||
},
|
||||
},
|
||||
} as unknown as MatrixRawEvent;
|
||||
|
||||
await handler("!room:example.org", event);
|
||||
|
||||
expect(formatInboundEnvelope).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatType: "channel",
|
||||
senderLabel: "Bu (bu)",
|
||||
}),
|
||||
);
|
||||
expect(recordInboundSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
ChatType: "thread",
|
||||
BodyForAgent: "Bu (bu): show me my commits",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
694
openclaw/extensions/matrix/src/matrix/monitor/handler.ts
Normal file
694
openclaw/extensions/matrix/src/matrix/monitor/handler.ts
Normal file
@@ -0,0 +1,694 @@
|
||||
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
formatAllowlistMatchMeta,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
resolveControlCommandGate,
|
||||
type PluginRuntime,
|
||||
type RuntimeEnv,
|
||||
type RuntimeLogger,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
|
||||
import { fetchEventSummary } from "../actions/summary.js";
|
||||
import {
|
||||
formatPollAsText,
|
||||
isPollStartType,
|
||||
parsePollStartContent,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
|
||||
import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
} from "./allowlist.js";
|
||||
import {
|
||||
resolveMatrixBodyForAgent,
|
||||
resolveMatrixInboundSenderLabel,
|
||||
resolveMatrixSenderUsername,
|
||||
} from "./inbound-body.js";
|
||||
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
import { resolveMentions } from "./mentions.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
|
||||
import { EventType, RelationType } from "./types.js";
|
||||
|
||||
export type MatrixMonitorHandlerParams = {
|
||||
client: MatrixClient;
|
||||
core: PluginRuntime;
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
logger: RuntimeLogger;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
allowFrom: string[];
|
||||
roomsConfig: Record<string, MatrixRoomConfig> | undefined;
|
||||
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
replyToMode: ReplyToMode;
|
||||
threadReplies: "off" | "inbound" | "always";
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
|
||||
textLimit: number;
|
||||
mediaMaxBytes: number;
|
||||
startupMs: number;
|
||||
startupGraceMs: number;
|
||||
directTracker: {
|
||||
isDirectMessage: (params: {
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
selfUserId: string;
|
||||
}) => Promise<boolean>;
|
||||
};
|
||||
getRoomInfo: (
|
||||
roomId: string,
|
||||
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
|
||||
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
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,
|
||||
accountId,
|
||||
} = params;
|
||||
const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||
const pairing = createScopedPairingAccess({
|
||||
core,
|
||||
channel: "matrix",
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
|
||||
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 unknown 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 unknown as RoomMessageEventContent;
|
||||
if (isPollEvent) {
|
||||
const pollStartContent = event.content as unknown 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 senderUsername = resolveMatrixSenderUsername(senderId);
|
||||
const senderLabel = resolveMatrixInboundSenderLabel({
|
||||
senderName,
|
||||
senderId,
|
||||
senderUsername,
|
||||
});
|
||||
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
|
||||
const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } =
|
||||
await resolveMatrixAccessState({
|
||||
isDirectMessage,
|
||||
resolvedAccountId,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
senderId,
|
||||
readStoreForDmPolicy: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
|
||||
if (isDirectMessage) {
|
||||
const allowedDirectMessage = await enforceMatrixDirectMessageAccess({
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
accessDecision: access.decision,
|
||||
senderId,
|
||||
senderName,
|
||||
effectiveAllowFrom,
|
||||
upsertPairingRequest: pairing.upsertPairingRequest,
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageMatrix(`room:${roomId}`, text, { client });
|
||||
},
|
||||
logVerboseMessage,
|
||||
});
|
||||
if (!allowedDirectMessage) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const roomUsers = roomConfig?.users ?? [];
|
||||
if (isRoom && roomUsers.length > 0) {
|
||||
const userMatch = resolveMatrixAllowListMatch({
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") {
|
||||
const groupAllowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
});
|
||||
if (!groupAllowMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
groupAllowMatch,
|
||||
)})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody =
|
||||
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
|
||||
let media: {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null = null;
|
||||
const contentUrl =
|
||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||
const contentFile =
|
||||
"file" in content && content.file && typeof content.file === "object"
|
||||
? content.file
|
||||
: undefined;
|
||||
const mediaUrl = contentUrl ?? contentFile?.url;
|
||||
if (!rawBody && !mediaUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentInfo =
|
||||
"info" in content && content.info && typeof content.info === "object"
|
||||
? (content.info as { mimetype?: string; size?: number })
|
||||
: undefined;
|
||||
const contentType = contentInfo?.mimetype;
|
||||
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||
if (mediaUrl?.startsWith("mxc://")) {
|
||||
try {
|
||||
media = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: mediaUrl,
|
||||
contentType,
|
||||
sizeBytes: contentSize,
|
||||
maxBytes: mediaMaxBytes,
|
||||
file: contentFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyText = rawBody || media?.placeholder || "";
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||
content,
|
||||
userId: selfUserId,
|
||||
text: bodyText,
|
||||
mentionRegexes,
|
||||
});
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "matrix",
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = resolveMatrixAllowListMatches({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
});
|
||||
const senderAllowedForGroup = groupAllowConfigured
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: senderId,
|
||||
})
|
||||
: false;
|
||||
const senderAllowedForRoomUsers =
|
||||
isRoom && roomUsers.length > 0
|
||||
? resolveMatrixAllowListMatches({
|
||||
allowList: normalizeMatrixAllowList(roomUsers),
|
||||
userId: senderId,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
|
||||
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (isRoom && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix",
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const shouldRequireMention = isRoom
|
||||
? roomConfig?.autoReply === true
|
||||
? false
|
||||
: roomConfig?.autoReply === false
|
||||
? true
|
||||
: typeof roomConfig?.requireMention === "boolean"
|
||||
? roomConfig?.requireMention
|
||||
: true
|
||||
: false;
|
||||
const shouldBypassMention =
|
||||
allowTextCommands &&
|
||||
isRoom &&
|
||||
shouldRequireMention &&
|
||||
!wasMentioned &&
|
||||
!hasExplicitMention &&
|
||||
commandAuthorized &&
|
||||
hasControlCommandInMessage;
|
||||
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info("skipping room message", { roomId, reason: "no-mention" });
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = event.event_id ?? "";
|
||||
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
|
||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||
const threadTarget = resolveMatrixThreadTarget({
|
||||
threadReplies,
|
||||
messageId,
|
||||
threadRootId,
|
||||
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
|
||||
});
|
||||
|
||||
const baseRoute = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId,
|
||||
peer: {
|
||||
kind: isDirectMessage ? "direct" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
|
||||
const route = {
|
||||
...baseRoute,
|
||||
sessionKey: threadRootId
|
||||
? `${baseRoute.sessionKey}:thread:${threadRootId}`
|
||||
: baseRoute.sessionKey,
|
||||
};
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let parentSessionKey: string | undefined;
|
||||
|
||||
if (threadRootId) {
|
||||
const existingSession = core.channel.session.readSessionUpdatedAt({
|
||||
storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: baseRoute.agentId,
|
||||
}),
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
if (existingSession === undefined) {
|
||||
try {
|
||||
const rootEvent = await fetchEventSummary(client, roomId, threadRootId);
|
||||
if (rootEvent?.body) {
|
||||
const rootSenderName = rootEvent.sender
|
||||
? await getMemberDisplayName(roomId, rootEvent.sender)
|
||||
: undefined;
|
||||
|
||||
threadStarterBody = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: rootSenderName ?? rootEvent.sender ?? "Unknown",
|
||||
timestamp: rootEvent.timestamp,
|
||||
envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
||||
body: rootEvent.body,
|
||||
});
|
||||
|
||||
threadLabel = `Matrix thread in ${roomName ?? roomId}`;
|
||||
parentSessionKey = baseRoute.sessionKey;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerboseMessage(
|
||||
`matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = threadRootId
|
||||
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
|
||||
: `${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.formatInboundEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: eventTs ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
chatType: isDirectMessage ? "direct" : "channel",
|
||||
senderLabel,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: resolveMatrixBodyForAgent({
|
||||
isDirectMessage,
|
||||
bodyText,
|
||||
senderLabel,
|
||||
}),
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
|
||||
To: `room:${roomId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel",
|
||||
ConversationLabel: envelopeFrom,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderUsername,
|
||||
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}`,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
});
|
||||
|
||||
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("failed updating session meta", {
|
||||
error: String(err),
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReaction &&
|
||||
core.channel.reactions.shouldAckReaction({
|
||||
scope: ackScope,
|
||||
isDirect: isDirectMessage,
|
||||
isGroup: isRoom,
|
||||
isMentionableGroup: isRoom,
|
||||
requireMention: Boolean(shouldRequireMention),
|
||||
canDetectMention,
|
||||
effectiveWasMentioned: wasMentioned || shouldBypassMention,
|
||||
shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
if (shouldAckReaction() && messageId) {
|
||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const replyTarget = ctxPayload.To;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.("matrix: missing reply target");
|
||||
return;
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "matrix",
|
||||
accountId: route.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingMatrix(roomId, true, undefined, client),
|
||||
stop: () => sendTypingMatrix(roomId, false, undefined, client),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix",
|
||||
action: "start",
|
||||
target: roomId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix",
|
||||
action: "stop",
|
||||
target: roomId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...prefixOptions,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
typingCallbacks,
|
||||
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)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
skillFilter: roomConfig?.skills,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
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)}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveMatrixBodyForAgent,
|
||||
resolveMatrixInboundSenderLabel,
|
||||
resolveMatrixSenderUsername,
|
||||
} from "./inbound-body.js";
|
||||
|
||||
describe("resolveMatrixSenderUsername", () => {
|
||||
it("extracts localpart without leading @", () => {
|
||||
expect(resolveMatrixSenderUsername("@bu:matrix.example.org")).toBe("bu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixInboundSenderLabel", () => {
|
||||
it("uses provided senderUsername when present", () => {
|
||||
expect(
|
||||
resolveMatrixInboundSenderLabel({
|
||||
senderName: "Bu",
|
||||
senderId: "@bu:matrix.example.org",
|
||||
senderUsername: "BU_CUSTOM",
|
||||
}),
|
||||
).toBe("Bu (BU_CUSTOM)");
|
||||
});
|
||||
|
||||
it("includes sender username when it differs from display name", () => {
|
||||
expect(
|
||||
resolveMatrixInboundSenderLabel({
|
||||
senderName: "Bu",
|
||||
senderId: "@bu:matrix.example.org",
|
||||
}),
|
||||
).toBe("Bu (bu)");
|
||||
});
|
||||
|
||||
it("falls back to sender username when display name is blank", () => {
|
||||
expect(
|
||||
resolveMatrixInboundSenderLabel({
|
||||
senderName: " ",
|
||||
senderId: "@zhang:matrix.example.org",
|
||||
}),
|
||||
).toBe("zhang");
|
||||
});
|
||||
|
||||
it("falls back to sender id when username cannot be parsed", () => {
|
||||
expect(
|
||||
resolveMatrixInboundSenderLabel({
|
||||
senderName: "",
|
||||
senderId: "matrix-user-without-colon",
|
||||
}),
|
||||
).toBe("matrix-user-without-colon");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatrixBodyForAgent", () => {
|
||||
it("keeps direct message body unchanged", () => {
|
||||
expect(
|
||||
resolveMatrixBodyForAgent({
|
||||
isDirectMessage: true,
|
||||
bodyText: "show me my commits",
|
||||
senderLabel: "Bu (bu)",
|
||||
}),
|
||||
).toBe("show me my commits");
|
||||
});
|
||||
|
||||
it("prefixes non-direct message body with sender label", () => {
|
||||
expect(
|
||||
resolveMatrixBodyForAgent({
|
||||
isDirectMessage: false,
|
||||
bodyText: "show me my commits",
|
||||
senderLabel: "Bu (bu)",
|
||||
}),
|
||||
).toBe("Bu (bu): show me my commits");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
export function resolveMatrixSenderUsername(senderId: string): string | undefined {
|
||||
const username = senderId.split(":")[0]?.replace(/^@/, "").trim();
|
||||
return username ? username : undefined;
|
||||
}
|
||||
|
||||
export function resolveMatrixInboundSenderLabel(params: {
|
||||
senderName: string;
|
||||
senderId: string;
|
||||
senderUsername?: string;
|
||||
}): string {
|
||||
const senderName = params.senderName.trim();
|
||||
const senderUsername = params.senderUsername ?? resolveMatrixSenderUsername(params.senderId);
|
||||
if (senderName && senderUsername && senderName !== senderUsername) {
|
||||
return `${senderName} (${senderUsername})`;
|
||||
}
|
||||
return senderName || senderUsername || params.senderId;
|
||||
}
|
||||
|
||||
export function resolveMatrixBodyForAgent(params: {
|
||||
isDirectMessage: boolean;
|
||||
bodyText: string;
|
||||
senderLabel: string;
|
||||
}): string {
|
||||
if (params.isDirectMessage) {
|
||||
return params.bodyText;
|
||||
}
|
||||
return `${params.senderLabel}: ${params.bodyText}`;
|
||||
}
|
||||
358
openclaw/extensions/matrix/src/matrix/monitor/index.ts
Normal file
358
openclaw/extensions/matrix/src/matrix/monitor/index.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
createLoggerBackedRuntime,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
summarizeMapping,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||
import { resolveMatrixAccount } from "../accounts.js";
|
||||
import { setActiveMatrixClient } from "../active-client.js";
|
||||
import {
|
||||
isBunRuntime,
|
||||
resolveMatrixAuth,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "../client.js";
|
||||
import { normalizeMatrixUserId } from "./allowlist.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
import { registerMatrixMonitorEvents } from "./events.js";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
mediaMaxMb?: number;
|
||||
initialSyncLimit?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MEDIA_MAX_MB = 20;
|
||||
|
||||
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||
}
|
||||
const core = getMatrixRuntime();
|
||||
let cfg = core.config.loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.matrix?.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||
const runtime: RuntimeEnv =
|
||||
opts.runtime ??
|
||||
createLoggerBackedRuntime({
|
||||
logger,
|
||||
});
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
return;
|
||||
}
|
||||
logger.debug?.(message);
|
||||
};
|
||||
|
||||
const normalizeUserEntry = (raw: string) =>
|
||||
raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.trim();
|
||||
const normalizeRoomEntry = (raw: string) =>
|
||||
raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^(room|channel):/i, "")
|
||||
.trim();
|
||||
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
|
||||
const resolveUserAllowlist = async (
|
||||
label: string,
|
||||
list?: Array<string | number>,
|
||||
): Promise<string[]> => {
|
||||
let allowList = list ?? [];
|
||||
if (allowList.length === 0) {
|
||||
return allowList.map(String);
|
||||
}
|
||||
const entries = allowList
|
||||
.map((entry) => normalizeUserEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
if (entries.length === 0) {
|
||||
return allowList.map(String);
|
||||
}
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const additions: string[] = [];
|
||||
const pending: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isMatrixUserId(entry)) {
|
||||
additions.push(normalizeMatrixUserId(entry));
|
||||
continue;
|
||||
}
|
||||
pending.push(entry);
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
runtime,
|
||||
});
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.id) {
|
||||
const normalizedId = normalizeMatrixUserId(entry.id);
|
||||
additions.push(normalizedId);
|
||||
mapping.push(`${entry.input}→${normalizedId}`);
|
||||
} else {
|
||||
unresolved.push(entry.input);
|
||||
}
|
||||
}
|
||||
}
|
||||
allowList = mergeAllowlist({ existing: allowList, additions });
|
||||
summarizeMapping(label, mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
|
||||
);
|
||||
}
|
||||
return allowList.map(String);
|
||||
};
|
||||
|
||||
// Resolve account-specific config for multi-account support
|
||||
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
|
||||
const accountConfig = account.config;
|
||||
|
||||
const allowlistOnly = accountConfig.allowlistOnly === true;
|
||||
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
|
||||
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
|
||||
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
|
||||
|
||||
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
|
||||
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
|
||||
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
|
||||
if (roomsConfig["*"]) {
|
||||
nextRooms["*"] = roomsConfig["*"];
|
||||
}
|
||||
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
|
||||
[];
|
||||
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
|
||||
if (entry === "*") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = normalizeRoomEntry(trimmed);
|
||||
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
|
||||
if (!nextRooms[cleaned]) {
|
||||
nextRooms[cleaned] = roomConfig;
|
||||
}
|
||||
if (cleaned !== entry) {
|
||||
mapping.push(`${entry}→${cleaned}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending.push({ input: entry, query: trimmed, config: roomConfig });
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
const resolved = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending.map((entry) => entry.query),
|
||||
kind: "group",
|
||||
runtime,
|
||||
});
|
||||
resolved.forEach((entry, index) => {
|
||||
const source = pending[index];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
if (entry.resolved && entry.id) {
|
||||
if (!nextRooms[entry.id]) {
|
||||
nextRooms[entry.id] = source.config;
|
||||
}
|
||||
mapping.push(`${source.input}→${entry.id}`);
|
||||
} else {
|
||||
unresolved.push(source.input);
|
||||
}
|
||||
});
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
|
||||
if (unresolved.length > 0) {
|
||||
runtime.log?.(
|
||||
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
|
||||
const nextRooms = { ...roomsConfig };
|
||||
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
|
||||
const users = roomConfig?.users ?? [];
|
||||
if (users.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
|
||||
if (resolvedUsers !== users) {
|
||||
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
|
||||
}
|
||||
}
|
||||
roomsConfig = nextRooms;
|
||||
}
|
||||
|
||||
cfg = {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
allowFrom,
|
||||
},
|
||||
groupAllowFrom,
|
||||
...(roomsConfig ? { groups: roomsConfig } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
|
||||
const resolvedInitialSyncLimit =
|
||||
typeof opts.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(opts.initialSyncLimit))
|
||||
: auth.initialSyncLimit;
|
||||
const authWithLimit =
|
||||
resolvedInitialSyncLimit === auth.initialSyncLimit
|
||||
? auth
|
||||
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
startClient: false,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
setActiveMatrixClient(client, opts.accountId);
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.matrix !== undefined,
|
||||
groupPolicy: accountConfig.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "matrix",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (message) => logVerboseMessage(message),
|
||||
});
|
||||
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
|
||||
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
|
||||
const threadReplies = accountConfig.threadReplies ?? "inbound";
|
||||
const dmConfig = accountConfig.dm;
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const startupMs = Date.now();
|
||||
const startupGraceMs = 0;
|
||||
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
|
||||
registerMatrixAutoJoin({ client, cfg, runtime });
|
||||
const warnedEncryptedRooms = new Set<string>();
|
||||
const warnedCryptoMissingRooms = new Set<string>();
|
||||
|
||||
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
|
||||
const handleRoomMessage = createMatrixRoomMessageHandler({
|
||||
client,
|
||||
core,
|
||||
cfg,
|
||||
runtime,
|
||||
logger,
|
||||
logVerboseMessage,
|
||||
allowFrom,
|
||||
roomsConfig,
|
||||
mentionRegexes,
|
||||
groupPolicy,
|
||||
replyToMode,
|
||||
threadReplies,
|
||||
dmEnabled,
|
||||
dmPolicy,
|
||||
textLimit,
|
||||
mediaMaxBytes,
|
||||
startupMs,
|
||||
startupGraceMs,
|
||||
directTracker,
|
||||
getRoomInfo,
|
||||
getMemberDisplayName,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
registerMatrixMonitorEvents({
|
||||
client,
|
||||
auth,
|
||||
logVerboseMessage,
|
||||
warnedEncryptedRooms,
|
||||
warnedCryptoMissingRooms,
|
||||
logger,
|
||||
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
|
||||
onRoomMessage: handleRoomMessage,
|
||||
});
|
||||
|
||||
logVerboseMessage("matrix: starting client");
|
||||
await resolveSharedMatrixClient({
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
logVerboseMessage("matrix: client started");
|
||||
|
||||
// @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 as { requestOwnUserVerification?: () => Promise<unknown> }
|
||||
).requestOwnUserVerification?.();
|
||||
if (verificationRequest) {
|
||||
logger.info("matrix: device verification requested - please verify in another client");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug?.("Device verification request failed (may already be verified)", {
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
try {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
stopSharedClientForAccount(auth, opts.accountId);
|
||||
} finally {
|
||||
setActiveMatrixClient(null, opts.accountId);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (opts.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
100
openclaw/extensions/matrix/src/matrix/monitor/location.ts
Normal file
100
openclaw/extensions/matrix/src/matrix/monitor/location.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
86
openclaw/extensions/matrix/src/matrix/monitor/media.test.ts
Normal file
86
openclaw/extensions/matrix/src/matrix/monitor/media.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
|
||||
describe("downloadMatrixMedia", () => {
|
||||
const saveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
path: "/tmp/media",
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const runtimeStub = {
|
||||
channel: {
|
||||
media: {
|
||||
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
function makeEncryptedMediaFixture() {
|
||||
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",
|
||||
};
|
||||
return { decryptMedia, client, file };
|
||||
}
|
||||
|
||||
it("decrypts encrypted media when file payloads are present", async () => {
|
||||
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
|
||||
|
||||
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, client, file } = makeEncryptedMediaFixture();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
118
openclaw/extensions/matrix/src/matrix/monitor/media.ts
Normal file
118
openclaw/extensions/matrix/src/matrix/monitor/media.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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 result = await params.client.downloadContent(params.mxcUrl);
|
||||
const raw = result.data ?? result;
|
||||
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
||||
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
return { buffer, headerType: result.contentType };
|
||||
} catch (err) {
|
||||
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and decrypt encrypted media from a Matrix room.
|
||||
* Uses @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 as Parameters<typeof params.client.crypto.decryptMedia>[0],
|
||||
);
|
||||
|
||||
if (decrypted.byteLength > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
return { buffer: decrypted };
|
||||
}
|
||||
|
||||
export async function downloadMatrixMedia(params: {
|
||||
client: MatrixClient;
|
||||
mxcUrl: string;
|
||||
contentType?: string;
|
||||
sizeBytes?: number;
|
||||
maxBytes: number;
|
||||
file?: EncryptedFile;
|
||||
}): Promise<{
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
} | null> {
|
||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
|
||||
throw new Error("Matrix media exceeds configured size limit");
|
||||
}
|
||||
|
||||
if (params.file) {
|
||||
// Encrypted media
|
||||
fetched = await fetchEncryptedMediaBuffer({
|
||||
client: params.client,
|
||||
file: params.file,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
} else {
|
||||
// Unencrypted media
|
||||
fetched = await fetchMatrixMediaBuffer({
|
||||
client: params.client,
|
||||
mxcUrl: params.mxcUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fetched) {
|
||||
return null;
|
||||
}
|
||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
headerType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "[matrix media]",
|
||||
};
|
||||
}
|
||||
154
openclaw/extensions/matrix/src/matrix/monitor/mentions.test.ts
Normal file
154
openclaw/extensions/matrix/src/matrix/monitor/mentions.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the runtime before importing resolveMentions
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
channel: {
|
||||
mentions: {
|
||||
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
|
||||
patterns.some((p) => p.test(text)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { resolveMentions } from "./mentions.js";
|
||||
|
||||
describe("resolveMentions", () => {
|
||||
const userId = "@bot:matrix.org";
|
||||
const mentionRegexes = [/@bot/i];
|
||||
|
||||
describe("m.mentions field", () => {
|
||||
it("detects mention via m.mentions.user_ids", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
"m.mentions": { user_ids: ["@bot:matrix.org"] },
|
||||
},
|
||||
userId,
|
||||
text: "hello",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.hasExplicitMention).toBe(true);
|
||||
});
|
||||
|
||||
it("detects room mention via m.mentions.room", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello everyone",
|
||||
"m.mentions": { room: true },
|
||||
},
|
||||
userId,
|
||||
text: "hello everyone",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatted_body matrix.to links", () => {
|
||||
it("detects mention in formatted_body with plain user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@bot:matrix.org">Bot</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("detects mention in formatted_body with URL-encoded user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/%40bot%3Amatrix.org">Bot</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("detects mention with single quotes in href", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot: hello",
|
||||
formatted_body: "<a href='https://matrix.to/#/@bot:matrix.org'>Bot</a>: hello",
|
||||
},
|
||||
userId,
|
||||
text: "Bot: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("does not detect mention for different user ID", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Other: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@other:matrix.org">Other</a>: hello',
|
||||
},
|
||||
userId,
|
||||
text: "Other: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it("does not false-positive on partial user ID match", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "Bot2: hello",
|
||||
formatted_body: '<a href="https://matrix.to/#/@bot2:matrix.org">Bot2</a>: hello',
|
||||
},
|
||||
userId: "@bot:matrix.org",
|
||||
text: "Bot2: hello",
|
||||
mentionRegexes: [],
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regex patterns", () => {
|
||||
it("detects mention via regex pattern in body text", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hey @bot can you help?",
|
||||
},
|
||||
userId,
|
||||
text: "hey @bot can you help?",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no mention", () => {
|
||||
it("returns false when no mention is present", () => {
|
||||
const result = resolveMentions({
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello world",
|
||||
},
|
||||
userId,
|
||||
text: "hello world",
|
||||
mentionRegexes,
|
||||
});
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
expect(result.hasExplicitMention).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
openclaw/extensions/matrix/src/matrix/monitor/mentions.ts
Normal file
62
openclaw/extensions/matrix/src/matrix/monitor/mentions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
// Type for room message content with mentions
|
||||
type MessageContentWithMentions = {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
formatted_body?: string;
|
||||
"m.mentions"?: {
|
||||
user_ids?: string[];
|
||||
room?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the formatted_body contains a matrix.to mention link for the given user ID.
|
||||
* Many Matrix clients (including Element) use HTML links in formatted_body instead of
|
||||
* or in addition to the m.mentions field.
|
||||
*/
|
||||
function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
|
||||
if (!formattedBody || !userId) {
|
||||
return false;
|
||||
}
|
||||
// Escape special regex characters in the user ID (e.g., @user:matrix.org)
|
||||
const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Match matrix.to links with the user ID, handling both URL-encoded and plain formats
|
||||
// Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
|
||||
const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
|
||||
if (plainPattern.test(formattedBody)) {
|
||||
return true;
|
||||
}
|
||||
// Also check URL-encoded version (@ -> %40, : -> %3A)
|
||||
const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
|
||||
return encodedPattern.test(formattedBody);
|
||||
}
|
||||
|
||||
export function resolveMentions(params: {
|
||||
content: MessageContentWithMentions;
|
||||
userId?: string | null;
|
||||
text?: string;
|
||||
mentionRegexes: RegExp[];
|
||||
}) {
|
||||
const mentions = params.content["m.mentions"];
|
||||
const mentionedUsers = Array.isArray(mentions?.user_ids)
|
||||
? new Set(mentions.user_ids)
|
||||
: new Set<string>();
|
||||
|
||||
// Check formatted_body for matrix.to mention links (legacy/alternative mention format)
|
||||
const mentionedInFormattedBody = params.userId
|
||||
? checkFormattedBodyMention(params.content.formatted_body, params.userId)
|
||||
: false;
|
||||
|
||||
const wasMentioned =
|
||||
Boolean(mentions?.room) ||
|
||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||
mentionedInFormattedBody ||
|
||||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||
params.text ?? "",
|
||||
params.mentionRegexes,
|
||||
);
|
||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||
}
|
||||
184
openclaw/extensions/matrix/src/matrix/monitor/replies.test.ts
Normal file
184
openclaw/extensions/matrix/src/matrix/monitor/replies.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
|
||||
sendMessageMatrixMock(to, message, opts),
|
||||
}));
|
||||
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
|
||||
describe("deliverMatrixReplies", () => {
|
||||
const loadConfigMock = vi.fn(() => ({}));
|
||||
const resolveMarkdownTableModeMock = vi.fn(() => "code");
|
||||
const convertMarkdownTablesMock = vi.fn((text: string) => text);
|
||||
const resolveChunkModeMock = vi.fn(() => "length");
|
||||
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => loadConfigMock(),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
resolveChunkMode: () => resolveChunkModeMock(),
|
||||
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
|
||||
});
|
||||
|
||||
it("keeps replyToId on first reply only when replyToMode=first", async () => {
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "first-a|first-b", replyToId: "reply-1" },
|
||||
{ text: "second", replyToId: "reply-2" },
|
||||
],
|
||||
roomId: "room:1",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps replyToId on every reply when replyToMode=all", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{
|
||||
text: "caption",
|
||||
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
|
||||
replyToId: "reply-media",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
{ text: "plain", replyToId: "reply-text" },
|
||||
],
|
||||
roomId: "room:2",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
|
||||
"room:2",
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
|
||||
]);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
|
||||
"room:2",
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
|
||||
]);
|
||||
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: "reply-text" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips reasoning-only replies with Reasoning prefix", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" },
|
||||
{ text: "Here is the answer.", replyToId: "r2" },
|
||||
],
|
||||
roomId: "room:reason",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer.");
|
||||
});
|
||||
|
||||
it("skips reasoning-only replies with thinking tags", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "<thinking>internal chain of thought</thinking>", replyToId: "r1" },
|
||||
{ text: " <think>more reasoning</think> ", replyToId: "r2" },
|
||||
{ text: "<antthinking>hidden</antthinking>", replyToId: "r3" },
|
||||
{ text: "Visible reply", replyToId: "r4" },
|
||||
],
|
||||
roomId: "room:tags",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply");
|
||||
});
|
||||
|
||||
it("delivers all replies when none are reasoning-only", async () => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [
|
||||
{ text: "First answer", replyToId: "r1" },
|
||||
{ text: "Second answer", replyToId: "r2" },
|
||||
],
|
||||
roomId: "room:normal",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("suppresses replyToId when threadId is set", async () => {
|
||||
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverMatrixReplies({
|
||||
replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
|
||||
roomId: "room:3",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "all",
|
||||
threadId: "thread-77",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
|
||||
);
|
||||
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
124
openclaw/extensions/matrix/src/matrix/monitor/replies.ts
Normal file
124
openclaw/extensions/matrix/src/matrix/monitor/replies.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
roomId: string;
|
||||
client: MatrixClient;
|
||||
runtime: RuntimeEnv;
|
||||
textLimit: number;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
accountId?: string;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const cfg = core.config.loadConfig();
|
||||
const tableMode =
|
||||
params.tableMode ??
|
||||
core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
// Skip pure reasoning messages so internal thinking traces are never delivered.
|
||||
if (reply.text && isReasoningOnlyMessage(reply.text)) {
|
||||
logVerbose("matrix reply is reasoning-only; skipping");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
|
||||
const rawText = reply.text ?? "";
|
||||
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
|
||||
const shouldIncludeReply = (id?: string) =>
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
let sentTextChunk = false;
|
||||
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
chunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
client: params.client,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
sentTextChunk = true;
|
||||
}
|
||||
if (replyToIdForReply && !hasReplied && sentTextChunk) {
|
||||
hasReplied = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
await sendMessageMatrix(params.roomId, caption, {
|
||||
client: params.client,
|
||||
mediaUrl,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
if (replyToIdForReply && !hasReplied) {
|
||||
hasReplied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const REASONING_PREFIX = "Reasoning:\n";
|
||||
const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i;
|
||||
|
||||
/**
|
||||
* Detect messages that contain only reasoning/thinking content and no user-facing answer.
|
||||
* These are emitted by the agent when `includeReasoning` is active but should not
|
||||
* be forwarded to channels that do not support a dedicated reasoning lane.
|
||||
*/
|
||||
function isReasoningOnlyMessage(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith(REASONING_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (THINKING_TAG_RE.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
55
openclaw/extensions/matrix/src/matrix/monitor/room-info.ts
Normal file
55
openclaw/extensions/matrix/src/matrix/monitor/room-info.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
39
openclaw/extensions/matrix/src/matrix/monitor/rooms.test.ts
Normal file
39
openclaw/extensions/matrix/src/matrix/monitor/rooms.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
|
||||
describe("resolveMatrixRoomConfig", () => {
|
||||
it("matches room IDs and aliases, not names", () => {
|
||||
const rooms = {
|
||||
"!room:example.org": { allow: true },
|
||||
"#alias:example.org": { allow: true },
|
||||
"Project Room": { allow: true },
|
||||
};
|
||||
|
||||
const byId = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byId.allowed).toBe(true);
|
||||
expect(byId.matchKey).toBe("!room:example.org");
|
||||
|
||||
const byAlias = resolveMatrixRoomConfig({
|
||||
rooms,
|
||||
roomId: "!other:example.org",
|
||||
aliases: ["#alias:example.org"],
|
||||
name: "Other Room",
|
||||
});
|
||||
expect(byAlias.allowed).toBe(true);
|
||||
expect(byAlias.matchKey).toBe("#alias:example.org");
|
||||
|
||||
const byName = resolveMatrixRoomConfig({
|
||||
rooms: { "Project Room": { allow: true } },
|
||||
roomId: "!different:example.org",
|
||||
aliases: [],
|
||||
name: "Project Room",
|
||||
});
|
||||
expect(byName.allowed).toBe(false);
|
||||
expect(byName.config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
47
openclaw/extensions/matrix/src/matrix/monitor/rooms.ts
Normal file
47
openclaw/extensions/matrix/src/matrix/monitor/rooms.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk";
|
||||
import type { MatrixRoomConfig } from "../../types.js";
|
||||
|
||||
export type MatrixRoomConfigResolved = {
|
||||
allowed: boolean;
|
||||
allowlistConfigured: boolean;
|
||||
config?: MatrixRoomConfig;
|
||||
matchKey?: string;
|
||||
matchSource?: "direct" | "wildcard";
|
||||
};
|
||||
|
||||
export function resolveMatrixRoomConfig(params: {
|
||||
rooms?: Record<string, MatrixRoomConfig>;
|
||||
roomId: string;
|
||||
aliases: string[];
|
||||
name?: string | null;
|
||||
}): MatrixRoomConfigResolved {
|
||||
const rooms = params.rooms ?? {};
|
||||
const keys = Object.keys(rooms);
|
||||
const allowlistConfigured = keys.length > 0;
|
||||
const candidates = buildChannelKeyCandidates(
|
||||
params.roomId,
|
||||
`room:${params.roomId}`,
|
||||
...params.aliases,
|
||||
);
|
||||
const {
|
||||
entry: matched,
|
||||
key: matchedKey,
|
||||
wildcardEntry,
|
||||
wildcardKey,
|
||||
} = resolveChannelEntryMatch({
|
||||
entries: rooms,
|
||||
keys: candidates,
|
||||
wildcardKey: "*",
|
||||
});
|
||||
const resolved = matched ?? wildcardEntry;
|
||||
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
|
||||
const matchKey = matchedKey ?? wildcardKey;
|
||||
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
|
||||
return {
|
||||
allowed,
|
||||
allowlistConfigured,
|
||||
config: resolved,
|
||||
matchKey,
|
||||
matchSource,
|
||||
};
|
||||
}
|
||||
68
openclaw/extensions/matrix/src/matrix/monitor/threads.ts
Normal file
68
openclaw/extensions/matrix/src/matrix/monitor/threads.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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;
|
||||
}
|
||||
39
openclaw/extensions/matrix/src/matrix/monitor/types.ts
Normal file
39
openclaw/extensions/matrix/src/matrix/monitor/types.ts
Normal 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 };
|
||||
};
|
||||
};
|
||||
21
openclaw/extensions/matrix/src/matrix/poll-types.test.ts
Normal file
21
openclaw/extensions/matrix/src/matrix/poll-types.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePollStartContent } from "./poll-types.js";
|
||||
|
||||
describe("parsePollStartContent", () => {
|
||||
it("parses legacy m.poll payloads", () => {
|
||||
const summary = parsePollStartContent({
|
||||
"m.poll": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "answer1", "m.text": "Yes" },
|
||||
{ id: "answer2", "m.text": "No" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.question).toBe("Lunch?");
|
||||
expect(summary?.answers).toEqual(["Yes", "No"]);
|
||||
});
|
||||
});
|
||||
167
openclaw/extensions/matrix/src/matrix/poll-types.ts
Normal file
167
openclaw/extensions/matrix/src/matrix/poll-types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Matrix Poll Types (MSC3381)
|
||||
*
|
||||
* Defines types for Matrix poll events:
|
||||
* - m.poll.start - Creates a new poll
|
||||
* - m.poll.response - Records a vote
|
||||
* - m.poll.end - Closes a poll
|
||||
*/
|
||||
|
||||
import type { PollInput } from "openclaw/plugin-sdk";
|
||||
|
||||
export const M_POLL_START = "m.poll.start" as const;
|
||||
export const M_POLL_RESPONSE = "m.poll.response" as const;
|
||||
export const M_POLL_END = "m.poll.end" as const;
|
||||
|
||||
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
|
||||
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
|
||||
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
|
||||
|
||||
export const POLL_EVENT_TYPES = [
|
||||
M_POLL_START,
|
||||
M_POLL_RESPONSE,
|
||||
M_POLL_END,
|
||||
ORG_POLL_START,
|
||||
ORG_POLL_RESPONSE,
|
||||
ORG_POLL_END,
|
||||
];
|
||||
|
||||
export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
|
||||
export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
|
||||
export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
|
||||
|
||||
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
||||
|
||||
export type TextContent = {
|
||||
"m.text"?: string;
|
||||
"org.matrix.msc1767.text"?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export type PollAnswer = {
|
||||
id: string;
|
||||
} & TextContent;
|
||||
|
||||
export type PollStartSubtype = {
|
||||
question: TextContent;
|
||||
kind?: PollKind;
|
||||
max_selections?: number;
|
||||
answers: PollAnswer[];
|
||||
};
|
||||
|
||||
export type LegacyPollStartContent = {
|
||||
"m.poll"?: PollStartSubtype;
|
||||
};
|
||||
|
||||
export type PollStartContent = {
|
||||
[M_POLL_START]?: PollStartSubtype;
|
||||
[ORG_POLL_START]?: PollStartSubtype;
|
||||
"m.poll"?: PollStartSubtype;
|
||||
"m.text"?: string;
|
||||
"org.matrix.msc1767.text"?: string;
|
||||
};
|
||||
|
||||
export type PollSummary = {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
question: string;
|
||||
answers: string[];
|
||||
kind: PollKind;
|
||||
maxSelections: number;
|
||||
};
|
||||
|
||||
export function isPollStartType(eventType: string): boolean {
|
||||
return (POLL_START_TYPES as readonly string[]).includes(eventType);
|
||||
}
|
||||
|
||||
export function getTextContent(text?: TextContent): string {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
||||
}
|
||||
|
||||
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
||||
const poll =
|
||||
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
||||
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
|
||||
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const question = getTextContent(poll.question);
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const answers = poll.answers
|
||||
.map((answer) => getTextContent(answer))
|
||||
.filter((a) => a.trim().length > 0);
|
||||
|
||||
return {
|
||||
eventId: "",
|
||||
roomId: "",
|
||||
sender: "",
|
||||
senderName: "",
|
||||
question,
|
||||
answers,
|
||||
kind: poll.kind ?? "m.poll.disclosed",
|
||||
maxSelections: poll.max_selections ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPollAsText(summary: PollSummary): string {
|
||||
const lines = [
|
||||
"[Poll]",
|
||||
summary.question,
|
||||
"",
|
||||
...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildTextContent(body: string): TextContent {
|
||||
return {
|
||||
"m.text": body,
|
||||
"org.matrix.msc1767.text": body,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPollFallbackText(question: string, answers: string[]): string {
|
||||
if (answers.length === 0) {
|
||||
return question;
|
||||
}
|
||||
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
||||
}
|
||||
|
||||
export function buildPollStartContent(poll: PollInput): PollStartContent {
|
||||
const question = poll.question.trim();
|
||||
const answers = poll.options
|
||||
.map((option) => option.trim())
|
||||
.filter((option) => option.length > 0)
|
||||
.map((option, idx) => ({
|
||||
id: `answer${idx + 1}`,
|
||||
...buildTextContent(option),
|
||||
}));
|
||||
|
||||
const isMultiple = (poll.maxSelections ?? 1) > 1;
|
||||
const maxSelections = isMultiple ? Math.max(1, answers.length) : 1;
|
||||
const fallbackText = buildPollFallbackText(
|
||||
question,
|
||||
answers.map((answer) => getTextContent(answer)),
|
||||
);
|
||||
|
||||
return {
|
||||
[M_POLL_START]: {
|
||||
question: buildTextContent(question),
|
||||
kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
|
||||
max_selections: maxSelections,
|
||||
answers,
|
||||
},
|
||||
"m.text": fallbackText,
|
||||
"org.matrix.msc1767.text": fallbackText,
|
||||
};
|
||||
}
|
||||
69
openclaw/extensions/matrix/src/matrix/probe.ts
Normal file
69
openclaw/extensions/matrix/src/matrix/probe.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { createMatrixClient, isBunRuntime } from "./client.js";
|
||||
|
||||
export type MatrixProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs: number;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
export async function probeMatrix(params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
userId?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixProbe> {
|
||||
const started = Date.now();
|
||||
const result: MatrixProbe = {
|
||||
ok: false,
|
||||
status: null,
|
||||
error: null,
|
||||
elapsedMs: 0,
|
||||
};
|
||||
if (isBunRuntime()) {
|
||||
return {
|
||||
...result,
|
||||
error: "Matrix probe requires Node (bun runtime not supported)",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
if (!params.homeserver?.trim()) {
|
||||
return {
|
||||
...result,
|
||||
error: "missing homeserver",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
if (!params.accessToken?.trim()) {
|
||||
return {
|
||||
...result,
|
||||
error: "missing access token",
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
154
openclaw/extensions/matrix/src/matrix/send-queue.test.ts
Normal file
154
openclaw/extensions/matrix/src/matrix/send-queue.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("enqueueSend", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("serializes sends per room", async () => {
|
||||
const gate = deferred<void>();
|
||||
const events: string[] = [];
|
||||
|
||||
const first = enqueueSend("!room:example.org", async () => {
|
||||
events.push("start1");
|
||||
await gate.promise;
|
||||
events.push("end1");
|
||||
return "one";
|
||||
});
|
||||
const second = enqueueSend("!room:example.org", async () => {
|
||||
events.push("start2");
|
||||
events.push("end2");
|
||||
return "two";
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
expect(events).toEqual(["start1"]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2);
|
||||
expect(events).toEqual(["start1"]);
|
||||
|
||||
gate.resolve();
|
||||
await first;
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1);
|
||||
expect(events).toEqual(["start1", "end1"]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await second;
|
||||
expect(events).toEqual(["start1", "end1", "start2", "end2"]);
|
||||
});
|
||||
|
||||
it("does not serialize across different rooms", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const a = enqueueSend("!a:example.org", async () => {
|
||||
events.push("a");
|
||||
return "a";
|
||||
});
|
||||
const b = enqueueSend("!b:example.org", async () => {
|
||||
events.push("b");
|
||||
return "b";
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
await Promise.all([a, b]);
|
||||
expect(events.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("continues queue after failures", async () => {
|
||||
const first = enqueueSend("!room:example.org", async () => {
|
||||
throw new Error("boom");
|
||||
}).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
const firstResult = await first;
|
||||
expect(firstResult.ok).toBe(false);
|
||||
if (firstResult.ok) {
|
||||
throw new Error("expected first queue item to fail");
|
||||
}
|
||||
expect(firstResult.error).toBeInstanceOf(Error);
|
||||
expect(firstResult.error.message).toBe("boom");
|
||||
|
||||
const second = enqueueSend("!room:example.org", async () => "ok");
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
await expect(second).resolves.toBe("ok");
|
||||
});
|
||||
|
||||
it("continues queued work when the head task fails", async () => {
|
||||
const gate = deferred<void>();
|
||||
const events: string[] = [];
|
||||
|
||||
const first = enqueueSend("!room:example.org", async () => {
|
||||
events.push("start1");
|
||||
await gate.promise;
|
||||
throw new Error("boom");
|
||||
}).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
);
|
||||
const second = enqueueSend("!room:example.org", async () => {
|
||||
events.push("start2");
|
||||
return "two";
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
expect(events).toEqual(["start1"]);
|
||||
|
||||
gate.resolve();
|
||||
const firstResult = await first;
|
||||
expect(firstResult.ok).toBe(false);
|
||||
if (firstResult.ok) {
|
||||
throw new Error("expected head queue item to fail");
|
||||
}
|
||||
expect(firstResult.error).toBeInstanceOf(Error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
|
||||
await expect(second).resolves.toBe("two");
|
||||
expect(events).toEqual(["start1", "start2"]);
|
||||
});
|
||||
|
||||
it("supports custom gap and delay injection", async () => {
|
||||
const events: string[] = [];
|
||||
const delayFn = vi.fn(async (_ms: number) => {});
|
||||
|
||||
const first = enqueueSend(
|
||||
"!room:example.org",
|
||||
async () => {
|
||||
events.push("first");
|
||||
return "one";
|
||||
},
|
||||
{ gapMs: 7, delayFn },
|
||||
);
|
||||
const second = enqueueSend(
|
||||
"!room:example.org",
|
||||
async () => {
|
||||
events.push("second");
|
||||
return "two";
|
||||
},
|
||||
{ gapMs: 7, delayFn },
|
||||
);
|
||||
|
||||
await expect(first).resolves.toBe("one");
|
||||
await expect(second).resolves.toBe("two");
|
||||
expect(events).toEqual(["first", "second"]);
|
||||
expect(delayFn).toHaveBeenCalledTimes(2);
|
||||
expect(delayFn).toHaveBeenNthCalledWith(1, 7);
|
||||
expect(delayFn).toHaveBeenNthCalledWith(2, 7);
|
||||
});
|
||||
});
|
||||
44
openclaw/extensions/matrix/src/matrix/send-queue.ts
Normal file
44
openclaw/extensions/matrix/src/matrix/send-queue.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export const DEFAULT_SEND_GAP_MS = 150;
|
||||
|
||||
type MatrixSendQueueOptions = {
|
||||
gapMs?: number;
|
||||
delayFn?: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
// Serialize sends per room to preserve Matrix delivery order.
|
||||
const roomQueues = new Map<string, Promise<void>>();
|
||||
|
||||
export async function enqueueSend<T>(
|
||||
roomId: string,
|
||||
fn: () => Promise<T>,
|
||||
options?: MatrixSendQueueOptions,
|
||||
): Promise<T> {
|
||||
const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS;
|
||||
const delayFn = options?.delayFn ?? delay;
|
||||
const previous = roomQueues.get(roomId) ?? Promise.resolve();
|
||||
|
||||
const next = previous
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await delayFn(gapMs);
|
||||
return await fn();
|
||||
});
|
||||
|
||||
const queueMarker = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
roomQueues.set(roomId, queueMarker);
|
||||
|
||||
queueMarker.finally(() => {
|
||||
if (roomQueues.get(roomId) === queueMarker) {
|
||||
roomQueues.delete(roomId);
|
||||
}
|
||||
});
|
||||
|
||||
return await next;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
238
openclaw/extensions/matrix/src/matrix/send.test.ts
Normal file
238
openclaw/extensions/matrix/src/matrix/send.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
|
||||
vi.mock("music-metadata", () => ({
|
||||
// `resolveMediaDurationMs` lazily imports `music-metadata`; in tests we don't
|
||||
// need real duration parsing and the real module is expensive to load.
|
||||
parseBuffer: vi.fn().mockResolvedValue({ format: {} }),
|
||||
}));
|
||||
|
||||
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 mediaKindFromMimeMock = vi.fn(() => "image");
|
||||
const isVoiceCompatibleAudioMock = vi.fn(() => false);
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
mediaKindFromMime:
|
||||
mediaKindFromMimeMock as unknown as PluginRuntime["media"]["mediaKindFromMime"],
|
||||
isVoiceCompatibleAudio:
|
||||
isVoiceCompatibleAudioMock as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
|
||||
getImageMetadata: getImageMetadataMock as unknown as PluginRuntime["media"]["getImageMetadata"],
|
||||
resizeToJpeg: resizeToJpegMock as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
||||
},
|
||||
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 };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mediaKindFromMimeMock.mockReturnValue("image");
|
||||
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
const uploadArg = uploadContent.mock.calls[0]?.[0];
|
||||
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
msgtype?: string;
|
||||
format?: string;
|
||||
formatted_body?: string;
|
||||
};
|
||||
expect(content.msgtype).toBe("m.image");
|
||||
expect(content.format).toBe("org.matrix.custom.html");
|
||||
expect(content.formatted_body).toContain("caption");
|
||||
expect(content.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("uploads encrypted media with file payloads", async () => {
|
||||
const { client, sendMessage, uploadContent } = makeClient();
|
||||
(client as { crypto?: object }).crypto = {
|
||||
isRoomEncrypted: vi.fn().mockResolvedValue(true),
|
||||
encryptMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("encrypted"),
|
||||
file: {
|
||||
key: {
|
||||
kty: "oct",
|
||||
key_ops: ["encrypt", "decrypt"],
|
||||
alg: "A256CTR",
|
||||
k: "secret",
|
||||
ext: true,
|
||||
},
|
||||
iv: "iv",
|
||||
hashes: { sha256: "hash" },
|
||||
v: "v2",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
});
|
||||
|
||||
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
|
||||
expect(uploadArg?.toString()).toBe("encrypted");
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
file?: { url?: string };
|
||||
};
|
||||
expect(content.url).toBeUndefined();
|
||||
expect(content.file?.url).toBe("mxc://example/file");
|
||||
});
|
||||
|
||||
it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => {
|
||||
const { client, sendMessage } = makeClient();
|
||||
mediaKindFromMimeMock.mockReturnValue("audio");
|
||||
isVoiceCompatibleAudioMock.mockReturnValue(true);
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("audio"),
|
||||
fileName: "clip.mp3",
|
||||
contentType: "audio/mpeg",
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "voice caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/clip.mp3",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
|
||||
expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({
|
||||
contentType: "audio/mpeg",
|
||||
fileName: "clip.mp3",
|
||||
});
|
||||
expect(sendMessage).toHaveBeenCalledTimes(2);
|
||||
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
|
||||
msgtype?: string;
|
||||
body?: string;
|
||||
"org.matrix.msc3245.voice"?: Record<string, never>;
|
||||
};
|
||||
expect(mediaContent.msgtype).toBe("m.audio");
|
||||
expect(mediaContent.body).toBe("Voice message");
|
||||
expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps regular audio payload when audioAsVoice media is incompatible", async () => {
|
||||
const { client, sendMessage } = makeClient();
|
||||
mediaKindFromMimeMock.mockReturnValue("audio");
|
||||
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("audio"),
|
||||
fileName: "clip.wav",
|
||||
contentType: "audio/wav",
|
||||
kind: "audio",
|
||||
});
|
||||
|
||||
await sendMessageMatrix("room:!room:example", "voice caption", {
|
||||
client,
|
||||
mediaUrl: "file:///tmp/clip.wav",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
|
||||
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
|
||||
msgtype?: string;
|
||||
body?: string;
|
||||
"org.matrix.msc3245.voice"?: Record<string, never>;
|
||||
};
|
||||
expect(mediaContent.msgtype).toBe("m.audio");
|
||||
expect(mediaContent.body).toBe("voice caption");
|
||||
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageMatrix threads", () => {
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
265
openclaw/extensions/matrix/src/matrix/send.ts
Normal file
265
openclaw/extensions/matrix/src/matrix/send.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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 { enqueueSend } from "./send-queue.js";
|
||||
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
|
||||
import {
|
||||
buildReplyRelation,
|
||||
buildTextContent,
|
||||
buildThreadRelation,
|
||||
resolveMatrixMsgType,
|
||||
resolveMatrixVoiceDecision,
|
||||
} from "./send/formatting.js";
|
||||
import {
|
||||
buildMediaContent,
|
||||
prepareImageInfo,
|
||||
resolveMediaDurationMs,
|
||||
uploadMediaMaybeEncrypted,
|
||||
} from "./send/media.js";
|
||||
import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
|
||||
import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixOutboundContent,
|
||||
type MatrixSendOpts,
|
||||
type MatrixSendResult,
|
||||
type ReactionEventContent,
|
||||
} from "./send/types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
|
||||
export { resolveMatrixRoomId } from "./send/targets.js";
|
||||
|
||||
export async function sendMessageMatrix(
|
||||
to: string,
|
||||
message: string,
|
||||
opts: MatrixSendOpts = {},
|
||||
): Promise<MatrixSendResult> {
|
||||
const trimmedMessage = message?.trim() ?? "";
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
throw new Error("Matrix send requires text or media");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveMatrixClient({
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
return await enqueueSend(roomId, async () => {
|
||||
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(opts.accountId);
|
||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
||||
contentType: media.contentType,
|
||||
filename: media.fileName,
|
||||
});
|
||||
const durationMs = await resolveMediaDurationMs({
|
||||
buffer: media.buffer,
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
kind: media.kind,
|
||||
});
|
||||
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
|
||||
const { useVoice } = resolveMatrixVoiceDecision({
|
||||
wantsVoice: opts.audioAsVoice === true,
|
||||
contentType: media.contentType,
|
||||
fileName: media.fileName,
|
||||
});
|
||||
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
|
||||
const isImage = msgtype === MsgType.Image;
|
||||
const imageInfo = isImage
|
||||
? await prepareImageInfo({ buffer: media.buffer, client })
|
||||
: undefined;
|
||||
const [firstChunk, ...rest] = chunks;
|
||||
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
||||
const content = buildMediaContent({
|
||||
msgtype,
|
||||
body,
|
||||
url: uploaded.url,
|
||||
file: uploaded.file,
|
||||
filename: media.fileName,
|
||||
mimetype: media.contentType,
|
||||
size: media.buffer.byteLength,
|
||||
durationMs,
|
||||
relation,
|
||||
isVoice: useVoice,
|
||||
imageInfo,
|
||||
});
|
||||
const eventId = await sendContent(content);
|
||||
lastMessageId = eventId ?? lastMessageId;
|
||||
const textChunks = useVoice ? chunks : rest;
|
||||
const followupRelation = threadId ? relation : undefined;
|
||||
for (const chunk of textChunks) {
|
||||
const text = chunk.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const followup = buildTextContent(text, followupRelation);
|
||||
const followupEventId = await sendContent(followup);
|
||||
lastMessageId = followupEventId ?? lastMessageId;
|
||||
}
|
||||
} else {
|
||||
for (const chunk of chunks.length ? chunks : [""]) {
|
||||
const text = chunk.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const content = buildTextContent(text, relation);
|
||||
const eventId = await sendContent(content);
|
||||
lastMessageId = eventId ?? lastMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: lastMessageId || "unknown",
|
||||
roomId,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPollMatrix(
|
||||
to: string,
|
||||
poll: PollInput,
|
||||
opts: MatrixSendOpts = {},
|
||||
): Promise<{ eventId: string; roomId: string }> {
|
||||
if (!poll.question?.trim()) {
|
||||
throw new Error("Matrix poll requires a question");
|
||||
}
|
||||
if (!poll.options?.length) {
|
||||
throw new Error("Matrix poll requires options");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveMatrixClient({
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const pollContent = buildPollStartContent(poll);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const pollPayload = threadId
|
||||
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
|
||||
: pollContent;
|
||||
// @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();
|
||||
}
|
||||
}
|
||||
}
|
||||
97
openclaw/extensions/matrix/src/matrix/send/client.ts
Normal file
97
openclaw/extensions/matrix/src/matrix/send/client.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
|
||||
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
export function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up account config with case-insensitive key fallback. */
|
||||
function findAccountConfig(
|
||||
accounts: Record<string, unknown> | undefined,
|
||||
accountId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!accounts) return undefined;
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
// Direct lookup first
|
||||
if (accounts[normalized]) return accounts[normalized] as Record<string, unknown>;
|
||||
// Case-insensitive fallback
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalized) {
|
||||
return accounts[key] as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveMediaMaxBytes(accountId?: string): number | undefined {
|
||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||
// Check account-specific config first (case-insensitive key matching)
|
||||
const accountConfig = findAccountConfig(
|
||||
cfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
|
||||
accountId ?? "",
|
||||
);
|
||||
if (typeof accountConfig?.mediaMaxMb === "number") {
|
||||
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
|
||||
}
|
||||
// Fall back to top-level config
|
||||
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;
|
||||
accountId?: string;
|
||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
const accountId =
|
||||
typeof opts.accountId === "string" && opts.accountId.trim().length > 0
|
||||
? normalizeAccountId(opts.accountId)
|
||||
: undefined;
|
||||
// Try to get the client for the specific account
|
||||
const active = getActiveMatrixClient(accountId);
|
||||
if (active) {
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
// When no account is specified, try the default account first; only fall back to
|
||||
// any active client as a last resort (prevents sending from an arbitrary account).
|
||||
if (!accountId) {
|
||||
const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID);
|
||||
if (defaultClient) {
|
||||
return { client: defaultClient, stopOnDone: false };
|
||||
}
|
||||
const anyActive = getAnyActiveMatrixClient();
|
||||
if (anyActive) {
|
||||
return { client: anyActive, stopOnDone: false };
|
||||
}
|
||||
}
|
||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ accountId });
|
||||
const client = await createPreparedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
93
openclaw/extensions/matrix/src/matrix/send/formatting.ts
Normal file
93
openclaw/extensions/matrix/src/matrix/send/formatting.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { markdownToMatrixHtml } from "../format.js";
|
||||
import {
|
||||
MsgType,
|
||||
RelationType,
|
||||
type MatrixFormattedContent,
|
||||
type MatrixMediaMsgType,
|
||||
type MatrixRelation,
|
||||
type MatrixReplyRelation,
|
||||
type MatrixTextContent,
|
||||
type MatrixThreadRelation,
|
||||
} from "./types.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
|
||||
const content: MatrixTextContent = relation
|
||||
? {
|
||||
msgtype: MsgType.Text,
|
||||
body,
|
||||
"m.relates_to": relation,
|
||||
}
|
||||
: {
|
||||
msgtype: MsgType.Text,
|
||||
body,
|
||||
};
|
||||
applyMatrixFormatting(content, body);
|
||||
return content;
|
||||
}
|
||||
|
||||
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
||||
const formatted = markdownToMatrixHtml(body ?? "");
|
||||
if (!formatted) {
|
||||
return;
|
||||
}
|
||||
content.format = "org.matrix.custom.html";
|
||||
content.formatted_body = formatted;
|
||||
}
|
||||
|
||||
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
||||
const trimmed = replyToId?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return { "m.in_reply_to": { event_id: trimmed } };
|
||||
}
|
||||
|
||||
export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
|
||||
const trimmed = threadId.trim();
|
||||
return {
|
||||
rel_type: RelationType.Thread,
|
||||
event_id: trimmed,
|
||||
is_falling_back: true,
|
||||
"m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
|
||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MsgType.Image;
|
||||
case "audio":
|
||||
return MsgType.Audio;
|
||||
case "video":
|
||||
return MsgType.Video;
|
||||
default:
|
||||
return MsgType.File;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMatrixVoiceDecision(opts: {
|
||||
wantsVoice: boolean;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
}): { useVoice: boolean } {
|
||||
if (!opts.wantsVoice) {
|
||||
return { useVoice: false };
|
||||
}
|
||||
if (isMatrixVoiceCompatibleAudio(opts)) {
|
||||
return { useVoice: true };
|
||||
}
|
||||
return { useVoice: false };
|
||||
}
|
||||
|
||||
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
||||
// Matrix currently shares the core voice compatibility policy.
|
||||
// Keep this wrapper as the seam if Matrix policy diverges later.
|
||||
return getCore().media.isVoiceCompatibleAudio({
|
||||
contentType: opts.contentType,
|
||||
fileName: opts.fileName,
|
||||
});
|
||||
}
|
||||
230
openclaw/extensions/matrix/src/matrix/send/media.ts
Normal file
230
openclaw/extensions/matrix/src/matrix/send/media.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import type {
|
||||
DimensionalFileInfo,
|
||||
EncryptedFile,
|
||||
FileWithThumbnailInfo,
|
||||
MatrixClient,
|
||||
TimedFileInfo,
|
||||
VideoFileInfo,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { applyMatrixFormatting } from "./formatting.js";
|
||||
import {
|
||||
type MatrixMediaContent,
|
||||
type MatrixMediaInfo,
|
||||
type MatrixMediaMsgType,
|
||||
type MatrixRelation,
|
||||
type MediaKind,
|
||||
} from "./types.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
type IFileInfo = import("music-metadata").IFileInfo;
|
||||
|
||||
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 { parseBuffer } = await import("music-metadata");
|
||||
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 };
|
||||
}
|
||||
98
openclaw/extensions/matrix/src/matrix/send/targets.test.ts
Normal file
98
openclaw/extensions/matrix/src/matrix/send/targets.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
||||
});
|
||||
|
||||
describe("resolveMatrixRoomId", () => {
|
||||
it("uses m.direct when available", async () => {
|
||||
const userId = "@user:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockResolvedValue({
|
||||
[userId]: ["!room:example.org"],
|
||||
}),
|
||||
getJoinedRooms: vi.fn(),
|
||||
getJoinedRoomMembers: vi.fn(),
|
||||
setAccountData: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const roomId = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(roomId).toBe("!room:example.org");
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||
// oxlint-disable-next-line typescript/unbound-method
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to joined rooms and persists m.direct", async () => {
|
||||
const userId = "@fallback:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]),
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({ [userId]: [roomId] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues when a room member lookup fails", async () => {
|
||||
const userId = "@continue:example.org";
|
||||
const roomId = "!good:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const getJoinedRoomMembers = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
||||
getJoinedRoomMembers,
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows larger rooms when no 1:1 match exists", async () => {
|
||||
const userId = "@group:example.org";
|
||||
const roomId = "!group:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi
|
||||
.fn()
|
||||
.mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]),
|
||||
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeThreadId", () => {
|
||||
it("returns null for empty thread ids", () => {
|
||||
expect(normalizeThreadId(" ")).toBeNull();
|
||||
expect(normalizeThreadId("$thread")).toBe("$thread");
|
||||
});
|
||||
});
|
||||
150
openclaw/extensions/matrix/src/matrix/send/targets.ts
Normal file
150
openclaw/extensions/matrix/src/matrix/send/targets.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth (#4948)
|
||||
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
|
||||
const directRoomCache = new Map<string, string>();
|
||||
function setDirectRoomCached(key: string, value: string): void {
|
||||
directRoomCache.set(key, value);
|
||||
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
|
||||
const oldest = directRoomCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
directRoomCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDirectRoom(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
let directContent: MatrixDirectAccountData | null = null;
|
||||
try {
|
||||
directContent = await client.getAccountData(EventType.Direct);
|
||||
} catch {
|
||||
// Ignore fetch errors and fall back to an empty map.
|
||||
}
|
||||
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
|
||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||
if (current[0] === roomId) {
|
||||
return;
|
||||
}
|
||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||
try {
|
||||
await client.setAccountData(EventType.Direct, {
|
||||
...existing,
|
||||
[userId]: next,
|
||||
});
|
||||
} catch {
|
||||
// Ignore persistence errors.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed.startsWith("@")) {
|
||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||
}
|
||||
|
||||
const cached = directRoomCache.get(trimmed);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||
try {
|
||||
const directContent = (await client.getAccountData(EventType.Direct)) as Record<
|
||||
string,
|
||||
string[] | undefined
|
||||
>;
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list && list.length > 0) {
|
||||
setDirectRoomCached(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
}
|
||||
|
||||
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
||||
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
||||
let fallbackRoom: string | null = null;
|
||||
try {
|
||||
const rooms = await client.getJoinedRooms();
|
||||
for (const roomId of rooms) {
|
||||
let members: string[];
|
||||
try {
|
||||
members = await client.getJoinedRoomMembers(roomId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!members.includes(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||
if (members.length === 2) {
|
||||
setDirectRoomCached(trimmed, roomId);
|
||||
await persistDirectRoom(client, trimmed, roomId);
|
||||
return roomId;
|
||||
}
|
||||
if (!fallbackRoom) {
|
||||
fallbackRoom = roomId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
}
|
||||
|
||||
if (fallbackRoom) {
|
||||
setDirectRoomCached(trimmed, fallbackRoom);
|
||||
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||
return fallbackRoom;
|
||||
}
|
||||
|
||||
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||
}
|
||||
|
||||
export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
|
||||
const target = normalizeTarget(raw);
|
||||
const lowered = target.toLowerCase();
|
||||
if (lowered.startsWith("matrix:")) {
|
||||
return await resolveMatrixRoomId(client, target.slice("matrix:".length));
|
||||
}
|
||||
if (lowered.startsWith("room:")) {
|
||||
return await resolveMatrixRoomId(client, target.slice("room:".length));
|
||||
}
|
||||
if (lowered.startsWith("channel:")) {
|
||||
return await resolveMatrixRoomId(client, target.slice("channel:".length));
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return await resolveDirectRoomId(client, target.slice("user:".length));
|
||||
}
|
||||
if (target.startsWith("@")) {
|
||||
return await resolveDirectRoomId(client, target);
|
||||
}
|
||||
if (target.startsWith("#")) {
|
||||
const resolved = await client.resolveRoom(target);
|
||||
if (!resolved) {
|
||||
throw new Error(`Matrix alias ${target} could not be resolved`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
109
openclaw/extensions/matrix/src/matrix/send/types.ts
Normal file
109
openclaw/extensions/matrix/src/matrix/send/types.ts
Normal 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;
|
||||
};
|
||||
446
openclaw/extensions/matrix/src/onboarding.ts
Normal file
446
openclaw/extensions/matrix/src/onboarding.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import type { DmPolicy } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
promptChannelAccessConfig,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
||||
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
|
||||
const allowFrom =
|
||||
policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Matrix requires a homeserver URL.",
|
||||
"Use an access token (recommended) 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 (full @user:server; display name only if unique)",
|
||||
placeholder: "@user:server",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const resolvedIds: string[] = [];
|
||||
const pending: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const unresolvedNotes: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (isFullUserId(part)) {
|
||||
resolvedIds.push(part);
|
||||
continue;
|
||||
}
|
||||
if (!canResolve) {
|
||||
unresolved.push(part);
|
||||
continue;
|
||||
}
|
||||
pending.push(part);
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const results = await resolveMatrixTargets({
|
||||
cfg,
|
||||
inputs: pending,
|
||||
kind: "user",
|
||||
}).catch(() => []);
|
||||
for (const result of results) {
|
||||
if (result?.resolved && result.id) {
|
||||
resolvedIds.push(result.id);
|
||||
continue;
|
||||
}
|
||||
if (result?.input) {
|
||||
unresolved.push(result.input);
|
||||
if (result.note) {
|
||||
unresolvedNotes.push(`${result.input}: ${result.note}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolved.length > 0) {
|
||||
const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
|
||||
await prompter.note(
|
||||
`Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
|
||||
"Matrix allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
dm: {
|
||||
...cfg.channels?.matrix?.dm,
|
||||
policy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
|
||||
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...cfg.channels?.matrix,
|
||||
enabled: true,
|
||||
groups,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Matrix",
|
||||
channel,
|
||||
policyKey: "channels.matrix.dm.policy",
|
||||
allowFromKey: "channels.matrix.dm.allowFrom",
|
||||
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
|
||||
promptAllowFrom: promptMatrixAllowFrom,
|
||||
};
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||
const configured = account.configured;
|
||||
const sdkReady = isMatrixSdkAvailable();
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [
|
||||
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
|
||||
],
|
||||
selectionHint: !sdkReady
|
||||
? "install @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)" },
|
||||
],
|
||||
});
|
||||
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
55
openclaw/extensions/matrix/src/outbound.ts
Normal file
55
openclaw/extensions/matrix/src/outbound.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
|
||||
export const matrixOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await send(to, text, {
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
channel: "matrix",
|
||||
messageId: result.messageId,
|
||||
roomId: result.roomId,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
|
||||
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
channel: "matrix",
|
||||
messageId: result.messageId,
|
||||
roomId: result.roomId,
|
||||
};
|
||||
},
|
||||
sendPoll: async ({ to, poll, threadId, accountId }) => {
|
||||
const resolvedThreadId =
|
||||
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
||||
const result = await sendPollMatrix(to, poll, {
|
||||
threadId: resolvedThreadId,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
channel: "matrix",
|
||||
messageId: result.eventId,
|
||||
roomId: result.roomId,
|
||||
pollId: result.eventId,
|
||||
};
|
||||
},
|
||||
};
|
||||
67
openclaw/extensions/matrix/src/resolve-targets.test.ts
Normal file
67
openclaw/extensions/matrix/src/resolve-targets.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
vi.mock("./directory-live.js", () => ({
|
||||
listMatrixDirectoryPeersLive: vi.fn(),
|
||||
listMatrixDirectoryGroupsLive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveMatrixTargets (users)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
|
||||
vi.mocked(listMatrixDirectoryGroupsLive).mockReset();
|
||||
});
|
||||
|
||||
it("resolves exact unique display name matches", async () => {
|
||||
const matches: ChannelDirectoryEntry[] = [
|
||||
{ kind: "user", id: "@alice:example.org", name: "Alice" },
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
|
||||
expect(result?.resolved).toBe(true);
|
||||
expect(result?.id).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
it("does not resolve ambiguous or non-exact matches", async () => {
|
||||
const matches: ChannelDirectoryEntry[] = [
|
||||
{ kind: "user", id: "@alice:example.org", name: "Alice" },
|
||||
{ kind: "user", id: "@alice:evil.example", name: "Alice" },
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
});
|
||||
|
||||
expect(result?.resolved).toBe(false);
|
||||
expect(result?.note).toMatch(/use full Matrix ID/i);
|
||||
});
|
||||
|
||||
it("prefers exact group matches over first partial result", async () => {
|
||||
const matches: ChannelDirectoryEntry[] = [
|
||||
{ kind: "group", id: "!one:example.org", name: "General", handle: "#general" },
|
||||
{ kind: "group", id: "!two:example.org", name: "Team", handle: "#team" },
|
||||
];
|
||||
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue(matches);
|
||||
|
||||
const [result] = await resolveMatrixTargets({
|
||||
cfg: {},
|
||||
inputs: ["#team"],
|
||||
kind: "group",
|
||||
});
|
||||
|
||||
expect(result?.resolved).toBe(true);
|
||||
expect(result?.id).toBe("!two:example.org");
|
||||
expect(result?.note).toBe("multiple matches; chose first");
|
||||
});
|
||||
});
|
||||
126
openclaw/extensions/matrix/src/resolve-targets.ts
Normal file
126
openclaw/extensions/matrix/src/resolve-targets.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
function findExactDirectoryMatches(
|
||||
matches: ChannelDirectoryEntry[],
|
||||
query: string,
|
||||
): ChannelDirectoryEntry[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
return matches.filter((match) => {
|
||||
const id = match.id.trim().toLowerCase();
|
||||
const name = match.name?.trim().toLowerCase();
|
||||
const handle = match.handle?.trim().toLowerCase();
|
||||
return normalized === id || normalized === name || normalized === handle;
|
||||
});
|
||||
}
|
||||
|
||||
function pickBestGroupMatch(
|
||||
matches: ChannelDirectoryEntry[],
|
||||
query: string,
|
||||
): ChannelDirectoryEntry | undefined {
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [exact] = findExactDirectoryMatches(matches, query);
|
||||
return exact ?? matches[0];
|
||||
}
|
||||
|
||||
function pickBestUserMatch(
|
||||
matches: ChannelDirectoryEntry[],
|
||||
query: string,
|
||||
): ChannelDirectoryEntry | undefined {
|
||||
if (matches.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const exact = findExactDirectoryMatches(matches, query);
|
||||
if (exact.length === 1) {
|
||||
return exact[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
|
||||
if (matches.length === 0) {
|
||||
return "no matches";
|
||||
}
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "empty input";
|
||||
}
|
||||
const exact = findExactDirectoryMatches(matches, normalized);
|
||||
if (exact.length === 0) {
|
||||
return "no exact match; use full Matrix ID";
|
||||
}
|
||||
if (exact.length > 1) {
|
||||
return "multiple exact matches; use full Matrix ID";
|
||||
}
|
||||
return "no exact match; use full Matrix ID";
|
||||
}
|
||||
|
||||
export async function resolveMatrixTargets(params: {
|
||||
cfg: unknown;
|
||||
inputs: string[];
|
||||
kind: ChannelResolveKind;
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<ChannelResolveResult[]> {
|
||||
const results: ChannelResolveResult[] = [];
|
||||
for (const input of params.inputs) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
results.push({ input, resolved: false, note: "empty input" });
|
||||
continue;
|
||||
}
|
||||
if (params.kind === "user") {
|
||||
if (trimmed.startsWith("@") && trimmed.includes(":")) {
|
||||
results.push({ input, resolved: true, id: trimmed });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const matches = await listMatrixDirectoryPeersLive({
|
||||
cfg: params.cfg,
|
||||
query: trimmed,
|
||||
limit: 5,
|
||||
});
|
||||
const best = pickBestUserMatch(matches, trimmed);
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.id),
|
||||
id: best?.id,
|
||||
name: best?.name,
|
||||
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
|
||||
});
|
||||
} catch (err) {
|
||||
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
|
||||
results.push({ input, resolved: false, note: "lookup failed" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const matches = await listMatrixDirectoryGroupsLive({
|
||||
cfg: params.cfg,
|
||||
query: trimmed,
|
||||
limit: 5,
|
||||
});
|
||||
const best = pickBestGroupMatch(matches, trimmed);
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.id),
|
||||
id: best?.id,
|
||||
name: best?.name,
|
||||
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
|
||||
results.push({ input, resolved: false, note: "lookup failed" });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
14
openclaw/extensions/matrix/src/runtime.ts
Normal file
14
openclaw/extensions/matrix/src/runtime.ts
Normal 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;
|
||||
}
|
||||
164
openclaw/extensions/matrix/src/tool-actions.ts
Normal file
164
openclaw/extensions/matrix/src/tool-actions.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import {
|
||||
deleteMatrixMessage,
|
||||
editMatrixMessage,
|
||||
getMatrixMemberInfo,
|
||||
getMatrixRoomInfo,
|
||||
listMatrixPins,
|
||||
listMatrixReactions,
|
||||
pinMatrixMessage,
|
||||
readMatrixMessages,
|
||||
removeMatrixReactions,
|
||||
sendMatrixMessage,
|
||||
unpinMatrixMessage,
|
||||
} from "./matrix/actions.js";
|
||||
import { reactMatrixMessage } from "./matrix/send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
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}`);
|
||||
}
|
||||
116
openclaw/extensions/matrix/src/types.ts
Normal file
116
openclaw/extensions/matrix/src/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
|
||||
export type { DmPolicy, GroupPolicy };
|
||||
|
||||
export type ReplyToMode = "off" | "first" | "all";
|
||||
|
||||
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 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 (matrix user IDs). */
|
||||
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;
|
||||
};
|
||||
|
||||
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
|
||||
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
|
||||
|
||||
export type MatrixConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start Matrix. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Multi-account configuration keyed by account ID. */
|
||||
accounts?: Record<string, MatrixAccountConfig>;
|
||||
/** 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 (matrix user IDs). */
|
||||
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";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/** 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 or alias (names resolved to IDs when possible). */
|
||||
groups?: Record<string, MatrixRoomConfig>;
|
||||
/** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
|
||||
rooms?: Record<string, MatrixRoomConfig>;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: MatrixActionConfig;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
matrix?: MatrixConfig;
|
||||
defaults?: {
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
};
|
||||
};
|
||||
commands?: {
|
||||
useAccessGroups?: boolean;
|
||||
};
|
||||
session?: {
|
||||
store?: string;
|
||||
};
|
||||
messages?: {
|
||||
ackReaction?: string;
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
Reference in New Issue
Block a user