Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
142
extensions/matrix/src/account-selection.test.ts
Normal file
142
extensions/matrix/src/account-selection.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// Matrix tests cover account selection plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./account-selection.js";
|
||||
import { getMatrixScopedEnvVarNames } from "./env-vars.js";
|
||||
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("matches the default account against normalized Matrix account keys", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Ops",
|
||||
accounts: {
|
||||
"Ops Bot": { homeserver: "https://matrix.example.org" },
|
||||
"Team Ops": { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires an explicit default when multiple Matrix accounts exist without one", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
alerts: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses a named "default" Matrix account when defaultAccount is unset', () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: { homeserver: "https://matrix.example.org" },
|
||||
ops: { homeserver: "https://matrix.example.org" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("default");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the raw Matrix account entry by normalized account id", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
"Team Ops": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers env-backed named Matrix accounts during enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the "default" Matrix account when mixed default and named env-backed accounts exist', () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
[keys.homeserver]: "https://matrix.example.org",
|
||||
[keys.accessToken]: "team-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
|
||||
});
|
||||
|
||||
it("discovers default Matrix accounts backed only by global env vars", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-secret",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]);
|
||||
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
|
||||
});
|
||||
});
|
||||
224
extensions/matrix/src/account-selection.ts
Normal file
224
extensions/matrix/src/account-selection.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// Matrix plugin module implements account selection behavior.
|
||||
import {
|
||||
listCombinedAccountIds,
|
||||
listConfiguredAccountIds,
|
||||
resolveListedDefaultAccountId,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveMatrixAccountStringValues,
|
||||
type MatrixResolvedStringField,
|
||||
} from "./auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds } from "./env-vars.js";
|
||||
import { isRecord } from "./record-shared.js";
|
||||
|
||||
type MatrixTopologyStringSources = Partial<Record<MatrixResolvedStringField, string>>;
|
||||
|
||||
function readConfiguredMatrixString(value: unknown): string {
|
||||
return normalizeOptionalString(value) ?? "";
|
||||
}
|
||||
|
||||
function readConfiguredMatrixSecretSource(value: unknown): string {
|
||||
return hasConfiguredSecretInput(value) ? "configured" : "";
|
||||
}
|
||||
|
||||
function resolveMatrixChannelStringSources(
|
||||
entry: Record<string, unknown> | null,
|
||||
): MatrixTopologyStringSources {
|
||||
if (!entry) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
homeserver: readConfiguredMatrixString(entry.homeserver),
|
||||
userId: readConfiguredMatrixString(entry.userId),
|
||||
accessToken: readConfiguredMatrixSecretSource(entry.accessToken),
|
||||
password: readConfiguredMatrixSecretSource(entry.password),
|
||||
deviceId: readConfiguredMatrixString(entry.deviceId),
|
||||
deviceName: readConfiguredMatrixString(entry.deviceName),
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvMatrixString(env: NodeJS.ProcessEnv, key: string): string {
|
||||
return normalizeOptionalString(env[key]) ?? "";
|
||||
}
|
||||
|
||||
function resolveScopedMatrixEnvStringSources(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): MatrixTopologyStringSources {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: readEnvMatrixString(env, keys.homeserver),
|
||||
userId: readEnvMatrixString(env, keys.userId),
|
||||
accessToken: readEnvMatrixString(env, keys.accessToken),
|
||||
password: readEnvMatrixString(env, keys.password),
|
||||
deviceId: readEnvMatrixString(env, keys.deviceId),
|
||||
deviceName: readEnvMatrixString(env, keys.deviceName),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopologyStringSources {
|
||||
return {
|
||||
homeserver: readEnvMatrixString(env, "MATRIX_HOMESERVER"),
|
||||
userId: readEnvMatrixString(env, "MATRIX_USER_ID"),
|
||||
accessToken: readEnvMatrixString(env, "MATRIX_ACCESS_TOKEN"),
|
||||
password: readEnvMatrixString(env, "MATRIX_PASSWORD"),
|
||||
deviceId: readEnvMatrixString(env, "MATRIX_DEVICE_ID"),
|
||||
deviceName: readEnvMatrixString(env, "MATRIX_DEVICE_NAME"),
|
||||
};
|
||||
}
|
||||
|
||||
function hasUsableResolvedMatrixAuth(values: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
}): boolean {
|
||||
// Account discovery must keep homeserver+userId shapes because auth can still
|
||||
// resolve through cached Matrix credentials even when no fresh token/password
|
||||
// is present in config or env.
|
||||
return Boolean(values.homeserver && (values.accessToken || values.userId));
|
||||
}
|
||||
|
||||
function hasFreshResolvedMatrixAuth(values: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password: string;
|
||||
}): boolean {
|
||||
return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password)));
|
||||
}
|
||||
|
||||
function resolveEffectiveMatrixAccountSources(params: {
|
||||
channel: Record<string, unknown> | null;
|
||||
accountId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): ReturnType<typeof resolveMatrixAccountStringValues> {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
return resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env),
|
||||
channel: resolveMatrixChannelStringSources(params.channel),
|
||||
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
|
||||
});
|
||||
}
|
||||
|
||||
function hasUsableEffectiveMatrixAccountSource(params: {
|
||||
channel: Record<string, unknown> | null;
|
||||
accountId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
return hasUsableResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
|
||||
}
|
||||
|
||||
function hasFreshEffectiveMatrixAccountSource(params: {
|
||||
channel: Record<string, unknown> | null;
|
||||
accountId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
return hasFreshResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
|
||||
}
|
||||
|
||||
function hasConfiguredDefaultMatrixAccountSource(params: {
|
||||
channel: Record<string, unknown> | null;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
return hasFreshEffectiveMatrixAccountSource({
|
||||
channel: params.channel,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
export function findMatrixAccountEntry(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> | null {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return null;
|
||||
}
|
||||
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
|
||||
return isRecord(entry) ? entry : null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredMatrixAccountIds(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
const configuredAccountIds = listConfiguredAccountIds({
|
||||
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
|
||||
normalizeAccountId,
|
||||
});
|
||||
if (hasConfiguredDefaultMatrixAccountSource({ channel, env })) {
|
||||
configuredAccountIds.push(DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) =>
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID
|
||||
? hasConfiguredDefaultMatrixAccountSource({ channel, env })
|
||||
: hasUsableEffectiveMatrixAccountSource({ channel, accountId, env }),
|
||||
);
|
||||
return listCombinedAccountIds({
|
||||
configuredAccountIds,
|
||||
additionalAccountIds: readyEnvAccountIds,
|
||||
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatrixDefaultOrOnlyAccountId(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
|
||||
return resolveListedDefaultAccountId({
|
||||
accountIds: configuredAccountIds,
|
||||
configuredDefaultAccountId: configuredDefault,
|
||||
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
}
|
||||
|
||||
export function requiresExplicitMatrixDefaultAccount(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
|
||||
if (configuredAccountIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return false;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
|
||||
}
|
||||
238
extensions/matrix/src/actions.account-propagation.test.ts
Normal file
238
extensions/matrix/src/actions.account-propagation.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
// Matrix tests cover actions.account propagation plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessageActionContext } from "../runtime-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleMatrixAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./tool-actions.js", () => ({
|
||||
handleMatrixAction: mocks.handleMatrixAction,
|
||||
}));
|
||||
|
||||
const { matrixMessageActions } = await import("./actions.js");
|
||||
|
||||
const profileAction = "set-profile" as ChannelMessageActionContext["action"];
|
||||
|
||||
function matrixActionCall() {
|
||||
const call = mocks.handleMatrixAction.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("expected handleMatrixAction call");
|
||||
}
|
||||
return {
|
||||
input: call[0] as Record<string, unknown>,
|
||||
cfg: call[1],
|
||||
options: call[2],
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<ChannelMessageActionContext>,
|
||||
): ChannelMessageActionContext {
|
||||
return {
|
||||
channel: "matrix",
|
||||
action: "send",
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
params: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrixMessageActions account propagation", () => {
|
||||
beforeEach(() => {
|
||||
mocks.handleMatrixAction.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
output: "",
|
||||
details: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards accountId for send actions", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
message: "hello",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("sendMessage");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
|
||||
it("forwards accountId for permissions actions", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "permissions",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
operation: "verification-list",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("verificationList");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
|
||||
it("forwards accountId for self-profile updates", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: profileAction,
|
||||
accountId: "ops",
|
||||
senderIsOwner: true,
|
||||
params: {
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("setProfile");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.displayName).toBe("Ops Bot");
|
||||
expect(call.input.avatarUrl).toBe("mxc://example/avatar");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
|
||||
it("rejects self-profile updates without sender owner context", async () => {
|
||||
await expect(
|
||||
matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: profileAction,
|
||||
accountId: "ops",
|
||||
params: {
|
||||
displayName: "Ops Bot",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Matrix profile updates require owner access.");
|
||||
});
|
||||
|
||||
it("dispatches self-profile updates with sender owner context", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: profileAction,
|
||||
accountId: "ops",
|
||||
senderIsOwner: true,
|
||||
params: {
|
||||
displayName: "Ops Bot",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input).toMatchObject({
|
||||
action: "setProfile",
|
||||
accountId: "ops",
|
||||
displayName: "Ops Bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards local avatar paths for self-profile updates", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: profileAction,
|
||||
accountId: "ops",
|
||||
senderIsOwner: true,
|
||||
params: {
|
||||
path: "/tmp/avatar.jpg",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("setProfile");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.avatarPath).toBe("/tmp/avatar.jpg");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots for media sends", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
message: "hello",
|
||||
media: "file:///tmp/photo.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("sendMessage");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] });
|
||||
});
|
||||
|
||||
it("allows media-only sends without requiring a message body", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
media: "file:///tmp/photo.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("sendMessage");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.content).toBeUndefined();
|
||||
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
|
||||
it("accepts shared media aliases and forwards voice-send intent", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
filePath: "/tmp/clip.mp3",
|
||||
asVoice: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const call = matrixActionCall();
|
||||
expect(call.input.action).toBe("sendMessage");
|
||||
expect(call.input.accountId).toBe("ops");
|
||||
expect(call.input.content).toBeUndefined();
|
||||
expect(call.input.mediaUrl).toBe("/tmp/clip.mp3");
|
||||
expect(call.input.audioAsVoice).toBe(true);
|
||||
expect(call.cfg).toBeTypeOf("object");
|
||||
expect(call.options).toEqual({ mediaLocalRoots: undefined });
|
||||
});
|
||||
});
|
||||
249
extensions/matrix/src/actions.test.ts
Normal file
249
extensions/matrix/src/actions.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// Matrix tests cover actions plugin behavior.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const profileAction = "set-profile" as const;
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
current: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: async () => null,
|
||||
resizeToJpeg: async () => Buffer.from(""),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: () => "/tmp/openclaw-matrix-test",
|
||||
},
|
||||
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;
|
||||
|
||||
function createConfiguredMatrixConfig(): CoreConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
describe("matrixMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("exposes poll create but only handles poll votes inside the plugin", () => {
|
||||
const describeMessageTool = matrixMessageActions.describeMessageTool;
|
||||
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
|
||||
|
||||
expect(describeMessageTool).toBeTypeOf("function");
|
||||
expect(supportsAction).toBeTypeOf("function");
|
||||
|
||||
const discovery = describeMessageTool({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
if (!discovery) {
|
||||
throw new Error("describeMessageTool returned null");
|
||||
}
|
||||
const actions = discovery.actions;
|
||||
expect(actions).toContain("poll");
|
||||
expect(actions).toContain("poll-vote");
|
||||
expect(supportsAction({ action: "poll" } as never)).toBe(false);
|
||||
expect(supportsAction({ action: "poll-vote" } as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes and describes self-profile updates", () => {
|
||||
const describeMessageTool = matrixMessageActions.describeMessageTool;
|
||||
const supportsAction = matrixMessageActions.supportsAction ?? (() => false);
|
||||
|
||||
const discovery = describeMessageTool({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
senderIsOwner: true,
|
||||
} as never);
|
||||
if (!discovery) {
|
||||
throw new Error("describeMessageTool returned null");
|
||||
}
|
||||
const actions = discovery.actions;
|
||||
const schema = discovery.schema;
|
||||
if (!schema) {
|
||||
throw new Error("matrix schema missing");
|
||||
}
|
||||
const properties = (schema as { properties?: Record<string, unknown> }).properties ?? {};
|
||||
|
||||
expect(actions).toContain(profileAction);
|
||||
expect(supportsAction({ action: profileAction } as never)).toBe(true);
|
||||
expect(discovery.mediaSourceParams).toEqual({
|
||||
"set-profile": ["avatarUrl", "avatarPath"],
|
||||
});
|
||||
expect(Object.keys(properties).toSorted()).toEqual([
|
||||
"avatarPath",
|
||||
"avatarUrl",
|
||||
"avatar_path",
|
||||
"avatar_url",
|
||||
"displayName",
|
||||
"display_name",
|
||||
]);
|
||||
expect(properties.displayName).toHaveProperty("type", "string");
|
||||
expect(properties.avatarUrl).toHaveProperty("type", "string");
|
||||
expect(properties.avatarPath).toHaveProperty("type", "string");
|
||||
});
|
||||
|
||||
it("hides self-profile updates without owner identity context", () => {
|
||||
const discovery = matrixMessageActions.describeMessageTool({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
if (!discovery) {
|
||||
throw new Error("describeMessageTool returned null");
|
||||
}
|
||||
|
||||
expect(discovery.actions).not.toContain(profileAction);
|
||||
});
|
||||
|
||||
it("hides gated actions when the default Matrix account disables them", () => {
|
||||
const discovery = matrixMessageActions.describeMessageTool({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never);
|
||||
if (!discovery) {
|
||||
throw new Error("describeMessageTool returned null");
|
||||
}
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const discovery = matrixMessageActions.describeMessageTool({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never);
|
||||
if (!discovery) {
|
||||
throw new Error("describeMessageTool returned null");
|
||||
}
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("honors the selected Matrix account during discovery", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@assistant:example.org",
|
||||
accessToken: "assistant-token",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: false,
|
||||
},
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const describeMessageTool = matrixMessageActions.describeMessageTool;
|
||||
if (!describeMessageTool) {
|
||||
throw new Error("matrix message action discovery is unavailable");
|
||||
}
|
||||
|
||||
const assistantDiscovery = describeMessageTool({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
} as never);
|
||||
const opsDiscovery = describeMessageTool({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
} as never);
|
||||
|
||||
if (!assistantDiscovery || !opsDiscovery) {
|
||||
throw new Error("matrix action discovery returned null");
|
||||
}
|
||||
|
||||
const assistantActions = assistantDiscovery.actions;
|
||||
const opsActions = opsDiscovery.actions;
|
||||
|
||||
expect(assistantActions).not.toContain("react");
|
||||
expect(assistantActions).not.toContain("reactions");
|
||||
expect(opsActions).toContain("react");
|
||||
expect(opsActions).toContain("reactions");
|
||||
});
|
||||
});
|
||||
352
extensions/matrix/src/actions.ts
Normal file
352
extensions/matrix/src/actions.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// Matrix plugin module implements actions behavior.
|
||||
import {
|
||||
createActionGate,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
ToolAuthorizationError,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionContext,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { Type } from "typebox";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
"poll-vote",
|
||||
"react",
|
||||
"reactions",
|
||||
"read",
|
||||
"edit",
|
||||
"delete",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"set-profile",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"permissions",
|
||||
]);
|
||||
const MATRIX_PROFILE_MEDIA_PROPERTIES = {
|
||||
avatarUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Profile avatar URL for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
avatar_url: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"snake_case alias of avatarUrl for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
avatarPath: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Local avatar file path for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
|
||||
}),
|
||||
),
|
||||
avatar_path: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"snake_case alias of avatarPath for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
|
||||
}),
|
||||
),
|
||||
} as const;
|
||||
const MATRIX_PROFILE_MEDIA_SOURCE_PARAMS = Object.freeze(["avatarUrl", "avatarPath"]);
|
||||
|
||||
function createMatrixExposedActions(params: {
|
||||
gate: ReturnType<typeof createActionGate>;
|
||||
encryptionEnabled: boolean;
|
||||
senderIsOwner?: boolean;
|
||||
}) {
|
||||
const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
|
||||
if (params.gate("messages")) {
|
||||
actions.add("send");
|
||||
actions.add("read");
|
||||
actions.add("edit");
|
||||
actions.add("delete");
|
||||
}
|
||||
if (params.gate("reactions")) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
if (params.gate("pins")) {
|
||||
actions.add("pin");
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (params.gate("profile") && params.senderIsOwner === true) {
|
||||
actions.add("set-profile");
|
||||
}
|
||||
if (params.gate("memberInfo")) {
|
||||
actions.add("member-info");
|
||||
}
|
||||
if (params.gate("channelInfo")) {
|
||||
actions.add("channel-info");
|
||||
}
|
||||
if (params.encryptionEnabled && params.gate("verification")) {
|
||||
actions.add("permissions");
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery["schema"]> {
|
||||
return {
|
||||
actions: ["set-profile"],
|
||||
properties: {
|
||||
displayName: Type.Optional(
|
||||
Type.String({
|
||||
description: "Profile display name for Matrix self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
display_name: Type.Optional(
|
||||
Type.String({
|
||||
description: "snake_case alias of displayName for Matrix self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
...MATRIX_PROFILE_MEDIA_PROPERTIES,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return { actions: [], capabilities: [] };
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: resolvedCfg,
|
||||
accountId: accountId ?? resolveDefaultMatrixAccountId(resolvedCfg),
|
||||
});
|
||||
if (!account.enabled || !account.configured) {
|
||||
return { actions: [], capabilities: [] };
|
||||
}
|
||||
const gate = createActionGate(account.config.actions);
|
||||
const actions = createMatrixExposedActions({
|
||||
gate,
|
||||
encryptionEnabled: account.config.encryption === true,
|
||||
senderIsOwner,
|
||||
});
|
||||
const listedActions = Array.from(actions);
|
||||
return {
|
||||
actions: listedActions,
|
||||
capabilities: [],
|
||||
schema: listedActions.includes("set-profile") ? buildMatrixProfileToolSchema() : null,
|
||||
mediaSourceParams: listedActions.includes("set-profile")
|
||||
? { "set-profile": MATRIX_PROFILE_MEDIA_SOURCE_PARAMS }
|
||||
: null,
|
||||
};
|
||||
},
|
||||
supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }) => {
|
||||
return extractToolSend(args, "sendMessage");
|
||||
},
|
||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||
const { handleMatrixAction } = await import("./tool-actions.runtime.js");
|
||||
const { action, params, cfg, accountId, mediaLocalRoots } = ctx;
|
||||
const dispatch = async (actionParams: Record<string, unknown>) =>
|
||||
await handleMatrixAction(
|
||||
{
|
||||
...actionParams,
|
||||
...(accountId ? { accountId } : {}),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
const resolveRoomId = () =>
|
||||
readStringParam(params, "roomId") ??
|
||||
readStringParam(params, "channelId") ??
|
||||
readStringParam(params, "to", { required: true });
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl =
|
||||
readStringParam(params, "media", { trim: false }) ??
|
||||
readStringParam(params, "mediaUrl", { trim: false }) ??
|
||||
readStringParam(params, "filePath", { trim: false }) ??
|
||||
readStringParam(params, "path", { trim: false });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: !mediaUrl,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
const audioAsVoice =
|
||||
typeof params.asVoice === "boolean"
|
||||
? params.asVoice
|
||||
: typeof params.audioAsVoice === "boolean"
|
||||
? params.audioAsVoice
|
||||
: undefined;
|
||||
return await dispatch({
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToId: replyTo ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
audioAsVoice,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "poll-vote") {
|
||||
return await dispatch({
|
||||
...params,
|
||||
action: "pollVote",
|
||||
});
|
||||
}
|
||||
|
||||
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 dispatch({
|
||||
action: "react",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readPositiveIntegerParam(params, "limit", {
|
||||
message: "limit must be a positive integer.",
|
||||
});
|
||||
return await dispatch({
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readPositiveIntegerParam(params, "limit", {
|
||||
message: "limit must be a positive integer.",
|
||||
});
|
||||
return await dispatch({
|
||||
action: "readMessages",
|
||||
roomId: resolveRoomId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
threadId: readStringParam(params, "threadId"),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const content = readStringParam(params, "message", { required: true });
|
||||
return await dispatch({
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
return await dispatch({
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
const messageId =
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await dispatch({
|
||||
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "set-profile") {
|
||||
if (ctx.senderIsOwner !== true) {
|
||||
throw new ToolAuthorizationError("Matrix profile updates require owner access.");
|
||||
}
|
||||
const avatarPath =
|
||||
readStringParam(params, "avatarPath") ??
|
||||
readStringParam(params, "path") ??
|
||||
readStringParam(params, "filePath");
|
||||
return await dispatch({
|
||||
action: "setProfile",
|
||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||
avatarPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await dispatch({
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
return await dispatch({
|
||||
action: "channelInfo",
|
||||
roomId: resolveRoomId(),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "permissions") {
|
||||
const operation = normalizeLowercaseStringOrEmpty(
|
||||
readStringParam(params, "operation") ??
|
||||
readStringParam(params, "mode") ??
|
||||
"verification-list",
|
||||
);
|
||||
const operationToAction: Record<string, string> = {
|
||||
"encryption-status": "encryptionStatus",
|
||||
"verification-status": "verificationStatus",
|
||||
"verification-bootstrap": "verificationBootstrap",
|
||||
"verification-recovery-key": "verificationRecoveryKey",
|
||||
"verification-backup-status": "verificationBackupStatus",
|
||||
"verification-backup-restore": "verificationBackupRestore",
|
||||
"verification-list": "verificationList",
|
||||
"verification-request": "verificationRequest",
|
||||
"verification-accept": "verificationAccept",
|
||||
"verification-cancel": "verificationCancel",
|
||||
"verification-start": "verificationStart",
|
||||
"verification-generate-qr": "verificationGenerateQr",
|
||||
"verification-scan-qr": "verificationScanQr",
|
||||
"verification-sas": "verificationSas",
|
||||
"verification-confirm": "verificationConfirm",
|
||||
"verification-mismatch": "verificationMismatch",
|
||||
"verification-confirm-qr": "verificationConfirmQr",
|
||||
};
|
||||
const resolvedAction = operationToAction[operation];
|
||||
if (!resolvedAction) {
|
||||
throw new Error(
|
||||
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
|
||||
operationToAction,
|
||||
).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return await dispatch({
|
||||
...params,
|
||||
action: resolvedAction,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider matrix.`);
|
||||
},
|
||||
};
|
||||
24
extensions/matrix/src/approval-auth.test.ts
Normal file
24
extensions/matrix/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Matrix tests cover approval auth plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matrixApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("matrixApprovalAuth", () => {
|
||||
it("normalizes Matrix user ids before authorizing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: { allowFrom: ["matrix:@Owner:Example.org"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matrixApprovalAuth.authorizeActorAction({
|
||||
cfg,
|
||||
senderId: "@owner:example.org",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
26
extensions/matrix/src/approval-auth.ts
Normal file
26
extensions/matrix/src/approval-auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Matrix plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export function getMatrixApprovalAuthApprovers(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveMatrixAccount(params);
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.config.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
export const matrixApprovalAuth = createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Matrix",
|
||||
resolveApprovers: ({ cfg, accountId }) =>
|
||||
getMatrixApprovalAuthApprovers({ cfg: cfg as CoreConfig, accountId }),
|
||||
normalizeSenderId: (value) => normalizeMatrixApproverId(value),
|
||||
});
|
||||
577
extensions/matrix/src/approval-handler.runtime.test.ts
Normal file
577
extensions/matrix/src/approval-handler.runtime.test.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
// Matrix tests cover approval handler plugin behavior.
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { matrixApprovalNativeRuntime } from "./approval-handler.runtime.js";
|
||||
import {
|
||||
clearMatrixApprovalReactionTargetsForTest,
|
||||
resolveMatrixApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
|
||||
type MatrixDeliverPendingParams = Parameters<
|
||||
typeof matrixApprovalNativeRuntime.transport.deliverPending
|
||||
>[0];
|
||||
type MatrixPendingApprovalView = MatrixDeliverPendingParams["view"];
|
||||
type MatrixPendingExecApprovalView = Extract<MatrixPendingApprovalView, { approvalKind: "exec" }>;
|
||||
type MatrixPendingPluginApprovalView = Extract<
|
||||
MatrixPendingApprovalView,
|
||||
{ approvalKind: "plugin" }
|
||||
>;
|
||||
|
||||
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval";
|
||||
|
||||
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Expected record");
|
||||
}
|
||||
const actual = value as Record<string, unknown>;
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
expect(actual[key]).toEqual(expectedValue);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
function mockCall<T extends readonly unknown[]>(mock: { mock: { calls: T[] } }, index = 0) {
|
||||
return mock.mock.calls.at(index);
|
||||
}
|
||||
|
||||
function buildMatrixReceipt(messageIds: readonly string[], roomId = "!room:example.org") {
|
||||
return {
|
||||
primaryPlatformMessageId: messageIds[0],
|
||||
platformMessageIds: [...messageIds],
|
||||
parts: messageIds.map((messageId, index) => ({
|
||||
platformMessageId: messageId,
|
||||
kind: "text" as const,
|
||||
index,
|
||||
raw: {
|
||||
channel: "matrix",
|
||||
messageId,
|
||||
roomId,
|
||||
},
|
||||
})),
|
||||
sentAt: 100,
|
||||
raw: messageIds.map((messageId) => ({
|
||||
channel: "matrix",
|
||||
messageId,
|
||||
roomId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatrixApprovalRoomTarget(
|
||||
roomId: string,
|
||||
): MatrixDeliverPendingParams["plannedTarget"] {
|
||||
return {
|
||||
surface: "approver-dm",
|
||||
target: {
|
||||
to: `room:${roomId}`,
|
||||
},
|
||||
reason: "preferred",
|
||||
};
|
||||
}
|
||||
|
||||
// Pending approvals expire in the future; the reaction target store TTLs its
|
||||
// memory layer from `view.expiresAtMs - now`, so epoch-past fixtures would
|
||||
// evict the mapping before assertions run.
|
||||
const TEST_APPROVAL_EXPIRES_AT_MS = Date.now() + 5 * 60_000;
|
||||
|
||||
function buildExecApprovalView(
|
||||
overrides: Partial<MatrixPendingExecApprovalView> = {},
|
||||
): MatrixPendingExecApprovalView {
|
||||
return {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-1",
|
||||
phase: "pending",
|
||||
title: "Exec Approval Required",
|
||||
description: "A command needs your approval.",
|
||||
metadata: [],
|
||||
ask: "on-request",
|
||||
agentId: "agent-1",
|
||||
commandText: "echo hi",
|
||||
commandPreview: "echo hi",
|
||||
cwd: "/repo",
|
||||
host: "gateway",
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve req-1 allow-once",
|
||||
},
|
||||
{
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/approve req-1 deny",
|
||||
},
|
||||
],
|
||||
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPluginApprovalView(
|
||||
overrides: Partial<MatrixPendingPluginApprovalView> = {},
|
||||
): MatrixPendingPluginApprovalView {
|
||||
return {
|
||||
approvalKind: "plugin",
|
||||
approvalId: "plugin:req-1",
|
||||
phase: "pending",
|
||||
title: "Plugin Approval Required",
|
||||
description: "Approve the tool call.",
|
||||
metadata: [],
|
||||
agentId: "agent-1",
|
||||
pluginId: "ops",
|
||||
toolName: "deploy",
|
||||
severity: "critical",
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve plugin:req-1 allow-once",
|
||||
},
|
||||
],
|
||||
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPendingPayload(view: MatrixPendingApprovalView) {
|
||||
const request =
|
||||
view.approvalKind === "plugin"
|
||||
? ({
|
||||
id: view.approvalId,
|
||||
request: {
|
||||
title: view.title,
|
||||
description: view.description ?? "",
|
||||
severity: view.severity,
|
||||
toolName: view.toolName ?? undefined,
|
||||
pluginId: view.pluginId ?? undefined,
|
||||
agentId: view.agentId ?? undefined,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
} satisfies PluginApprovalRequest)
|
||||
: ({
|
||||
id: view.approvalId,
|
||||
request: {
|
||||
command: view.commandText,
|
||||
cwd: view.cwd ?? undefined,
|
||||
host: view.host ?? undefined,
|
||||
agentId: view.agentId ?? undefined,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: view.expiresAtMs,
|
||||
} satisfies ExecApprovalRequest);
|
||||
return await matrixApprovalNativeRuntime.presentation.buildPendingPayload({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: { client: {} as never },
|
||||
request,
|
||||
approvalKind: view.approvalKind,
|
||||
nowMs: 100,
|
||||
view,
|
||||
});
|
||||
}
|
||||
|
||||
describe("matrixApprovalNativeRuntime", () => {
|
||||
beforeEach(() => {
|
||||
clearMatrixApprovalReactionTargetsForTest();
|
||||
});
|
||||
|
||||
it("sends versioned Matrix approval content with pending exec approvals", async () => {
|
||||
const sendSingleTextMessage = vi.fn().mockResolvedValue({
|
||||
messageId: "$approval",
|
||||
primaryMessageId: "$approval",
|
||||
receipt: buildMatrixReceipt(["$approval"]),
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
const reactMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const view = buildExecApprovalView();
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
await matrixApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
sendSingleTextMessage,
|
||||
reactMessage,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
|
||||
preparedTarget: {
|
||||
to: "room:!room:example.org",
|
||||
roomId: "!room:example.org",
|
||||
},
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
|
||||
expect(target).toBe("room:!room:example.org");
|
||||
expect(String(text)).toContain("echo hi");
|
||||
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
|
||||
?.extraContent;
|
||||
expectRecordFields(extraContent?.[MATRIX_APPROVAL_METADATA_KEY], {
|
||||
version: 1,
|
||||
type: "approval.request",
|
||||
state: "pending",
|
||||
id: "req-1",
|
||||
kind: "exec",
|
||||
commandText: "echo hi",
|
||||
cwd: "/repo",
|
||||
agentId: "agent-1",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers Matrix approval content with plugin approval fields", async () => {
|
||||
const sendSingleTextMessage = vi.fn().mockResolvedValue({
|
||||
messageId: "$plugin-approval",
|
||||
primaryMessageId: "$plugin-approval",
|
||||
receipt: buildMatrixReceipt(["$plugin-approval"]),
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
const reactMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const view = buildPluginApprovalView();
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
await matrixApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
sendSingleTextMessage,
|
||||
reactMessage,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "plugin",
|
||||
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
|
||||
preparedTarget: {
|
||||
to: "room:!room:example.org",
|
||||
roomId: "!room:example.org",
|
||||
},
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
const [target, text, options] = mockCall(sendSingleTextMessage) ?? [];
|
||||
expect(target).toBe("room:!room:example.org");
|
||||
expect(String(text)).toContain("deploy");
|
||||
const extraContent = (options as { extraContent?: Record<string, unknown> } | undefined)
|
||||
?.extraContent;
|
||||
expect(extraContent?.[MATRIX_APPROVAL_METADATA_KEY]).toEqual({
|
||||
version: 1,
|
||||
type: "approval.request",
|
||||
state: "pending",
|
||||
phase: "pending",
|
||||
id: "plugin:req-1",
|
||||
kind: "plugin",
|
||||
title: "Plugin Approval Required",
|
||||
description: "Approve the tool call.",
|
||||
expiresAtMs: TEST_APPROVAL_EXPIRES_AT_MS,
|
||||
metadata: [],
|
||||
allowedDecisions: ["allow-once"],
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve plugin:req-1 allow-once",
|
||||
},
|
||||
],
|
||||
pluginId: "ops",
|
||||
toolName: "deploy",
|
||||
agentId: "agent-1",
|
||||
severity: "critical",
|
||||
});
|
||||
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
|
||||
expect(mockCall(reactMessage)?.[1]).toBe("$plugin-approval");
|
||||
expect(mockCall(reactMessage)?.[2]).toBe("✅");
|
||||
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
|
||||
});
|
||||
|
||||
it("binds Matrix approval reactions before publishing option reactions", async () => {
|
||||
const sendSingleTextMessage = vi.fn().mockResolvedValue({
|
||||
messageId: "$approval",
|
||||
primaryMessageId: "$approval",
|
||||
receipt: buildMatrixReceipt(["$approval"]),
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
const reactMessage = vi.fn().mockImplementation(async () => {
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$approval",
|
||||
reactionKey: "✅",
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
const view = buildExecApprovalView();
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
await matrixApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
sendSingleTextMessage,
|
||||
reactMessage,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
|
||||
preparedTarget: {
|
||||
to: "room:!room:example.org",
|
||||
roomId: "!room:example.org",
|
||||
},
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
expect(reactMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries transient Matrix approval send failures", async () => {
|
||||
const sendSingleTextMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("transient Matrix send failure"))
|
||||
.mockResolvedValue({
|
||||
messageId: "$approval",
|
||||
primaryMessageId: "$approval",
|
||||
receipt: buildMatrixReceipt(["$approval"]),
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
const reactMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const view = buildExecApprovalView();
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
sendSingleTextMessage,
|
||||
reactMessage,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
|
||||
preparedTarget: {
|
||||
to: "room:!room:example.org",
|
||||
roomId: "!room:example.org",
|
||||
},
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
expect(sendSingleTextMessage).toHaveBeenCalledTimes(2);
|
||||
expectRecordFields(entry, {
|
||||
roomId: "!room:example.org",
|
||||
platformMessageIds: ["$approval"],
|
||||
});
|
||||
});
|
||||
|
||||
it("retries transient Matrix direct-room repair failures before preparing approval DMs", async () => {
|
||||
const repairDirectRooms = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("direct account data not ready"))
|
||||
.mockResolvedValue({
|
||||
activeRoomId: "!dm:example.org",
|
||||
});
|
||||
|
||||
const prepared = await matrixApprovalNativeRuntime.transport.prepareTarget({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
repairDirectRooms,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
view: buildExecApprovalView(),
|
||||
pendingPayload: {} as never,
|
||||
plannedTarget: {
|
||||
surface: "approver-dm",
|
||||
target: {
|
||||
to: "user:@owner:example.org",
|
||||
},
|
||||
reason: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
expect(repairDirectRooms).toHaveBeenCalledTimes(2);
|
||||
const preparedTarget = expectRecordFields(prepared, {});
|
||||
expect(preparedTarget.target).toEqual({
|
||||
to: "room:!dm:example.org",
|
||||
roomId: "!dm:example.org",
|
||||
threadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to chunked Matrix delivery when approval content exceeds one event", async () => {
|
||||
const sendSingleTextMessage = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Matrix single-message text exceeds limit (5000 > 4000)"));
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
messageId: "$last",
|
||||
primaryMessageId: "$legacy-primary",
|
||||
receipt: buildMatrixReceipt(["$primary", "$last"]),
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
const reactMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const view = buildExecApprovalView({
|
||||
actions: [
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve req-1 allow-once",
|
||||
},
|
||||
],
|
||||
});
|
||||
const pendingPayload = await buildPendingPayload(view);
|
||||
|
||||
const entry = await matrixApprovalNativeRuntime.transport.deliverPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
deps: {
|
||||
sendSingleTextMessage,
|
||||
sendMessage,
|
||||
reactMessage,
|
||||
},
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
plannedTarget: buildMatrixApprovalRoomTarget("!room:example.org"),
|
||||
preparedTarget: {
|
||||
to: "room:!room:example.org",
|
||||
roomId: "!room:example.org",
|
||||
},
|
||||
view,
|
||||
pendingPayload,
|
||||
});
|
||||
|
||||
expect(mockCall(sendMessage)?.[0]).toBe("room:!room:example.org");
|
||||
expect(mockCall(sendMessage)?.[1]).toBe(pendingPayload.text);
|
||||
expectRecordFields(mockCall(sendMessage)?.[2], {
|
||||
accountId: "default",
|
||||
extraContent: pendingPayload.extraContent,
|
||||
});
|
||||
expect(mockCall(reactMessage)?.[0]).toBe("!room:example.org");
|
||||
expect(mockCall(reactMessage)?.[1]).toBe("$primary");
|
||||
expect(typeof mockCall(reactMessage)?.[2]).toBe("string");
|
||||
expectRecordFields(mockCall(reactMessage)?.[3], { accountId: "default" });
|
||||
expectRecordFields(entry, {
|
||||
roomId: "!room:example.org",
|
||||
platformMessageIds: ["$primary", "$last"],
|
||||
reactionEventId: "$primary",
|
||||
});
|
||||
const bindPending = matrixApprovalNativeRuntime.interactions?.bindPending;
|
||||
if (!bindPending) {
|
||||
throw new Error("Matrix approval runtime must expose bindPending");
|
||||
}
|
||||
const binding = await bindPending({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
},
|
||||
request: {} as never,
|
||||
approvalKind: "exec",
|
||||
view,
|
||||
pendingPayload,
|
||||
entry: entry!,
|
||||
});
|
||||
|
||||
expect(binding).toEqual({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$primary",
|
||||
});
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$primary",
|
||||
reactionKey: "✅",
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$last",
|
||||
reactionKey: "✅",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a longer code fence when resolved commands contain triple backticks", async () => {
|
||||
const result = await matrixApprovalNativeRuntime.presentation.buildResolvedResult({
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
context: {
|
||||
client: {} as never,
|
||||
},
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1_000,
|
||||
},
|
||||
resolved: {
|
||||
id: "req-1",
|
||||
decision: "allow-once",
|
||||
ts: 0,
|
||||
},
|
||||
view: {
|
||||
approvalKind: "exec",
|
||||
approvalId: "req-1",
|
||||
decision: "allow-once",
|
||||
commandText: "echo ```danger```",
|
||||
} as never,
|
||||
entry: {} as never,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "update",
|
||||
payload: [
|
||||
"Exec approval: Allowed once",
|
||||
"",
|
||||
"Command",
|
||||
"````",
|
||||
"echo ```danger```",
|
||||
"````",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
});
|
||||
595
extensions/matrix/src/approval-handler.runtime.ts
Normal file
595
extensions/matrix/src/approval-handler.runtime.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
// Matrix plugin module implements approval handler behavior.
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import type {
|
||||
ChannelApprovalCapabilityHandlerContext,
|
||||
PendingApprovalView,
|
||||
ResolvedApprovalView,
|
||||
} from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { createChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import {
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildPluginApprovalPendingReplyPayload,
|
||||
type ExecApprovalReplyDecision,
|
||||
} from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import { buildPluginApprovalResolvedReplyPayload } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import {
|
||||
listMessageReceiptPlatformIds,
|
||||
resolveMessageReceiptPrimaryId,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildMatrixApprovalReactionHint,
|
||||
listMatrixApprovalReactionBindings,
|
||||
registerMatrixApprovalReactionTarget,
|
||||
unregisterMatrixApprovalReactionTarget,
|
||||
} from "./approval-reactions.js";
|
||||
import {
|
||||
isMatrixAnyApprovalClientEnabled,
|
||||
shouldHandleMatrixApprovalRequest,
|
||||
} from "./exec-approvals.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { deleteMatrixMessage, editMatrixMessage } from "./matrix/actions/messages.js";
|
||||
import { repairMatrixDirectRooms } from "./matrix/direct-management.js";
|
||||
import type { MatrixClient } from "./matrix/sdk.js";
|
||||
import {
|
||||
reactMatrixMessage,
|
||||
sendMessageMatrix,
|
||||
sendSingleTextMessageMatrix,
|
||||
} from "./matrix/send.js";
|
||||
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// OpenClaw Matrix custom event content for capable clients; body and reactions remain fallback.
|
||||
const MATRIX_APPROVAL_METADATA_KEY = "com.openclaw.approval" as const;
|
||||
|
||||
type PendingMessage = {
|
||||
roomId: string;
|
||||
platformMessageIds: readonly string[];
|
||||
reactionEventId: string;
|
||||
};
|
||||
type PreparedMatrixTarget = {
|
||||
to: string;
|
||||
roomId: string;
|
||||
threadId?: string;
|
||||
};
|
||||
type MatrixApprovalMetadataAction = {
|
||||
decision: ExecApprovalReplyDecision;
|
||||
label: string;
|
||||
style: PendingApprovalView["actions"][number]["style"];
|
||||
command: string;
|
||||
};
|
||||
type MatrixApprovalMetadataBase = {
|
||||
version: 1;
|
||||
type: "approval.request";
|
||||
id: string;
|
||||
state: "pending";
|
||||
kind: PendingApprovalView["approvalKind"];
|
||||
phase: "pending";
|
||||
title: string;
|
||||
description?: string;
|
||||
expiresAtMs: number;
|
||||
metadata: PendingApprovalView["metadata"];
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
actions: MatrixApprovalMetadataAction[];
|
||||
};
|
||||
type MatrixExecApprovalMetadata = MatrixApprovalMetadataBase & {
|
||||
kind: "exec";
|
||||
ask?: string;
|
||||
agentId?: string;
|
||||
commandText: string;
|
||||
commandPreview?: string;
|
||||
cwd?: string;
|
||||
envKeys?: readonly string[];
|
||||
host?: string;
|
||||
nodeId?: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
type MatrixPluginApprovalSeverity = Extract<
|
||||
PendingApprovalView,
|
||||
{ approvalKind: "plugin" }
|
||||
>["severity"];
|
||||
type MatrixPluginApprovalMetadata = MatrixApprovalMetadataBase & {
|
||||
kind: "plugin";
|
||||
agentId?: string;
|
||||
pluginId?: string;
|
||||
toolName?: string;
|
||||
severity: MatrixPluginApprovalSeverity;
|
||||
};
|
||||
type MatrixApprovalMetadata = MatrixExecApprovalMetadata | MatrixPluginApprovalMetadata;
|
||||
type MatrixApprovalExtraContent = {
|
||||
[MATRIX_APPROVAL_METADATA_KEY]: MatrixApprovalMetadata;
|
||||
};
|
||||
type PendingApprovalContent = {
|
||||
approvalId: string;
|
||||
text: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
extraContent: MatrixApprovalExtraContent;
|
||||
};
|
||||
type ReactionTargetRef = {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
};
|
||||
type MatrixRawApprovalTarget = {
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
type MatrixPrepareTargetParams = {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
gatewayUrl?: string;
|
||||
context?: unknown;
|
||||
rawTarget: MatrixRawApprovalTarget;
|
||||
};
|
||||
|
||||
const MATRIX_APPROVAL_DELIVERY_ATTEMPTS = 3;
|
||||
const MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS = 250;
|
||||
|
||||
export type MatrixApprovalHandlerDeps = {
|
||||
nowMs?: () => number;
|
||||
sendMessage?: typeof sendMessageMatrix;
|
||||
sendSingleTextMessage?: typeof sendSingleTextMessageMatrix;
|
||||
reactMessage?: typeof reactMatrixMessage;
|
||||
editMessage?: typeof editMatrixMessage;
|
||||
deleteMessage?: typeof deleteMatrixMessage;
|
||||
repairDirectRooms?: typeof repairMatrixDirectRooms;
|
||||
};
|
||||
|
||||
export type MatrixApprovalHandlerContext = {
|
||||
client: MatrixClient;
|
||||
deps?: MatrixApprovalHandlerDeps;
|
||||
};
|
||||
|
||||
function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): {
|
||||
accountId: string;
|
||||
context: MatrixApprovalHandlerContext;
|
||||
} | null {
|
||||
const context = params.context as MatrixApprovalHandlerContext | undefined;
|
||||
const accountId = params.accountId?.trim() || "";
|
||||
if (!context?.client || !accountId) {
|
||||
return null;
|
||||
}
|
||||
return { accountId, context };
|
||||
}
|
||||
|
||||
function normalizePendingMessageIds(entry: PendingMessage): string[] {
|
||||
return normalizeUniqueStringEntries(entry.platformMessageIds);
|
||||
}
|
||||
|
||||
function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null {
|
||||
const roomId = params.roomId.trim();
|
||||
const eventId = params.eventId.trim();
|
||||
if (!roomId || !eventId) {
|
||||
return null;
|
||||
}
|
||||
return { roomId, eventId };
|
||||
}
|
||||
|
||||
function normalizeThreadId(value?: string | number | null): string | undefined {
|
||||
const trimmed = value == null ? "" : String(value).trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function isSingleMatrixMessageLimitError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes("Matrix single-message text exceeds limit")
|
||||
);
|
||||
}
|
||||
|
||||
async function retryMatrixApprovalDelivery<T>(
|
||||
operation: () => Promise<T>,
|
||||
params: { shouldRetry?: (error: unknown) => boolean } = {},
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= MATRIX_APPROVAL_DELIVERY_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === MATRIX_APPROVAL_DELIVERY_ATTEMPTS || params.shouldRetry?.(error) === false) {
|
||||
break;
|
||||
}
|
||||
await sleep(MATRIX_APPROVAL_DELIVERY_RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function prepareTarget(
|
||||
params: MatrixPrepareTargetParams,
|
||||
): Promise<PreparedMatrixTarget | null> {
|
||||
const resolved = resolveHandlerContext(params);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const target = resolveMatrixTargetIdentity(params.rawTarget.to);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const threadId = normalizeThreadId(params.rawTarget.threadId);
|
||||
if (target.kind === "user") {
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: resolved.accountId,
|
||||
});
|
||||
const repairDirectRooms = resolved.context.deps?.repairDirectRooms ?? repairMatrixDirectRooms;
|
||||
const repaired = await retryMatrixApprovalDelivery(
|
||||
async () =>
|
||||
await repairDirectRooms({
|
||||
client: resolved.context.client,
|
||||
remoteUserId: target.id,
|
||||
encrypted: account.config.encryption === true,
|
||||
}),
|
||||
);
|
||||
if (!repaired.activeRoomId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: `room:${repaired.activeRoomId}`,
|
||||
roomId: repaired.activeRoomId,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
to: `room:${target.id}`,
|
||||
roomId: target.id,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatrixApprovalMetadata(params: {
|
||||
view: PendingApprovalView;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
}): MatrixApprovalMetadata {
|
||||
const base: MatrixApprovalMetadataBase = {
|
||||
version: 1,
|
||||
type: "approval.request",
|
||||
id: params.view.approvalId,
|
||||
state: "pending",
|
||||
kind: params.view.approvalKind,
|
||||
phase: params.view.phase,
|
||||
title: params.view.title,
|
||||
expiresAtMs: params.view.expiresAtMs,
|
||||
metadata: params.view.metadata,
|
||||
allowedDecisions: Array.from(params.allowedDecisions),
|
||||
actions: params.view.actions.map((action) => ({
|
||||
decision: action.decision,
|
||||
label: action.label,
|
||||
style: action.style,
|
||||
command: action.command,
|
||||
})),
|
||||
...(params.view.description != null ? { description: params.view.description } : {}),
|
||||
};
|
||||
|
||||
if (params.view.approvalKind === "plugin") {
|
||||
return {
|
||||
...base,
|
||||
kind: "plugin",
|
||||
severity: params.view.severity,
|
||||
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
|
||||
...(params.view.pluginId != null ? { pluginId: params.view.pluginId } : {}),
|
||||
...(params.view.toolName != null ? { toolName: params.view.toolName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: "exec",
|
||||
commandText: params.view.commandText,
|
||||
...(params.view.ask != null ? { ask: params.view.ask } : {}),
|
||||
...(params.view.agentId != null ? { agentId: params.view.agentId } : {}),
|
||||
...(params.view.commandPreview != null ? { commandPreview: params.view.commandPreview } : {}),
|
||||
...(params.view.cwd != null ? { cwd: params.view.cwd } : {}),
|
||||
...(params.view.envKeys != null ? { envKeys: params.view.envKeys } : {}),
|
||||
...(params.view.host != null ? { host: params.view.host } : {}),
|
||||
...(params.view.nodeId != null ? { nodeId: params.view.nodeId } : {}),
|
||||
...(params.view.sessionKey != null ? { sessionKey: params.view.sessionKey } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPendingApprovalContent(params: {
|
||||
view: PendingApprovalView;
|
||||
nowMs: number;
|
||||
}): PendingApprovalContent {
|
||||
const allowedDecisions = params.view.actions.map((action) => action.decision);
|
||||
const payload =
|
||||
params.view.approvalKind === "plugin"
|
||||
? buildPluginApprovalPendingReplyPayload({
|
||||
request: {
|
||||
id: params.view.approvalId,
|
||||
request: {
|
||||
title: params.view.title,
|
||||
description: params.view.description ?? "",
|
||||
severity: params.view.severity,
|
||||
toolName: params.view.toolName ?? undefined,
|
||||
pluginId: params.view.pluginId ?? undefined,
|
||||
agentId: params.view.agentId ?? undefined,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: params.view.expiresAtMs,
|
||||
} satisfies PluginApprovalRequest,
|
||||
nowMs: params.nowMs,
|
||||
allowedDecisions,
|
||||
})
|
||||
: buildExecApprovalPendingReplyPayload({
|
||||
approvalId: params.view.approvalId,
|
||||
approvalSlug: params.view.approvalId.slice(0, 8),
|
||||
approvalCommandId: params.view.approvalId,
|
||||
ask: params.view.ask ?? undefined,
|
||||
agentId: params.view.agentId ?? undefined,
|
||||
allowedDecisions,
|
||||
command: params.view.commandText,
|
||||
cwd: params.view.cwd ?? undefined,
|
||||
host: params.view.host === "node" ? "node" : "gateway",
|
||||
nodeId: params.view.nodeId ?? undefined,
|
||||
sessionKey: params.view.sessionKey ?? undefined,
|
||||
expiresAtMs: params.view.expiresAtMs,
|
||||
nowMs: params.nowMs,
|
||||
});
|
||||
const hint = buildMatrixApprovalReactionHint(allowedDecisions);
|
||||
const text = payload.text ?? "";
|
||||
return {
|
||||
approvalId: params.view.approvalId,
|
||||
text: hint ? (text ? `${hint}\n\n${text}` : hint) : text,
|
||||
allowedDecisions,
|
||||
extraContent: {
|
||||
[MATRIX_APPROVAL_METADATA_KEY]: buildMatrixApprovalMetadata({
|
||||
view: params.view,
|
||||
allowedDecisions,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildResolvedApprovalText(view: ResolvedApprovalView): string {
|
||||
if (view.approvalKind === "plugin") {
|
||||
return (
|
||||
buildPluginApprovalResolvedReplyPayload({
|
||||
resolved: {
|
||||
id: view.approvalId,
|
||||
decision: view.decision,
|
||||
resolvedBy: view.resolvedBy ?? undefined,
|
||||
ts: 0,
|
||||
},
|
||||
}).text ?? ""
|
||||
);
|
||||
}
|
||||
const decisionLabel =
|
||||
view.decision === "allow-once"
|
||||
? "Allowed once"
|
||||
: view.decision === "allow-always"
|
||||
? "Allowed always"
|
||||
: "Denied";
|
||||
return [
|
||||
`Exec approval: ${decisionLabel}`,
|
||||
"",
|
||||
"Command",
|
||||
buildMarkdownCodeBlock(view.commandText),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildMarkdownCodeBlock(text: string): string {
|
||||
const longestFence = Math.max(...Array.from(text.matchAll(/`+/g), (match) => match[0].length), 0);
|
||||
const fence = "`".repeat(Math.max(3, longestFence + 1));
|
||||
return [fence, text, fence].join("\n");
|
||||
}
|
||||
|
||||
export const matrixApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
||||
PendingApprovalContent,
|
||||
PreparedMatrixTarget,
|
||||
PendingMessage,
|
||||
ReactionTargetRef,
|
||||
string
|
||||
>({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
availability: {
|
||||
isConfigured: ({ cfg, accountId, context }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
return isMatrixAnyApprovalClientEnabled({
|
||||
cfg,
|
||||
accountId: resolved.accountId,
|
||||
});
|
||||
},
|
||||
shouldHandle: ({ cfg, accountId, request, context }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
return shouldHandleMatrixApprovalRequest({
|
||||
cfg,
|
||||
accountId: resolved.accountId,
|
||||
request: request as ExecApprovalRequest | PluginApprovalRequest,
|
||||
});
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
buildPendingPayload: ({ view, nowMs }) =>
|
||||
buildPendingApprovalContent({
|
||||
view,
|
||||
nowMs,
|
||||
}),
|
||||
buildResolvedResult: ({ view }) => ({
|
||||
kind: "update",
|
||||
payload: buildResolvedApprovalText(view),
|
||||
}),
|
||||
buildExpiredResult: () => ({ kind: "delete" }),
|
||||
},
|
||||
transport: {
|
||||
prepareTarget: ({ cfg, accountId, context, plannedTarget }) => {
|
||||
return prepareTarget({
|
||||
cfg,
|
||||
accountId,
|
||||
context,
|
||||
rawTarget: plannedTarget.target,
|
||||
}).then((preparedTarget) =>
|
||||
preparedTarget
|
||||
? {
|
||||
dedupeKey: buildChannelApprovalNativeTargetKey({
|
||||
to: preparedTarget.roomId,
|
||||
threadId: preparedTarget.threadId,
|
||||
}),
|
||||
target: preparedTarget,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload, view }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const sendSingleTextMessage =
|
||||
resolved.context.deps?.sendSingleTextMessage ?? sendSingleTextMessageMatrix;
|
||||
const reactMessage = resolved.context.deps?.reactMessage ?? reactMatrixMessage;
|
||||
let result;
|
||||
try {
|
||||
result = await retryMatrixApprovalDelivery(
|
||||
async () =>
|
||||
await sendSingleTextMessage(preparedTarget.to, pendingPayload.text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
threadId: preparedTarget.threadId,
|
||||
extraContent: pendingPayload.extraContent,
|
||||
}),
|
||||
{ shouldRetry: (error) => !isSingleMatrixMessageLimitError(error) },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isSingleMatrixMessageLimitError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const sendMessage = resolved.context.deps?.sendMessage ?? sendMessageMatrix;
|
||||
result = await retryMatrixApprovalDelivery(
|
||||
async () =>
|
||||
await sendMessage(preparedTarget.to, pendingPayload.text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
threadId: preparedTarget.threadId,
|
||||
extraContent: pendingPayload.extraContent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const receiptMessageIds = listMessageReceiptPlatformIds(result.receipt);
|
||||
const platformMessageIds = receiptMessageIds.length
|
||||
? receiptMessageIds
|
||||
: [result.messageId.trim()].filter(Boolean);
|
||||
const reactionEventId =
|
||||
resolveMessageReceiptPrimaryId(result.receipt) ||
|
||||
result.primaryMessageId?.trim() ||
|
||||
platformMessageIds[0] ||
|
||||
result.messageId.trim();
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: result.roomId,
|
||||
eventId: reactionEventId,
|
||||
approvalId: pendingPayload.approvalId,
|
||||
allowedDecisions: pendingPayload.allowedDecisions,
|
||||
ttlMs: view.expiresAtMs - Date.now(),
|
||||
});
|
||||
await Promise.allSettled(
|
||||
listMatrixApprovalReactionBindings(pendingPayload.allowedDecisions).map(
|
||||
async ({ emoji }) => {
|
||||
await reactMessage(result.roomId, reactionEventId, emoji, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
return {
|
||||
roomId: result.roomId,
|
||||
platformMessageIds,
|
||||
reactionEventId,
|
||||
};
|
||||
},
|
||||
updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const editMessage = resolved.context.deps?.editMessage ?? editMatrixMessage;
|
||||
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
|
||||
const [primaryMessageId, ...staleMessageIds] = normalizePendingMessageIds(entry);
|
||||
if (!primaryMessageId) {
|
||||
return;
|
||||
}
|
||||
const text = payload;
|
||||
await Promise.allSettled([
|
||||
editMessage(entry.roomId, primaryMessageId, text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
}),
|
||||
...staleMessageIds.map(async (messageId) => {
|
||||
await deleteMessage(entry.roomId, messageId, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
reason: "approval resolved",
|
||||
});
|
||||
}),
|
||||
]);
|
||||
},
|
||||
deleteEntry: async ({ cfg, accountId, context, entry, phase }) => {
|
||||
const resolved = resolveHandlerContext({ cfg, accountId, context });
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const deleteMessage = resolved.context.deps?.deleteMessage ?? deleteMatrixMessage;
|
||||
await Promise.allSettled(
|
||||
normalizePendingMessageIds(entry).map(async (messageId) => {
|
||||
await deleteMessage(entry.roomId, messageId, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolved.accountId,
|
||||
client: resolved.context.client,
|
||||
reason: phase === "expired" ? "approval expired" : "approval resolved",
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
interactions: {
|
||||
bindPending: (params) => {
|
||||
const target = normalizeReactionTargetRef({
|
||||
roomId: params.entry.roomId,
|
||||
eventId: params.entry.reactionEventId,
|
||||
});
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: target.roomId,
|
||||
eventId: target.eventId,
|
||||
approvalId: params.pendingPayload.approvalId,
|
||||
allowedDecisions: params.pendingPayload.allowedDecisions,
|
||||
ttlMs: params.view.expiresAtMs - Date.now(),
|
||||
});
|
||||
return target;
|
||||
},
|
||||
unbindPending: (params) => {
|
||||
const target = normalizeReactionTargetRef(params.binding);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
unregisterMatrixApprovalReactionTarget(target);
|
||||
},
|
||||
cancelDelivered: (params) => {
|
||||
const target = normalizeReactionTargetRef({
|
||||
roomId: params.entry.roomId,
|
||||
eventId: params.entry.reactionEventId,
|
||||
});
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
unregisterMatrixApprovalReactionTarget(target);
|
||||
},
|
||||
},
|
||||
});
|
||||
7
extensions/matrix/src/approval-ids.ts
Normal file
7
extensions/matrix/src/approval-ids.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Matrix plugin module implements approval ids behavior.
|
||||
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
|
||||
export function normalizeMatrixApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeMatrixUserId(String(value));
|
||||
return normalized || undefined;
|
||||
}
|
||||
330
extensions/matrix/src/approval-native.test.ts
Normal file
330
extensions/matrix/src/approval-native.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
// Matrix tests cover approval native plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matrixApprovalCapability } from "./approval-native.js";
|
||||
|
||||
function buildConfig(
|
||||
overrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
target: "both",
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("matrix approval capability", () => {
|
||||
it("describes the correct Matrix exec-approval setup path", () => {
|
||||
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.matrix.execApprovals.approvers`");
|
||||
expect(text).toContain("`channels.matrix.dm.allowFrom`");
|
||||
});
|
||||
|
||||
it("describes the named-account Matrix exec-approval setup path", () => {
|
||||
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(text).toContain("`channels.matrix.accounts.work.execApprovals.approvers`");
|
||||
expect(text).toContain("`channels.matrix.accounts.work.dm.allowFrom`");
|
||||
expect(text).not.toContain("`channels.matrix.execApprovals.approvers`");
|
||||
});
|
||||
|
||||
it("describes native matrix approval delivery capabilities", () => {
|
||||
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: "agent:main:matrix:channel:!ops:example.org",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
enabled: true,
|
||||
preferredSurface: "both",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves origin targets from matrix turn source", async () => {
|
||||
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceThreadId: "$thread",
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: "agent:main:matrix:channel:!ops:example.org",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "room:!ops:example.org",
|
||||
threadId: "$thread",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves approver dm targets", async () => {
|
||||
const targets = await matrixApprovalCapability.native?.resolveApproverDmTargets?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(targets).toEqual([{ to: "user:@owner:example.org" }]);
|
||||
});
|
||||
|
||||
it("suppresses same-channel plugin forwarding when Matrix native delivery is available", () => {
|
||||
const shouldSuppress = matrixApprovalCapability.delivery?.shouldSuppressForwardingFallback;
|
||||
if (!shouldSuppress) {
|
||||
throw new Error("delivery suppression helper unavailable");
|
||||
}
|
||||
|
||||
expect(
|
||||
shouldSuppress({
|
||||
cfg: buildConfig({
|
||||
dm: { allowFrom: ["@owner:example.org"] },
|
||||
}),
|
||||
approvalKind: "plugin",
|
||||
target: {
|
||||
channel: "matrix",
|
||||
to: "room:!ops:example.org",
|
||||
accountId: "default",
|
||||
},
|
||||
request: {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Plugin Approval Required",
|
||||
description: "Allow plugin action",
|
||||
pluginId: "git-tools",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!ops:example.org",
|
||||
turnSourceAccountId: "default",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
} as never),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves room-id case when matching Matrix origin targets", async () => {
|
||||
const target = await matrixApprovalCapability.native?.resolveOriginTarget?.({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
turnSourceChannel: "matrix",
|
||||
turnSourceTo: "room:!Ops:Example.org",
|
||||
turnSourceThreadId: "$thread",
|
||||
turnSourceAccountId: "default",
|
||||
sessionKey: "agent:main:matrix:channel:!Ops:Example.org",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target).toEqual({
|
||||
to: "room:!Ops:Example.org",
|
||||
threadId: "$thread",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plugin approval auth independent from exec approvers", () => {
|
||||
const cfg = buildConfig({
|
||||
dm: { allowFrom: ["@owner:example.org"] },
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["@exec:example.org"],
|
||||
target: "both",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
senderId: "@owner:example.org",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
senderId: "@exec:example.org",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({
|
||||
authorized: false,
|
||||
reason: "❌ You are not authorized to approve plugin requests on Matrix.",
|
||||
});
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
senderId: "@exec:example.org",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
|
||||
it("requires Matrix DM approvers before enabling plugin approval auth", () => {
|
||||
const cfg = buildConfig({
|
||||
dm: { allowFrom: [] },
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["@exec:example.org"],
|
||||
target: "both",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.authorizeActorAction?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
senderId: "@exec:example.org",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({
|
||||
authorized: false,
|
||||
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports exec initiating-surface availability independently from plugin auth", () => {
|
||||
const cfg = buildConfig({
|
||||
dm: { allowFrom: ["@owner:example.org"] },
|
||||
execApprovals: {
|
||||
enabled: false,
|
||||
approvers: [],
|
||||
target: "both",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.getActionAvailabilityState?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
approvalKind: "plugin",
|
||||
}),
|
||||
).toEqual({ kind: "enabled" });
|
||||
|
||||
expect(
|
||||
matrixApprovalCapability.getExecInitiatingSurfaceState?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
action: "approve",
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
});
|
||||
|
||||
it("enables matrix-native plugin approval delivery when DM approvers are configured", () => {
|
||||
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg: buildConfig({
|
||||
dm: { allowFrom: ["@owner:example.org"] },
|
||||
}),
|
||||
accountId: "default",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Plugin Approval Required",
|
||||
description: "Allow plugin access",
|
||||
pluginId: "git-tools",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
enabled: true,
|
||||
preferredSurface: "both",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps matrix-native plugin approval delivery disabled without DM approvers", () => {
|
||||
const capabilities = matrixApprovalCapability.native?.describeDeliveryCapabilities({
|
||||
cfg: buildConfig(),
|
||||
accountId: "default",
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
id: "plugin:req-1",
|
||||
request: {
|
||||
title: "Plugin Approval Required",
|
||||
description: "Allow plugin access",
|
||||
pluginId: "git-tools",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
enabled: false,
|
||||
preferredSurface: "both",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
349
extensions/matrix/src/approval-native.ts
Normal file
349
extensions/matrix/src/approval-native.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// Matrix plugin module implements approval native behavior.
|
||||
import {
|
||||
createChannelApprovalCapability,
|
||||
createApproverRestrictedNativeApprovalCapability,
|
||||
splitChannelApprovalCapability,
|
||||
} from "openclaw/plugin-sdk/approval-delivery-runtime";
|
||||
import { createLazyChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelApprovalNativeRuntimeAdapter } from "openclaw/plugin-sdk/approval-handler-runtime";
|
||||
import {
|
||||
createChannelNativeOriginTargetResolver,
|
||||
resolveApprovalRequestSessionConversation,
|
||||
} from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalStringifiedId,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixApprovalAuthApprovers, matrixApprovalAuth } from "./approval-auth.js";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import {
|
||||
getMatrixApprovalApprovers,
|
||||
getMatrixExecApprovalApprovers,
|
||||
isMatrixAnyApprovalClientEnabled,
|
||||
isMatrixApprovalClientEnabled,
|
||||
isMatrixExecApprovalClientEnabled,
|
||||
isMatrixExecApprovalAuthorizedSender,
|
||||
resolveMatrixExecApprovalTarget,
|
||||
shouldHandleMatrixApprovalRequest,
|
||||
} from "./exec-approvals.js";
|
||||
import { listMatrixAccountIds } from "./matrix/accounts.js";
|
||||
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import { resolveMatrixTargetIdentity } from "./matrix/target-ids.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
type MatrixOriginTarget = { to: string; threadId?: string };
|
||||
|
||||
function normalizeComparableTarget(value: string): string {
|
||||
const target = resolveMatrixTargetIdentity(value);
|
||||
if (!target) {
|
||||
return normalizeLowercaseStringOrEmpty(value);
|
||||
}
|
||||
if (target.kind === "user") {
|
||||
return `user:${normalizeMatrixUserId(target.id)}`;
|
||||
}
|
||||
return `${normalizeLowercaseStringOrEmpty(target.kind)}:${target.id}`;
|
||||
}
|
||||
|
||||
function resolveMatrixNativeTarget(raw: string): string | null {
|
||||
const target = resolveMatrixTargetIdentity(raw);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return target.kind === "user" ? `user:${target.id}` : `room:${target.id}`;
|
||||
}
|
||||
|
||||
function resolveTurnSourceMatrixOriginTarget(request: ApprovalRequest): MatrixOriginTarget | null {
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(request.request.turnSourceChannel);
|
||||
const turnSourceTo = request.request.turnSourceTo?.trim() || "";
|
||||
const target = resolveMatrixNativeTarget(turnSourceTo);
|
||||
if (turnSourceChannel !== "matrix" || !target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
threadId: normalizeOptionalStringifiedId(request.request.turnSourceThreadId),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionMatrixOriginTarget(sessionTarget: {
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
}): MatrixOriginTarget | null {
|
||||
const target = resolveMatrixNativeTarget(sessionTarget.to);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
threadId: normalizeOptionalStringifiedId(sessionTarget.threadId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMatrixOriginTarget(target: MatrixOriginTarget): MatrixOriginTarget {
|
||||
return {
|
||||
...target,
|
||||
to: normalizeComparableTarget(target.to),
|
||||
};
|
||||
}
|
||||
|
||||
function hasMatrixPluginApprovers(params: { cfg: CoreConfig; accountId?: string | null }): boolean {
|
||||
return getMatrixApprovalAuthApprovers(params).length > 0;
|
||||
}
|
||||
|
||||
function availabilityState(enabled: boolean) {
|
||||
return enabled ? ({ kind: "enabled" } as const) : ({ kind: "disabled" } as const);
|
||||
}
|
||||
|
||||
function hasMatrixApprovalApprovers(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
}): boolean {
|
||||
return (
|
||||
getMatrixApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnyMatrixApprovalApprovers(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
getMatrixExecApprovalApprovers(params).length > 0 ||
|
||||
getMatrixApprovalAuthApprovers(params).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function isMatrixPluginAuthorizedSender(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
}): boolean {
|
||||
const normalizedSenderId = params.senderId
|
||||
? normalizeMatrixApproverId(params.senderId)
|
||||
: undefined;
|
||||
if (!normalizedSenderId) {
|
||||
return false;
|
||||
}
|
||||
return getMatrixApprovalAuthApprovers(params).includes(normalizedSenderId);
|
||||
}
|
||||
|
||||
function resolveSuppressionAccountId(params: {
|
||||
target: { accountId?: string | null };
|
||||
request: { request: { turnSourceAccountId?: string | null } };
|
||||
}): string | undefined {
|
||||
return (
|
||||
params.target.accountId?.trim() ||
|
||||
params.request.request.turnSourceAccountId?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
const resolveMatrixOriginTarget = createChannelNativeOriginTargetResolver({
|
||||
channel: "matrix",
|
||||
shouldHandleRequest: ({ cfg, accountId, request }) =>
|
||||
shouldHandleMatrixApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
}),
|
||||
resolveTurnSourceTarget: resolveTurnSourceMatrixOriginTarget,
|
||||
resolveSessionTarget: resolveSessionMatrixOriginTarget,
|
||||
normalizeTargetForMatch: normalizeMatrixOriginTarget,
|
||||
resolveFallbackTarget: (request) => {
|
||||
const sessionConversation = resolveApprovalRequestSessionConversation({
|
||||
request,
|
||||
channel: "matrix",
|
||||
});
|
||||
if (!sessionConversation) {
|
||||
return null;
|
||||
}
|
||||
const target = resolveMatrixNativeTarget(sessionConversation.id);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: target,
|
||||
threadId: normalizeOptionalStringifiedId(sessionConversation.threadId),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function resolveMatrixApproverDmTargets(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
}): { to: string }[] {
|
||||
if (!shouldHandleMatrixApprovalRequest(params)) {
|
||||
return [];
|
||||
}
|
||||
return getMatrixApprovalApprovers(params)
|
||||
.map((approver) => {
|
||||
const normalized = normalizeMatrixUserId(approver);
|
||||
return normalized ? { to: `user:${normalized}` } : null;
|
||||
})
|
||||
.filter((target): target is { to: string } => target !== null);
|
||||
}
|
||||
|
||||
const matrixNativeApprovalCapability = createApproverRestrictedNativeApprovalCapability({
|
||||
channel: "matrix",
|
||||
channelLabel: "Matrix",
|
||||
describeExecApprovalSetup: ({
|
||||
accountId,
|
||||
}: Parameters<NonNullable<ChannelApprovalCapability["describeExecApprovalSetup"]>>[0]) => {
|
||||
const prefix =
|
||||
accountId && accountId !== "default"
|
||||
? `channels.matrix.accounts.${accountId}`
|
||||
: "channels.matrix";
|
||||
return `Approve it from the Web UI or terminal UI for now. Matrix supports native exec approvals for this account. Configure \`${prefix}.execApprovals.approvers\` or \`${prefix}.dm.allowFrom\`; leave \`${prefix}.execApprovals.enabled\` unset/\`auto\` or set it to \`true\`.`;
|
||||
},
|
||||
listAccountIds: listMatrixAccountIds,
|
||||
hasApprovers: ({ cfg, accountId }) =>
|
||||
hasAnyMatrixApprovalApprovers({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
}),
|
||||
isExecAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isMatrixExecApprovalAuthorizedSender({ cfg, accountId, senderId }),
|
||||
isPluginAuthorizedSender: ({ cfg, accountId, senderId }) =>
|
||||
isMatrixPluginAuthorizedSender({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
senderId,
|
||||
}),
|
||||
isNativeDeliveryEnabled: ({ cfg, accountId }) =>
|
||||
isMatrixExecApprovalClientEnabled({ cfg, accountId }),
|
||||
resolveNativeDeliveryMode: ({ cfg, accountId }) =>
|
||||
resolveMatrixExecApprovalTarget({ cfg, accountId }),
|
||||
requireMatchingTurnSourceChannel: true,
|
||||
resolveSuppressionAccountId,
|
||||
resolveOriginTarget: resolveMatrixOriginTarget,
|
||||
resolveApproverDmTargets: resolveMatrixApproverDmTargets,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
nativeRuntime: createLazyChannelApprovalNativeRuntimeAdapter({
|
||||
eventKinds: ["exec", "plugin"],
|
||||
isConfigured: ({ cfg, accountId }) =>
|
||||
isMatrixAnyApprovalClientEnabled({
|
||||
cfg,
|
||||
accountId,
|
||||
}),
|
||||
shouldHandle: ({ cfg, accountId, request }) =>
|
||||
shouldHandleMatrixApprovalRequest({
|
||||
cfg,
|
||||
accountId,
|
||||
request,
|
||||
}),
|
||||
load: async () =>
|
||||
(await import("./approval-handler.runtime.js"))
|
||||
.matrixApprovalNativeRuntime as unknown as ChannelApprovalNativeRuntimeAdapter,
|
||||
}),
|
||||
});
|
||||
|
||||
const splitMatrixApprovalCapability = splitChannelApprovalCapability(
|
||||
matrixNativeApprovalCapability,
|
||||
);
|
||||
const matrixBaseNativeApprovalAdapter = splitMatrixApprovalCapability.native;
|
||||
const matrixBaseDeliveryAdapter = splitMatrixApprovalCapability.delivery;
|
||||
type MatrixForwardingSuppressionParams = Parameters<
|
||||
NonNullable<NonNullable<typeof matrixBaseDeliveryAdapter>["shouldSuppressForwardingFallback"]>
|
||||
>[0];
|
||||
const matrixDeliveryAdapter = matrixBaseDeliveryAdapter && {
|
||||
...matrixBaseDeliveryAdapter,
|
||||
shouldSuppressForwardingFallback: (params: MatrixForwardingSuppressionParams) => {
|
||||
const accountId = resolveSuppressionAccountId(params);
|
||||
if (
|
||||
!hasMatrixApprovalApprovers({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return matrixBaseDeliveryAdapter.shouldSuppressForwardingFallback?.(params) ?? false;
|
||||
},
|
||||
};
|
||||
const matrixNativeAdapter = matrixBaseNativeApprovalAdapter && {
|
||||
describeDeliveryCapabilities: (
|
||||
params: Parameters<typeof matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities>[0],
|
||||
) => {
|
||||
const capabilities = matrixBaseNativeApprovalAdapter.describeDeliveryCapabilities(params);
|
||||
const hasApprovers = hasMatrixApprovalApprovers({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
});
|
||||
const clientEnabled = isMatrixApprovalClientEnabled({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
});
|
||||
return {
|
||||
...capabilities,
|
||||
enabled: capabilities.enabled && hasApprovers && clientEnabled,
|
||||
};
|
||||
},
|
||||
resolveOriginTarget: matrixBaseNativeApprovalAdapter.resolveOriginTarget,
|
||||
resolveApproverDmTargets: matrixBaseNativeApprovalAdapter.resolveApproverDmTargets,
|
||||
};
|
||||
|
||||
export const matrixApprovalCapability = createChannelApprovalCapability({
|
||||
authorizeActorAction: (
|
||||
params: Parameters<NonNullable<ChannelApprovalCapability["authorizeActorAction"]>>[0],
|
||||
) => {
|
||||
if (params.approvalKind !== "plugin") {
|
||||
return matrixNativeApprovalCapability.authorizeActorAction?.(params) ?? { authorized: true };
|
||||
}
|
||||
if (
|
||||
!hasMatrixPluginApprovers({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId: params.accountId,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: "❌ Matrix plugin approvals are not enabled for this bot account.",
|
||||
} as const;
|
||||
}
|
||||
return matrixApprovalAuth.authorizeActorAction(params);
|
||||
},
|
||||
getActionAvailabilityState: (
|
||||
params: Parameters<NonNullable<ChannelApprovalCapability["getActionAvailabilityState"]>>[0],
|
||||
) => {
|
||||
if (params.approvalKind === "plugin") {
|
||||
return availabilityState(
|
||||
hasMatrixPluginApprovers({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId: params.accountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
matrixNativeApprovalCapability.getActionAvailabilityState?.(params) ?? {
|
||||
kind: "disabled",
|
||||
}
|
||||
);
|
||||
},
|
||||
getExecInitiatingSurfaceState: (
|
||||
params: Parameters<NonNullable<ChannelApprovalCapability["getExecInitiatingSurfaceState"]>>[0],
|
||||
) =>
|
||||
matrixNativeApprovalCapability.getExecInitiatingSurfaceState?.(params) ??
|
||||
({ kind: "disabled" } as const),
|
||||
describeExecApprovalSetup: matrixNativeApprovalCapability.describeExecApprovalSetup,
|
||||
delivery: matrixDeliveryAdapter,
|
||||
nativeRuntime: matrixNativeApprovalCapability.nativeRuntime,
|
||||
native: matrixNativeAdapter,
|
||||
render: matrixNativeApprovalCapability.render,
|
||||
});
|
||||
46
extensions/matrix/src/approval-reaction-auth.ts
Normal file
46
extensions/matrix/src/approval-reaction-auth.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Matrix plugin module implements approval reaction auth behavior.
|
||||
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type MatrixApprovalReactionKind = "exec" | "plugin";
|
||||
|
||||
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeMatrixApproverId(value);
|
||||
return normalized === "*" ? undefined : normalized;
|
||||
}
|
||||
|
||||
function getMatrixApprovalReactionApprovers(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: MatrixApprovalReactionKind;
|
||||
}): string[] {
|
||||
const account = resolveMatrixAccount(params).config;
|
||||
if (params.approvalKind === "plugin") {
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixApproverId,
|
||||
});
|
||||
}
|
||||
return resolveApprovalApprovers({
|
||||
explicit: account.execApprovals?.approvers,
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixExecApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMatrixApprovalReactionAuthorizedSender(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
approvalKind: MatrixApprovalReactionKind;
|
||||
}): boolean {
|
||||
const normalizedSenderId = params.senderId
|
||||
? normalizeMatrixApproverId(params.senderId)
|
||||
: undefined;
|
||||
if (!normalizedSenderId) {
|
||||
return false;
|
||||
}
|
||||
return getMatrixApprovalReactionApprovers(params).includes(normalizedSenderId);
|
||||
}
|
||||
187
extensions/matrix/src/approval-reactions.test.ts
Normal file
187
extensions/matrix/src/approval-reactions.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// Matrix tests cover approval reactions plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMatrixApprovalReactionHint,
|
||||
clearMatrixApprovalReactionTargetsForTest,
|
||||
listMatrixApprovalReactionBindings,
|
||||
registerMatrixApprovalReactionTarget,
|
||||
resolveMatrixApprovalReactionTargetWithPersistence,
|
||||
unregisterMatrixApprovalReactionTarget,
|
||||
} from "./approval-reactions.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearMatrixApprovalReactionTargetsForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("matrix approval reactions", () => {
|
||||
it("lists reactions in stable decision order", () => {
|
||||
expect(listMatrixApprovalReactionBindings(["allow-once", "deny", "allow-always"])).toEqual([
|
||||
{ decision: "allow-once", emoji: "✅", label: "Allow once" },
|
||||
{ decision: "allow-always", emoji: "♾️", label: "Allow always" },
|
||||
{ decision: "deny", emoji: "❌", label: "Deny" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds a compact reaction hint", () => {
|
||||
expect(buildMatrixApprovalReactionHint(["allow-once", "deny"])).toBe(
|
||||
"React here: ✅ Allow once, ❌ Deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a registered approval anchor event back to an approval decision", async () => {
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
approvalId: "req-123",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
reactionKey: "✅",
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "req-123",
|
||||
decision: "allow-once",
|
||||
});
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
reactionKey: "♾️",
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "req-123",
|
||||
decision: "allow-always",
|
||||
});
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
reactionKey: "❌",
|
||||
}),
|
||||
).toEqual({
|
||||
approvalId: "req-123",
|
||||
decision: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores reactions that are not allowed on the registered approval anchor event", async () => {
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
approvalId: "req-123",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
reactionKey: "♾️",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("stops resolving reactions after the approval anchor event is unregistered", async () => {
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
approvalId: "req-123",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
});
|
||||
unregisterMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg",
|
||||
reactionKey: "✅",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("persists approval reaction targets when runtime state is available", async () => {
|
||||
const register = vi.fn().mockResolvedValue(undefined);
|
||||
const lookup = vi.fn().mockResolvedValue({
|
||||
version: 1,
|
||||
target: { approvalId: "req-persisted", allowedDecisions: ["deny"] },
|
||||
});
|
||||
const openKeyedStore = vi.fn(() => ({
|
||||
register,
|
||||
lookup,
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}));
|
||||
setMatrixRuntime({
|
||||
state: { openKeyedStore },
|
||||
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
|
||||
} as never);
|
||||
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg-2",
|
||||
approvalId: "req-123",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(register).toHaveBeenCalledTimes(1));
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
"!ops:example.org:$approval-msg-2",
|
||||
{
|
||||
version: 1,
|
||||
target: { approvalId: "req-123", allowedDecisions: ["allow-once", "deny"] },
|
||||
},
|
||||
{ ttlMs: 1000 },
|
||||
);
|
||||
|
||||
clearMatrixApprovalReactionTargetsForTest();
|
||||
await expect(
|
||||
resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg-2",
|
||||
reactionKey: "❌",
|
||||
}),
|
||||
).resolves.toEqual({ approvalId: "req-persisted", decision: "deny" });
|
||||
expect(openKeyedStore).toHaveBeenCalledTimes(2);
|
||||
expect(lookup).toHaveBeenCalledWith("!ops:example.org:$approval-msg-2");
|
||||
});
|
||||
|
||||
it("falls back to in-memory approval reaction targets when persistent state cannot open", async () => {
|
||||
const warn = vi.fn();
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
openKeyedStore: vi.fn(() => {
|
||||
throw new Error("sqlite unavailable");
|
||||
}),
|
||||
},
|
||||
logging: { getChildLogger: () => ({ warn }) },
|
||||
} as never);
|
||||
|
||||
registerMatrixApprovalReactionTarget({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg-3",
|
||||
approvalId: "req-fallback",
|
||||
allowedDecisions: ["deny"],
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixApprovalReactionTargetWithPersistence({
|
||||
roomId: "!ops:example.org",
|
||||
eventId: "$approval-msg-3",
|
||||
reactionKey: "❌",
|
||||
}),
|
||||
).toEqual({ approvalId: "req-fallback", decision: "deny" });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
208
extensions/matrix/src/approval-reactions.ts
Normal file
208
extensions/matrix/src/approval-reactions.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Matrix plugin module implements approval reactions behavior.
|
||||
import { createApprovalReactionTargetStore } from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import { getOptionalMatrixRuntime } from "./runtime.js";
|
||||
|
||||
// Matrix keeps its own reaction emoji set (checkmark/cross render reliably across
|
||||
// Matrix clients), so decision resolution stays local instead of using the SDK bindings.
|
||||
const MATRIX_APPROVAL_REACTION_META = {
|
||||
"allow-once": {
|
||||
emoji: "✅",
|
||||
label: "Allow once",
|
||||
},
|
||||
"allow-always": {
|
||||
emoji: "♾️",
|
||||
label: "Allow always",
|
||||
},
|
||||
deny: {
|
||||
emoji: "❌",
|
||||
label: "Deny",
|
||||
},
|
||||
} satisfies Record<ExecApprovalReplyDecision, { emoji: string; label: string }>;
|
||||
|
||||
const MATRIX_APPROVAL_REACTION_ORDER = [
|
||||
"allow-once",
|
||||
"allow-always",
|
||||
"deny",
|
||||
] as const satisfies readonly ExecApprovalReplyDecision[];
|
||||
|
||||
const PERSISTENT_NAMESPACE = "matrix.approval-reactions";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type MatrixApprovalReactionBinding = {
|
||||
decision: ExecApprovalReplyDecision;
|
||||
emoji: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type MatrixApprovalReactionResolution = {
|
||||
approvalId: string;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
};
|
||||
|
||||
type MatrixApprovalReactionTarget = {
|
||||
approvalId: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
|
||||
function reportPersistentApprovalReactionError(error: unknown): void {
|
||||
try {
|
||||
getOptionalMatrixRuntime()
|
||||
?.logging.getChildLogger({ plugin: "matrix", feature: "approval-reaction-state" })
|
||||
.warn("Matrix persistent approval reaction state failed", { error: String(error) });
|
||||
} catch {
|
||||
// Best effort only: persistent state must never break Matrix reactions.
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedTarget(target: unknown): MatrixApprovalReactionTarget | null {
|
||||
const value = target as Partial<MatrixApprovalReactionTarget> | null | undefined;
|
||||
if (!value || typeof value.approvalId !== "string" || !Array.isArray(value.allowedDecisions)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId: value.approvalId,
|
||||
allowedDecisions: value.allowedDecisions,
|
||||
};
|
||||
}
|
||||
|
||||
const matrixApprovalReactionTargets =
|
||||
createApprovalReactionTargetStore<MatrixApprovalReactionTarget>({
|
||||
namespace: PERSISTENT_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
|
||||
openStore: (storeParams) => getOptionalMatrixRuntime()?.state.openKeyedStore(storeParams),
|
||||
logPersistentError: reportPersistentApprovalReactionError,
|
||||
readPersistedTarget,
|
||||
});
|
||||
|
||||
function buildReactionTargetKey(roomId: string, eventId: string): string | null {
|
||||
const normalizedRoomId = roomId.trim();
|
||||
const normalizedEventId = eventId.trim();
|
||||
if (!normalizedRoomId || !normalizedEventId) {
|
||||
return null;
|
||||
}
|
||||
return `${normalizedRoomId}:${normalizedEventId}`;
|
||||
}
|
||||
|
||||
export function listMatrixApprovalReactionBindings(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): MatrixApprovalReactionBinding[] {
|
||||
const allowed = new Set(allowedDecisions);
|
||||
return MATRIX_APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)).map(
|
||||
(decision) => ({
|
||||
decision,
|
||||
emoji: MATRIX_APPROVAL_REACTION_META[decision].emoji,
|
||||
label: MATRIX_APPROVAL_REACTION_META[decision].label,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatrixApprovalReactionHint(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): string | null {
|
||||
const bindings = listMatrixApprovalReactionBindings(allowedDecisions);
|
||||
if (bindings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return `React here: ${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join(", ")}`;
|
||||
}
|
||||
|
||||
function resolveMatrixApprovalReactionDecision(
|
||||
reactionKey: string,
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
): ExecApprovalReplyDecision | null {
|
||||
const normalizedReaction = reactionKey.trim();
|
||||
if (!normalizedReaction) {
|
||||
return null;
|
||||
}
|
||||
const allowed = new Set(allowedDecisions);
|
||||
for (const decision of MATRIX_APPROVAL_REACTION_ORDER) {
|
||||
if (!allowed.has(decision)) {
|
||||
continue;
|
||||
}
|
||||
if (MATRIX_APPROVAL_REACTION_META[decision].emoji === normalizedReaction) {
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerMatrixApprovalReactionTarget(params: {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
approvalId: string;
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
ttlMs?: number;
|
||||
}): void {
|
||||
const key = buildReactionTargetKey(params.roomId, params.eventId);
|
||||
const approvalId = params.approvalId.trim();
|
||||
const allowedDecisions = Array.from(
|
||||
new Set(
|
||||
params.allowedDecisions.filter(
|
||||
(decision): decision is ExecApprovalReplyDecision =>
|
||||
decision === "allow-once" || decision === "allow-always" || decision === "deny",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!key || !approvalId || allowedDecisions.length === 0) {
|
||||
return;
|
||||
}
|
||||
matrixApprovalReactionTargets.register(
|
||||
key,
|
||||
{ approvalId, allowedDecisions },
|
||||
{ ttlMs: params.ttlMs },
|
||||
);
|
||||
}
|
||||
|
||||
export function unregisterMatrixApprovalReactionTarget(params: {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
}): void {
|
||||
const key = buildReactionTargetKey(params.roomId, params.eventId);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
matrixApprovalReactionTargets.delete(key);
|
||||
}
|
||||
|
||||
function resolveTarget(params: {
|
||||
target: MatrixApprovalReactionTarget | null | undefined;
|
||||
reactionKey: string;
|
||||
}): MatrixApprovalReactionResolution | null {
|
||||
const target = params.target;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const decision = resolveMatrixApprovalReactionDecision(
|
||||
params.reactionKey,
|
||||
target.allowedDecisions,
|
||||
);
|
||||
if (!decision) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId: target.approvalId,
|
||||
decision,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixApprovalReactionTargetWithPersistence(params: {
|
||||
roomId: string;
|
||||
eventId: string;
|
||||
reactionKey: string;
|
||||
}): Promise<MatrixApprovalReactionResolution | null> {
|
||||
const key = buildReactionTargetKey(params.roomId, params.eventId);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return resolveTarget({
|
||||
target: await matrixApprovalReactionTargets.lookup(key),
|
||||
reactionKey: params.reactionKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearMatrixApprovalReactionTargetsForTest(): void {
|
||||
matrixApprovalReactionTargets.clearForTest();
|
||||
}
|
||||
62
extensions/matrix/src/auth-precedence.ts
Normal file
62
extensions/matrix/src/auth-precedence.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Matrix plugin module implements auth precedence behavior.
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
export type MatrixResolvedStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
export type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
|
||||
|
||||
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
|
||||
|
||||
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
]);
|
||||
|
||||
function resolveMatrixStringSourceValue(value: string | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
|
||||
return (
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
|
||||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountStringValues(params: {
|
||||
accountId: string;
|
||||
account?: MatrixStringSourceMap;
|
||||
scopedEnv?: MatrixStringSourceMap;
|
||||
channel?: MatrixStringSourceMap;
|
||||
globalEnv?: MatrixStringSourceMap;
|
||||
}): MatrixResolvedStringValues {
|
||||
const fields: MatrixResolvedStringField[] = [
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
];
|
||||
const resolved = {} as MatrixResolvedStringValues;
|
||||
|
||||
for (const field of fields) {
|
||||
resolved[field] =
|
||||
resolveMatrixStringSourceValue(params.account?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
|
||||
(shouldAllowBaseAuthFallback(params.accountId, field)
|
||||
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
|
||||
: "");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
98
extensions/matrix/src/channel-account-paths.ts
Normal file
98
extensions/matrix/src/channel-account-paths.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Matrix plugin module implements channel account paths behavior.
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
import type { PinnedDispatcherPolicy, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import { formatMatrixErrorMessage } from "./matrix/errors.js";
|
||||
import type { MatrixProbe } from "./matrix/probe.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type ResolveMatrixAuth = (params: { cfg: CoreConfig; accountId?: string }) => Promise<{
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
}>;
|
||||
|
||||
type ProbeMatrix = (params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
timeoutMs: number;
|
||||
accountId?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
}) => Promise<MatrixProbe>;
|
||||
|
||||
type SendMessageMatrix = (
|
||||
to: string,
|
||||
message: string,
|
||||
options: { cfg: CoreConfig; accountId?: string },
|
||||
) => Promise<unknown>;
|
||||
|
||||
export function createMatrixProbeAccount(params: {
|
||||
resolveMatrixAuth: ResolveMatrixAuth;
|
||||
probeMatrix: ProbeMatrix;
|
||||
}) {
|
||||
return async ({
|
||||
account,
|
||||
timeoutMs,
|
||||
cfg,
|
||||
}: {
|
||||
account: { accountId?: string };
|
||||
timeoutMs?: number;
|
||||
cfg: unknown;
|
||||
}): Promise<MatrixProbe> => {
|
||||
try {
|
||||
const auth = await params.resolveMatrixAuth({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return await params.probeMatrix({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
deviceId: auth.deviceId,
|
||||
timeoutMs: timeoutMs ?? 5_000,
|
||||
accountId: account.accountId,
|
||||
allowPrivateNetwork: auth.allowPrivateNetwork,
|
||||
ssrfPolicy: auth.ssrfPolicy,
|
||||
dispatcherPolicy: auth.dispatcherPolicy,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatMatrixErrorMessage(err),
|
||||
elapsedMs: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) {
|
||||
return {
|
||||
idLabel: "matrixUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i),
|
||||
notify: async ({
|
||||
id,
|
||||
message,
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
id: string;
|
||||
message: string;
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
}) => {
|
||||
await sendMessageMatrix(`user:${id}`, message, {
|
||||
cfg,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
105
extensions/matrix/src/channel.account-paths.test.ts
Normal file
105
extensions/matrix/src/channel.account-paths.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// Matrix tests cover channel.account paths plugin behavior.
|
||||
import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
|
||||
const probeMatrixMock = vi.hoisted(() => vi.fn());
|
||||
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./matrix/send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./matrix/send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./matrix/probe.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/probe.js")>("./matrix/probe.js");
|
||||
return {
|
||||
...actual,
|
||||
probeMatrix: (...args: unknown[]) => probeMatrixMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./matrix/client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/client.js")>("./matrix/client.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("matrix account path propagation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageMatrixMock.mockResolvedValue({
|
||||
messageId: "$sent",
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
probeMatrixMock.mockResolvedValue({
|
||||
ok: true,
|
||||
error: null,
|
||||
status: null,
|
||||
elapsedMs: 5,
|
||||
userId: "@poe:example.org",
|
||||
});
|
||||
resolveMatrixAuthMock.mockResolvedValue({
|
||||
accountId: "poe",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@poe:example.org",
|
||||
accessToken: "poe-token",
|
||||
deviceId: "POEDEVICE",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards accountId when notifying pairing approval", async () => {
|
||||
const pairingText = createMatrixPairingText(sendMessageMatrixMock);
|
||||
|
||||
expect(pairingText.normalizeAllowEntry(" matrix:@user:example.org ")).toBe(
|
||||
"@user:example.org",
|
||||
);
|
||||
|
||||
await pairingText.notify({
|
||||
cfg: {} as never,
|
||||
id: "@user:example.org",
|
||||
message: pairingText.message,
|
||||
accountId: "poe",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
|
||||
"user:@user:example.org",
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
{ cfg: {}, accountId: "poe" },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards accountId and deviceId to matrix probes", async () => {
|
||||
const probeAccount = createMatrixProbeAccount({
|
||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||
probeMatrix: probeMatrixMock,
|
||||
});
|
||||
|
||||
await probeAccount({
|
||||
cfg: {} as never,
|
||||
timeoutMs: 500,
|
||||
account: {
|
||||
accountId: "poe",
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
accountId: "poe",
|
||||
});
|
||||
expect(probeMatrixMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "poe-token",
|
||||
userId: "@poe:example.org",
|
||||
deviceId: "POEDEVICE",
|
||||
timeoutMs: 500,
|
||||
accountId: "poe",
|
||||
});
|
||||
});
|
||||
});
|
||||
617
extensions/matrix/src/channel.directory.test.ts
Normal file
617
extensions/matrix/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,617 @@
|
||||
// Matrix tests cover channelirectory plugin behavior.
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime-api.js";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function requireMatrixDirectory() {
|
||||
const directory = matrixPlugin.directory;
|
||||
if (!directory?.listPeers || !directory.listGroups) {
|
||||
throw new Error("expected Matrix directory listPeers/listGroups");
|
||||
}
|
||||
return {
|
||||
listPeers: directory.listPeers,
|
||||
listGroups: directory.listGroups,
|
||||
};
|
||||
}
|
||||
|
||||
function requireMatrixReplyToModeResolver() {
|
||||
const resolveReplyToMode = matrixPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
throw new Error("expected Matrix replyToMode resolver");
|
||||
}
|
||||
return resolveReplyToMode;
|
||||
}
|
||||
|
||||
function requireDirectoryEntry(
|
||||
entries: readonly { kind: string; id: string; name?: string }[],
|
||||
kind: string,
|
||||
id: string,
|
||||
) {
|
||||
const entry = entries.find((candidate) => candidate.kind === kind && candidate.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`expected Matrix directory entry ${kind}:${id}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const directory = requireMatrixDirectory();
|
||||
|
||||
const peers = await directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(requireDirectoryEntry(peers, "user", "user:@alice:example.org").name).toBeUndefined();
|
||||
expect(requireDirectoryEntry(peers, "user", "bob").name).toBe(
|
||||
"incomplete id; expected @user:server",
|
||||
);
|
||||
expect(requireDirectoryEntry(peers, "user", "user:@carol:example.org").name).toBeUndefined();
|
||||
expect(requireDirectoryEntry(peers, "user", "user:@dana:example.org").name).toBeUndefined();
|
||||
|
||||
const groups = await directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(requireDirectoryEntry(groups, "group", "room:!room1:example.org").name).toBeUndefined();
|
||||
expect(requireDirectoryEntry(groups, "group", "#alias:example.org").name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves replyToMode from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
replyToMode: "off",
|
||||
accounts: {
|
||||
Assistant: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const resolveReplyToMode = requireMatrixReplyToModeResolver();
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("only exposes real Matrix thread ids in tool context", () => {
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
context: {
|
||||
To: "room:!room:example.org",
|
||||
ReplyToId: "$reply",
|
||||
},
|
||||
hasRepliedRef: { value: false },
|
||||
}),
|
||||
).toEqual({
|
||||
currentChannelId: "room:!room:example.org",
|
||||
currentThreadTs: undefined,
|
||||
hasRepliedRef: { value: false },
|
||||
});
|
||||
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
context: {
|
||||
To: "room:!room:example.org",
|
||||
ReplyToId: "$reply",
|
||||
MessageThreadId: "$thread",
|
||||
},
|
||||
hasRepliedRef: { value: true },
|
||||
}),
|
||||
).toEqual({
|
||||
currentChannelId: "room:!room:example.org",
|
||||
currentThreadTs: "$thread",
|
||||
hasRepliedRef: { value: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes Matrix direct user id in dm tool context", () => {
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
context: {
|
||||
From: "matrix:@alice:example.org",
|
||||
To: "room:!dm:example.org",
|
||||
ChatType: "direct",
|
||||
MessageThreadId: "$thread",
|
||||
},
|
||||
hasRepliedRef: { value: false },
|
||||
}),
|
||||
).toEqual({
|
||||
currentChannelId: "room:!dm:example.org",
|
||||
currentThreadTs: "$thread",
|
||||
currentDirectUserId: "@alice:example.org",
|
||||
hasRepliedRef: { value: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts raw room ids when inferring Matrix direct user ids", () => {
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
context: {
|
||||
From: "user:@alice:example.org",
|
||||
To: "!dm:example.org",
|
||||
ChatType: "direct",
|
||||
},
|
||||
hasRepliedRef: { value: false },
|
||||
}),
|
||||
).toEqual({
|
||||
currentChannelId: "!dm:example.org",
|
||||
currentThreadTs: undefined,
|
||||
currentDirectUserId: "@alice:example.org",
|
||||
hasRepliedRef: { value: false },
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
groupId: "matrix:room:!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches prefixed Matrix aliases in group context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"#ops:example.org": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
groupId: "matrix:room:!room:example.org",
|
||||
groupChannel: "matrix:channel:#ops:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports room access warnings against the active Matrix config path", () => {
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- 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.',
|
||||
]);
|
||||
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
accounts: {
|
||||
assistant: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
accounts: {
|
||||
assistant: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "assistant",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.accounts.assistant.groupPolicy="allowlist" + channels.matrix.accounts.assistant.groups (and optionally channels.matrix.accounts.assistant.groupAllowFrom) to restrict rooms.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports invite auto-join warnings only when explicitly enabled", () => {
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set channels.matrix.autoJoin="allowlist" + channels.matrix.autoJoinAllowlist (or channels.matrix.autoJoin="off") to restrict joins.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes matrix non-default account credentials under channels.matrix.accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://default.example.org",
|
||||
accessToken: "default-token",
|
||||
deviceId: "DEFAULTDEVICE",
|
||||
avatarUrl: "mxc://server/avatar",
|
||||
encryption: true,
|
||||
threadReplies: "inbound",
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accessToken).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.deviceId).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.avatarUrl).toBeUndefined();
|
||||
const defaultAccount = updated.channels?.["matrix"]?.accounts?.default;
|
||||
expect(defaultAccount?.accessToken).toBe("default-token");
|
||||
expect(defaultAccount?.homeserver).toBe("https://default.example.org");
|
||||
expect(defaultAccount?.deviceId).toBe("DEFAULTDEVICE");
|
||||
expect(defaultAccount?.avatarUrl).toBe("mxc://server/avatar");
|
||||
expect(defaultAccount?.encryption).toBe(true);
|
||||
expect(defaultAccount?.threadReplies).toBe("inbound");
|
||||
expect(defaultAccount?.groups).toEqual({
|
||||
"!room:example.org": { requireMention: true },
|
||||
});
|
||||
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
|
||||
expect(opsAccount?.enabled).toBe(true);
|
||||
expect(opsAccount?.homeserver).toBe("https://matrix.example.org");
|
||||
expect(opsAccount?.userId).toBe("@ops:example.org");
|
||||
expect(opsAccount?.accessToken).toBe("ops-token");
|
||||
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", {});
|
||||
expect(resolvedOps.homeserver).toBe("https://matrix.example.org");
|
||||
expect(resolvedOps.userId).toBe("@ops:example.org");
|
||||
expect(resolvedOps.accessToken).toBe("ops-token");
|
||||
expect(resolvedOps.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes default matrix account credentials under channels.matrix.accounts.default", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "bot-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
const matrixConfig = updated.channels?.["matrix"];
|
||||
expect(matrixConfig?.enabled).toBe(true);
|
||||
expect(matrixConfig?.homeserver).toBe("https://matrix.example.org");
|
||||
expect(matrixConfig?.userId).toBe("@bot:example.org");
|
||||
expect(matrixConfig?.accessToken).toBe("bot-token");
|
||||
expect(updated.channels?.["matrix"]?.accounts).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
|
||||
const envKeys = [
|
||||
"MATRIX_OPS_HOMESERVER",
|
||||
"MATRIX_OPS_USER_ID",
|
||||
"MATRIX_OPS_ACCESS_TOKEN",
|
||||
"MATRIX_OPS_PASSWORD",
|
||||
] as const;
|
||||
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
|
||||
(typeof envKeys)[number],
|
||||
string | undefined
|
||||
>;
|
||||
for (const key of envKeys) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
const error = matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
input: { useEnv: true },
|
||||
});
|
||||
expect(error).toBe(
|
||||
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
|
||||
);
|
||||
} finally {
|
||||
for (const key of envKeys) {
|
||||
if (previousEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previousEnv[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts --use-env for non-default account when scoped env vars are present", () => {
|
||||
const envKeys = {
|
||||
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
|
||||
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
|
||||
};
|
||||
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
|
||||
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
|
||||
try {
|
||||
const error = matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
input: { useEnv: true },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(envKeys)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("clears stored auth fields when switching a Matrix account to env-backed auth", () => {
|
||||
const envKeys = {
|
||||
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
|
||||
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_DEVICE_ID: process.env.MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_DEVICE_NAME: process.env.MATRIX_OPS_DEVICE_NAME,
|
||||
};
|
||||
process.env.MATRIX_OPS_HOMESERVER = "https://ops.env.example.org";
|
||||
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token";
|
||||
process.env.MATRIX_OPS_DEVICE_ID = "OPSENVDEVICE";
|
||||
process.env.MATRIX_OPS_DEVICE_NAME = "Ops Env Device";
|
||||
|
||||
try {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.inline.example.org",
|
||||
userId: "@ops:inline.example.org",
|
||||
accessToken: "ops-inline-token",
|
||||
password: "ops-inline-password", // pragma: allowlist secret
|
||||
deviceId: "OPSINLINEDEVICE",
|
||||
deviceName: "Ops Inline Device",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
useEnv: true,
|
||||
name: "Ops",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
const opsAccount = updated.channels?.["matrix"]?.accounts?.ops;
|
||||
expect(opsAccount?.name).toBe("Ops");
|
||||
expect(opsAccount?.enabled).toBe(true);
|
||||
expect(opsAccount?.encryption).toBe(true);
|
||||
expect(opsAccount?.homeserver).toBeUndefined();
|
||||
expect(opsAccount?.userId).toBeUndefined();
|
||||
expect(opsAccount?.accessToken).toBeUndefined();
|
||||
expect(opsAccount?.password).toBeUndefined();
|
||||
expect(opsAccount?.deviceId).toBeUndefined();
|
||||
expect(opsAccount?.deviceName).toBeUndefined();
|
||||
const resolvedOps = resolveMatrixConfigForAccount(updated, "ops", process.env);
|
||||
expect(resolvedOps.homeserver).toBe("https://ops.env.example.org");
|
||||
expect(resolvedOps.accessToken).toBe("ops-env-token");
|
||||
expect(resolvedOps.deviceId).toBe("OPSENVDEVICE");
|
||||
expect(resolvedOps.deviceName).toBe("Ops Env Device");
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(envKeys)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves account id from input name when explicit account id is missing", () => {
|
||||
const accountId = matrixPlugin.setup!.resolveAccountId?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: undefined,
|
||||
input: { name: "Main Bot" },
|
||||
});
|
||||
expect(accountId).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("resolves binding account id from agent id when omitted", () => {
|
||||
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
|
||||
cfg: {} as CoreConfig,
|
||||
agentId: "Ops",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("clears stale access token when switching an account to password auth", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "old-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "new-password", // pragma: allowlist secret
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBe("new-password");
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale password when switching an account to token auth", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "old-password", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
input: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "new-token",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBe("new-token");
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
265
extensions/matrix/src/channel.message-adapter.test.ts
Normal file
265
extensions/matrix/src/channel.message-adapter.test.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// Matrix tests cover channel.message adapter plugin behavior.
|
||||
import {
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageMatrix: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/send.js", () => ({
|
||||
sendMessageMatrix: mocks.sendMessageMatrix,
|
||||
sendPollMatrix: vi.fn(),
|
||||
sendTypingMatrix: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: (text: string) => [text],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "resolved-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
function lastMatrixSendOptions() {
|
||||
const options = mocks.sendMessageMatrix.mock.lastCall?.[2];
|
||||
if (!options || typeof options !== "object") {
|
||||
throw new Error("Expected Matrix send options");
|
||||
}
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("matrix channel message adapter", () => {
|
||||
beforeAll(async () => {
|
||||
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$warmup", roomId: "!room:example" });
|
||||
const sendText = matrixPlugin.message?.send?.text;
|
||||
if (!sendText) {
|
||||
throw new Error("Expected Matrix message adapter text sender");
|
||||
}
|
||||
await sendText({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "warmup",
|
||||
accountId: "default",
|
||||
});
|
||||
mocks.sendMessageMatrix.mockReset();
|
||||
});
|
||||
|
||||
it("declares Matrix markdown rendering support for shared reply payloads", () => {
|
||||
expect(matrixPlugin.meta.markdownCapable).toBe(true);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.sendMessageMatrix.mockReset();
|
||||
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
|
||||
});
|
||||
|
||||
it("backs declared durable-final capabilities with runtime outbound proofs", async () => {
|
||||
const adapter = matrixPlugin.message;
|
||||
if (!adapter?.send?.text || !adapter.send.media) {
|
||||
throw new Error("Expected Matrix message adapter send capabilities.");
|
||||
}
|
||||
const sendText = adapter.send.text;
|
||||
const sendMedia = adapter.send.media;
|
||||
|
||||
const proveText = async () => {
|
||||
mocks.sendMessageMatrix.mockClear();
|
||||
const result = await sendText({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("hello");
|
||||
const options = lastMatrixSendOptions();
|
||||
expect(options.cfg).toBe(cfg);
|
||||
expect(options.accountId).toBe("default");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["$event-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
};
|
||||
|
||||
const proveMedia = async () => {
|
||||
mocks.sendMessageMatrix.mockClear();
|
||||
const result = await sendMedia({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "caption",
|
||||
mediaUrl: "file:///tmp/cat.png",
|
||||
mediaLocalRoots: ["/tmp/openclaw"],
|
||||
accountId: "default",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("caption");
|
||||
const options = lastMatrixSendOptions();
|
||||
expect(options.cfg).toBe(cfg);
|
||||
expect(options.mediaUrl).toBe("file:///tmp/cat.png");
|
||||
expect(options.mediaLocalRoots).toEqual(["/tmp/openclaw"]);
|
||||
expect(options.audioAsVoice).toBe(true);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("voice");
|
||||
};
|
||||
|
||||
const proveReplyThread = async () => {
|
||||
mocks.sendMessageMatrix.mockClear();
|
||||
const result = await sendText({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: "threaded",
|
||||
accountId: "default",
|
||||
replyToId: "$reply",
|
||||
threadId: "$thread",
|
||||
});
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe("threaded");
|
||||
const options = lastMatrixSendOptions();
|
||||
expect(options.cfg).toBe(cfg);
|
||||
expect(options.replyToId).toBe("$reply");
|
||||
expect(options.threadId).toBe("$thread");
|
||||
expect(result.receipt.replyToId).toBe("$reply");
|
||||
expect(result.receipt.threadId).toBe("$thread");
|
||||
};
|
||||
|
||||
await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "matrixMessageAdapter",
|
||||
adapter,
|
||||
proofs: {
|
||||
text: proveText,
|
||||
media: proveMedia,
|
||||
replyTo: proveReplyThread,
|
||||
thread: proveReplyThread,
|
||||
messageSendingHooks: () => {
|
||||
expect(adapter.send?.text).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards presentation payload hooks through the registered outbound adapter", async () => {
|
||||
const outbound = matrixPlugin.outbound;
|
||||
expect(outbound?.presentationCapabilities?.supported).toBe(true);
|
||||
expect(outbound?.presentationCapabilities?.buttons).toBe(true);
|
||||
expect(outbound?.presentationCapabilities?.selects).toBe(true);
|
||||
expect(outbound?.presentationCapabilities?.context).toBe(true);
|
||||
expect(outbound?.presentationCapabilities?.divider).toBe(true);
|
||||
if (!outbound?.renderPresentation || !outbound.sendPayload) {
|
||||
throw new Error("Expected Matrix outbound presentation payload hooks.");
|
||||
}
|
||||
|
||||
const presentation = {
|
||||
title: "Select thinking level",
|
||||
tone: "info" as const,
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons" as const,
|
||||
buttons: [{ label: "Low", value: "/think low" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const rendered = await outbound.renderPresentation({
|
||||
payload: { text: "fallback", presentation },
|
||||
presentation,
|
||||
ctx: {} as never,
|
||||
});
|
||||
|
||||
const matrixChannelData = rendered?.channelData?.matrix as
|
||||
| { extraContent?: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(matrixChannelData?.extraContent).toEqual({
|
||||
"com.openclaw.presentation": {
|
||||
...presentation,
|
||||
version: 1,
|
||||
type: "message.presentation",
|
||||
},
|
||||
});
|
||||
|
||||
await outbound.sendPayload({
|
||||
cfg,
|
||||
to: "room:!room:example",
|
||||
text: rendered?.text ?? "",
|
||||
payload: rendered!,
|
||||
accountId: "default",
|
||||
threadId: "$thread",
|
||||
});
|
||||
|
||||
expect(mocks.sendMessageMatrix).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[0]).toBe("room:!room:example");
|
||||
expect(mocks.sendMessageMatrix.mock.lastCall?.[1]).toBe(rendered?.text);
|
||||
const options = lastMatrixSendOptions();
|
||||
expect(options.cfg).toBe(cfg);
|
||||
expect(options.accountId).toBe("default");
|
||||
expect(options.threadId).toBe("$thread");
|
||||
expect(options.extraContent).toEqual({
|
||||
"com.openclaw.presentation": {
|
||||
...presentation,
|
||||
version: 1,
|
||||
type: "message.presentation",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
|
||||
const adapter = matrixPlugin.message;
|
||||
|
||||
await verifyChannelMessageLiveCapabilityAdapterProofs({
|
||||
adapterName: "matrixMessageAdapter",
|
||||
adapter: adapter!,
|
||||
proofs: {
|
||||
draftPreview: () => {
|
||||
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
|
||||
},
|
||||
previewFinalization: () => {
|
||||
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
|
||||
},
|
||||
progressUpdates: () => {
|
||||
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
quietFinalization: () => {
|
||||
expect(adapter!.live?.finalizer?.capabilities?.previewReceipt).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await verifyChannelMessageLiveFinalizerProofs({
|
||||
adapterName: "matrixMessageAdapter",
|
||||
adapter: adapter!,
|
||||
proofs: {
|
||||
finalEdit: () => {
|
||||
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
|
||||
},
|
||||
normalFallback: () => {
|
||||
expect(adapter!.send!.text).toBeTypeOf("function");
|
||||
},
|
||||
discardPending: () => {
|
||||
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
previewReceipt: () => {
|
||||
expect(adapter!.live?.capabilities?.quietFinalization).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("declares bullets as the markdown table default", () => {
|
||||
expect(matrixPlugin.messaging?.defaultMarkdownTableMode).toBe("bullets");
|
||||
});
|
||||
});
|
||||
50
extensions/matrix/src/channel.resolve.test.ts
Normal file
50
extensions/matrix/src/channel.resolve.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Matrix tests cover channel.resolve plugin behavior.
|
||||
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
|
||||
|
||||
vi.mock("./resolver.runtime.js", () => ({
|
||||
matrixResolverRuntime: {
|
||||
resolveMatrixTargets: resolveMatrixTargetsMock,
|
||||
},
|
||||
}));
|
||||
|
||||
import { matrixResolverAdapter } from "./resolver.js";
|
||||
|
||||
describe("matrix resolver adapter", () => {
|
||||
beforeEach(() => {
|
||||
resolveMatrixTargetsMock.mockClear();
|
||||
});
|
||||
|
||||
it("forwards accountId into Matrix target resolution", async () => {
|
||||
await matrixResolverAdapter.resolveTargets({
|
||||
cfg: { channels: { matrix: {} } },
|
||||
accountId: "ops",
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(resolveMatrixTargetsMock).toHaveBeenCalledTimes(1);
|
||||
const [forwarded] = resolveMatrixTargetsMock.mock.calls.at(0) as unknown as [
|
||||
{
|
||||
accountId: string;
|
||||
cfg: { channels: { matrix: Record<string, never> } };
|
||||
inputs: string[];
|
||||
kind: string;
|
||||
runtime: { error: unknown; exit: unknown; log: unknown };
|
||||
},
|
||||
];
|
||||
expect(forwarded).toEqual({
|
||||
cfg: { channels: { matrix: {} } },
|
||||
accountId: "ops",
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
runtime: forwarded?.runtime,
|
||||
});
|
||||
expect(forwarded?.runtime.log).toBeTypeOf("function");
|
||||
expect(forwarded?.runtime.error).toBeTypeOf("function");
|
||||
expect(forwarded?.runtime.exit).toBeTypeOf("function");
|
||||
});
|
||||
});
|
||||
18
extensions/matrix/src/channel.runtime.ts
Normal file
18
extensions/matrix/src/channel.runtime.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Matrix plugin module implements channel behavior.
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix, sendTypingMatrix } from "./matrix/send.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
export const matrixChannelRuntime = {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
matrixOutbound,
|
||||
probeMatrix,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixTargets,
|
||||
sendMessageMatrix,
|
||||
sendTypingMatrix,
|
||||
};
|
||||
310
extensions/matrix/src/channel.setup.test.ts
Normal file
310
extensions/matrix/src/channel.setup.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
// Matrix tests cover channel.setup plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime-api.js";
|
||||
|
||||
const verificationMocks = vi.hoisted(() => ({
|
||||
bootstrapMatrixVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
|
||||
}));
|
||||
|
||||
import { matrixConfigAdapter } from "./config-adapter.js";
|
||||
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("matrix setup post-write bootstrap", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
});
|
||||
const encryptedDefaultCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const defaultPasswordInput = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
} as const;
|
||||
const runtime: RuntimeEnv = {
|
||||
log,
|
||||
error,
|
||||
exit,
|
||||
};
|
||||
|
||||
function applyAccountConfig(params: {
|
||||
previousCfg: CoreConfig;
|
||||
accountId: string;
|
||||
input: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
previousCfg: params.previousCfg,
|
||||
accountId: params.accountId,
|
||||
input: params.input,
|
||||
nextCfg: matrixSetupAdapter.applyAccountConfig({
|
||||
cfg: params.previousCfg,
|
||||
accountId: params.accountId,
|
||||
input: params.input,
|
||||
}) as CoreConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function applyDefaultAccountConfig(input: Record<string, unknown> = defaultPasswordInput) {
|
||||
return applyAccountConfig({
|
||||
previousCfg: encryptedDefaultCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
function mockBootstrapResult(params: {
|
||||
success: boolean;
|
||||
backupVersion?: string | null;
|
||||
error?: string;
|
||||
}) {
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: params.success,
|
||||
...(params.error ? { error: params.error } : {}),
|
||||
verification: {
|
||||
backupVersion: params.backupVersion ?? null,
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runAfterAccountConfigWritten(params: {
|
||||
previousCfg: CoreConfig;
|
||||
nextCfg: CoreConfig;
|
||||
accountId: string;
|
||||
input: Record<string, unknown>;
|
||||
}) {
|
||||
await runMatrixSetupBootstrapAfterConfigWrite({
|
||||
previousCfg: params.previousCfg,
|
||||
cfg: params.nextCfg,
|
||||
accountId: params.accountId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
async function withSavedEnv<T>(
|
||||
values: Record<string, string | undefined>,
|
||||
run: () => Promise<T> | T,
|
||||
) {
|
||||
const previousEnv = Object.fromEntries(
|
||||
Object.keys(values).map((key) => [key, process.env[key]]),
|
||||
) as Record<string, string | undefined>;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
verificationMocks.bootstrapMatrixVerification.mockReset();
|
||||
log.mockClear();
|
||||
error.mockClear();
|
||||
exit.mockClear();
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
it("bootstraps verification for newly added encrypted accounts", async () => {
|
||||
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
|
||||
mockBootstrapResult({ success: true, backupVersion: "7" });
|
||||
|
||||
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bootstrap verification for already configured accounts", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
flurry: {
|
||||
encryption: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "new-token",
|
||||
};
|
||||
const { nextCfg, accountId } = applyAccountConfig({
|
||||
previousCfg,
|
||||
accountId: "flurry",
|
||||
input,
|
||||
});
|
||||
|
||||
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps verification when setup enables encryption for an existing account", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const nextCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
mockBootstrapResult({ success: true, backupVersion: "8" });
|
||||
|
||||
await runAfterAccountConfigWritten({
|
||||
previousCfg,
|
||||
nextCfg,
|
||||
accountId: "default",
|
||||
input: {},
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 8');
|
||||
});
|
||||
|
||||
it("logs a warning when verification bootstrap fails", async () => {
|
||||
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
|
||||
mockBootstrapResult({
|
||||
success: false,
|
||||
error: "no room-key backup exists on the homeserver",
|
||||
});
|
||||
|
||||
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
|
||||
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
|
||||
await withSavedEnv(
|
||||
{
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
},
|
||||
async () => {
|
||||
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig({
|
||||
useEnv: true,
|
||||
});
|
||||
mockBootstrapResult({ success: true, backupVersion: "9" });
|
||||
|
||||
await runAfterAccountConfigWritten({ previousCfg, nextCfg, accountId, input });
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
|
||||
return withSavedEnv(
|
||||
{
|
||||
MATRIX_HOMESERVER: undefined,
|
||||
MATRIX_USER_ID: undefined,
|
||||
MATRIX_ACCESS_TOKEN: undefined,
|
||||
MATRIX_PASSWORD: undefined,
|
||||
MATRIX_DEFAULT_HOMESERVER: undefined,
|
||||
MATRIX_DEFAULT_USER_ID: undefined,
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN: undefined,
|
||||
MATRIX_DEFAULT_PASSWORD: undefined,
|
||||
},
|
||||
() => {
|
||||
expect(
|
||||
matrixSetupAdapter.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "default",
|
||||
input: { useEnv: true },
|
||||
}),
|
||||
).toContain("Set Matrix env vars for the default account");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears allowPrivateNetwork and proxy when deleting the default Matrix account config", () => {
|
||||
const updated = matrixConfigAdapter.deleteAccount?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "http://localhost.localdomain:8008",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.matrix).toEqual({
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
49
extensions/matrix/src/channel.setup.ts
Normal file
49
extensions/matrix/src/channel.setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Matrix plugin module implements channel.setup behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { matrixConfigAdapter } from "./config-adapter.js";
|
||||
import { MatrixChannelConfigSchema } from "./config-schema.js";
|
||||
import { resolveMatrixAccount, type ResolvedMatrixAccount } from "./matrix/accounts.js";
|
||||
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
|
||||
|
||||
const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({
|
||||
matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard,
|
||||
}));
|
||||
|
||||
export const matrixSetupPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
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,
|
||||
},
|
||||
setupWizard: matrixSetupWizard,
|
||||
setup: matrixSetupAdapter,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.matrix"] },
|
||||
configSchema: MatrixChannelConfigSchema,
|
||||
config: {
|
||||
...matrixConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.homeserver,
|
||||
},
|
||||
}),
|
||||
hasConfiguredState: ({ cfg }) => resolveMatrixAccount({ cfg }).configured,
|
||||
},
|
||||
};
|
||||
680
extensions/matrix/src/channel.ts
Normal file
680
extensions/matrix/src/channel.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
// Matrix plugin module implements channel behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
createChannelMessageAdapterFromOutbound,
|
||||
createRuntimeOutboundDelegates,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
projectAccountConfigWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
createLazyRuntimeNamedExport,
|
||||
createLazyRuntimeModule,
|
||||
} from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
buildProbeChannelStatusSummary,
|
||||
collectStatusIssuesFromLastError,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
chunkTextForOutbound,
|
||||
sanitizeAssistantVisibleText,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { matrixApprovalCapability } from "./approval-native.js";
|
||||
import { createMatrixPairingText, createMatrixProbeAccount } from "./channel-account-paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, matrixConfigAdapter } from "./config-adapter.js";
|
||||
import { MatrixChannelConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
import { shouldSuppressLocalMatrixExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import {
|
||||
resolveMatrixGroupRequireMention,
|
||||
resolveMatrixGroupToolPolicy,
|
||||
} from "./group-mentions.js";
|
||||
import {
|
||||
resolveMatrixAccount,
|
||||
resolveMatrixAccountConfig,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import type { MatrixProbe } from "./matrix/probe.js";
|
||||
import {
|
||||
normalizeMatrixMessagingTarget,
|
||||
resolveMatrixDirectUserId,
|
||||
resolveMatrixTargetIdentity,
|
||||
} from "./matrix/target-ids.js";
|
||||
import {
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKey,
|
||||
setMatrixThreadBindingMaxAgeBySessionKey,
|
||||
} from "./matrix/thread-bindings-shared.js";
|
||||
import { matrixResolverAdapter } from "./resolver.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
import {
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
singleAccountKeysToMove,
|
||||
} from "./setup-contract.js";
|
||||
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
|
||||
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
|
||||
import { resolveMatrixInboundConversation } from "./thread-binding-api.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 loadMatrixSetupWizard = createLazyRuntimeNamedExport(
|
||||
() => import("./setup-surface.js"),
|
||||
"matrixSetupWizard",
|
||||
);
|
||||
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"matrixChannelRuntime",
|
||||
);
|
||||
|
||||
const loadMatrixDoctorModule = createLazyRuntimeModule(() => import("./doctor.js"));
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
label: "Matrix",
|
||||
selectionLabel: "Matrix (plugin)",
|
||||
docsPath: "/channels/matrix",
|
||||
docsLabel: "matrix",
|
||||
blurb: "open protocol; configure a homeserver + access token.",
|
||||
order: 70,
|
||||
markdownCapable: true,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function buildMatrixTrafficStatusSummary(
|
||||
snapshot?: {
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
return {
|
||||
lastInboundAt: snapshot?.lastInboundAt ?? null,
|
||||
lastOutboundAt: snapshot?.lastOutboundAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const matrixDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "sender",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
|
||||
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
|
||||
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
cleanStaleConfig: async ({ cfg }) =>
|
||||
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
|
||||
};
|
||||
|
||||
const listMatrixDirectoryPeersFromConfig =
|
||||
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
|
||||
kind: "user",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
|
||||
resolveSources: (account) => [
|
||||
account.config.dm?.allowFrom ?? [],
|
||||
account.config.groupAllowFrom ?? [],
|
||||
...Object.values(account.config.groups ?? account.config.rooms ?? {}).map(
|
||||
(room) => room.users ?? [],
|
||||
),
|
||||
],
|
||||
normalizeId: (entry) => {
|
||||
const raw = entry.replace(/^matrix:/i, "").trim();
|
||||
if (!raw || raw === "*") {
|
||||
return null;
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(raw);
|
||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||
return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned;
|
||||
},
|
||||
});
|
||||
|
||||
const listMatrixDirectoryGroupsFromConfig =
|
||||
createResolvedDirectoryEntriesLister<ResolvedMatrixAccount>({
|
||||
kind: "group",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
|
||||
resolveSources: (account) => [Object.keys(account.config.groups ?? account.config.rooms ?? {})],
|
||||
normalizeId: (entry) => {
|
||||
const raw = entry.replace(/^matrix:/i, "").trim();
|
||||
if (!raw || raw === "*") {
|
||||
return null;
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
|
||||
return raw;
|
||||
}
|
||||
return raw.startsWith("!") ? `room:${raw}` : raw;
|
||||
},
|
||||
});
|
||||
|
||||
function projectMatrixConversationBinding(binding: {
|
||||
boundAt: number;
|
||||
metadata?: {
|
||||
lastActivityAt?: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
boundAt: binding.boundAt,
|
||||
lastActivityAt:
|
||||
typeof binding.metadata?.lastActivityAt === "number"
|
||||
? binding.metadata.lastActivityAt
|
||||
: binding.boundAt,
|
||||
idleTimeoutMs:
|
||||
typeof binding.metadata?.idleTimeoutMs === "number"
|
||||
? binding.metadata.idleTimeoutMs
|
||||
: undefined,
|
||||
maxAgeMs:
|
||||
typeof binding.metadata?.maxAgeMs === "number" ? binding.metadata.maxAgeMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
|
||||
channelKey: "matrix",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
|
||||
});
|
||||
|
||||
const collectMatrixSecurityWarnings =
|
||||
createAllowlistProviderOpenWarningCollector<ResolvedMatrixAccount>({
|
||||
providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
buildOpenWarning: {
|
||||
surface: "Matrix rooms",
|
||||
openBehavior: "allows any room to trigger (mention-gated)",
|
||||
remediation:
|
||||
'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms',
|
||||
},
|
||||
});
|
||||
|
||||
function resolveMatrixAccountConfigPath(accountId: string, field: string): string {
|
||||
return accountId === DEFAULT_ACCOUNT_ID
|
||||
? `channels.matrix.${field}`
|
||||
: `channels.matrix.accounts.${accountId}.${field}`;
|
||||
}
|
||||
|
||||
function collectMatrixSecurityWarningsForAccount(params: {
|
||||
account: ResolvedMatrixAccount;
|
||||
cfg: CoreConfig;
|
||||
}): string[] {
|
||||
const warnings = collectMatrixSecurityWarnings(params);
|
||||
if (params.account.accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const groupPolicyPath = resolveMatrixAccountConfigPath(params.account.accountId, "groupPolicy");
|
||||
const groupsPath = resolveMatrixAccountConfigPath(params.account.accountId, "groups");
|
||||
const groupAllowFromPath = resolveMatrixAccountConfigPath(
|
||||
params.account.accountId,
|
||||
"groupAllowFrom",
|
||||
);
|
||||
return warnings.map((warning) =>
|
||||
warning
|
||||
.replace("channels.matrix.groupPolicy", groupPolicyPath)
|
||||
.replace("channels.matrix.groups", groupsPath)
|
||||
.replace("channels.matrix.groupAllowFrom", groupAllowFromPath),
|
||||
);
|
||||
}
|
||||
if (params.account.config.autoJoin !== "always") {
|
||||
return warnings;
|
||||
}
|
||||
const autoJoinPath = resolveMatrixAccountConfigPath(params.account.accountId, "autoJoin");
|
||||
const autoJoinAllowlistPath = resolveMatrixAccountConfigPath(
|
||||
params.account.accountId,
|
||||
"autoJoinAllowlist",
|
||||
);
|
||||
return [
|
||||
...warnings,
|
||||
`- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set ${autoJoinPath}="allowlist" + ${autoJoinAllowlistPath} (or ${autoJoinPath}="off") to restrict joins.`,
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeMatrixAcpConversationId(conversationId: string) {
|
||||
const target = resolveMatrixTargetIdentity(conversationId);
|
||||
if (!target || target.kind !== "room") {
|
||||
return null;
|
||||
}
|
||||
return { conversationId: target.id };
|
||||
}
|
||||
|
||||
function matchMatrixAcpConversation(params: {
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
const binding = normalizeMatrixAcpConversationId(params.bindingConversationId);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
if (binding.conversationId === params.conversationId) {
|
||||
return { conversationId: params.conversationId, matchPriority: 2 };
|
||||
}
|
||||
if (
|
||||
params.parentConversationId &&
|
||||
params.parentConversationId !== params.conversationId &&
|
||||
binding.conversationId === params.parentConversationId
|
||||
) {
|
||||
return {
|
||||
conversationId: params.parentConversationId,
|
||||
matchPriority: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMatrixCommandConversation(params: {
|
||||
threadId?: string;
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
}) {
|
||||
const parentConversationId = [params.originatingTo, params.commandTo, params.fallbackTo]
|
||||
.map((candidate) => {
|
||||
const trimmed = candidate?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const target = resolveMatrixTargetIdentity(trimmed);
|
||||
return target?.kind === "room" ? target.id : undefined;
|
||||
})
|
||||
.find((candidate): candidate is string => Boolean(candidate));
|
||||
if (params.threadId) {
|
||||
return {
|
||||
conversationId: params.threadId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
return parentConversationId ? { conversationId: parentConversationId } : null;
|
||||
}
|
||||
|
||||
function resolveMatrixDeliveryTarget(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
const parentConversationId = params.parentConversationId?.trim();
|
||||
if (parentConversationId && parentConversationId !== params.conversationId.trim()) {
|
||||
const parentTarget = resolveMatrixTargetIdentity(parentConversationId);
|
||||
if (parentTarget?.kind === "room") {
|
||||
return {
|
||||
to: `room:${parentTarget.id}`,
|
||||
threadId: params.conversationId.trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
const conversationTarget = resolveMatrixTargetIdentity(params.conversationId);
|
||||
if (conversationTarget?.kind === "room") {
|
||||
return { to: `room:${conversationTarget.id}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const matrixChannelOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
|
||||
deliveryCapabilities: {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
media: true,
|
||||
replyTo: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
presentationCapabilities: {
|
||||
supported: true,
|
||||
buttons: true,
|
||||
selects: true,
|
||||
context: true,
|
||||
divider: true,
|
||||
limits: {
|
||||
text: {
|
||||
markdownDialect: "markdown",
|
||||
supportsEdit: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg,
|
||||
accountId,
|
||||
payload,
|
||||
}),
|
||||
...createRuntimeOutboundDelegates({
|
||||
getRuntime: loadMatrixChannelRuntime,
|
||||
renderPresentation: {
|
||||
resolve: (runtime) => runtime.matrixOutbound.renderPresentation,
|
||||
unavailableMessage: "Matrix outbound presentation rendering is unavailable",
|
||||
},
|
||||
sendPayload: {
|
||||
resolve: (runtime) => runtime.matrixOutbound.sendPayload,
|
||||
unavailableMessage: "Matrix outbound payload delivery is unavailable",
|
||||
},
|
||||
sendText: {
|
||||
resolve: (runtime) => runtime.matrixOutbound.sendText,
|
||||
unavailableMessage: "Matrix outbound text delivery is unavailable",
|
||||
},
|
||||
sendMedia: {
|
||||
resolve: (runtime) => runtime.matrixOutbound.sendMedia,
|
||||
unavailableMessage: "Matrix outbound media delivery is unavailable",
|
||||
},
|
||||
sendPoll: {
|
||||
resolve: (runtime) => runtime.matrixOutbound.sendPoll,
|
||||
unavailableMessage: "Matrix outbound poll delivery is unavailable",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const matrixMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "matrix",
|
||||
outbound: matrixChannelOutbound,
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
progressUpdates: true,
|
||||
quietFinalization: true,
|
||||
},
|
||||
finalizer: {
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
normalFallback: true,
|
||||
discardPending: true,
|
||||
previewReceipt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
|
||||
createChatChannelPlugin<ResolvedMatrixAccount, MatrixProbe>({
|
||||
base: {
|
||||
id: "matrix",
|
||||
meta,
|
||||
setupWizard: createMatrixSetupWizardProxy(async () => ({
|
||||
matrixSetupWizard: await loadMatrixSetupWizard(),
|
||||
})),
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
polls: true,
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
tts: {
|
||||
voice: {
|
||||
synthesisTarget: "voice-note",
|
||||
},
|
||||
},
|
||||
},
|
||||
reload: { configPrefixes: ["channels.matrix"] },
|
||||
configSchema: MatrixChannelConfigSchema,
|
||||
config: {
|
||||
...matrixConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.homeserver,
|
||||
},
|
||||
}),
|
||||
},
|
||||
approvalCapability: matrixApprovalCapability,
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
defaultTopLevelPlacement: "child",
|
||||
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
|
||||
setMatrixThreadBindingIdleTimeoutBySessionKey({
|
||||
targetSessionKey,
|
||||
accountId: accountId ?? "",
|
||||
idleTimeoutMs,
|
||||
}).map(projectMatrixConversationBinding),
|
||||
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
|
||||
setMatrixThreadBindingMaxAgeBySessionKey({
|
||||
targetSessionKey,
|
||||
accountId: accountId ?? "",
|
||||
maxAgeMs,
|
||||
}).map(projectMatrixConversationBinding),
|
||||
},
|
||||
messaging: {
|
||||
defaultMarkdownTableMode: "bullets",
|
||||
targetPrefixes: ["matrix"],
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
resolveInboundConversation: ({ to, conversationId, threadId }) =>
|
||||
resolveMatrixInboundConversation({ to, conversationId, threadId }),
|
||||
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
|
||||
resolveMatrixDeliveryTarget({ conversationId, parentConversationId }),
|
||||
resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params),
|
||||
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: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => {
|
||||
const entries = await listMatrixDirectoryPeersFromConfig(params);
|
||||
return entries.map((entry) => {
|
||||
const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id;
|
||||
const incomplete = !raw.startsWith("@") || !raw.includes(":");
|
||||
return incomplete
|
||||
? Object.assign({}, entry, { name: `incomplete id; expected @user:server` })
|
||||
: entry;
|
||||
});
|
||||
},
|
||||
listGroups: async (params) => await listMatrixDirectoryGroupsFromConfig(params),
|
||||
...createRuntimeDirectoryLiveAdapter({
|
||||
getRuntime: loadMatrixChannelRuntime,
|
||||
listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive,
|
||||
listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive,
|
||||
}),
|
||||
}),
|
||||
resolver: matrixResolverAdapter,
|
||||
actions: matrixMessageActions,
|
||||
message: matrixMessageAdapter,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
setup: {
|
||||
...matrixSetupAdapter,
|
||||
singleAccountKeysToMove,
|
||||
namedAccountPromotionKeys,
|
||||
resolveSingleAccountPromotionTarget,
|
||||
},
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeMatrixAcpConversationId(conversationId),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchMatrixAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
resolveCommandConversation: ({ threadId, originatingTo, commandTo, fallbackTo }) =>
|
||||
resolveMatrixCommandConversation({
|
||||
threadId,
|
||||
originatingTo,
|
||||
commandTo,
|
||||
fallbackTo,
|
||||
}),
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedMatrixAccount, MatrixProbe>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
||||
probeAccount: async ({ account, timeoutMs, cfg }) =>
|
||||
await createMatrixProbeAccount({
|
||||
resolveMatrixAuth: async ({ cfg: cfgLocal, accountId }) =>
|
||||
(await loadMatrixChannelRuntime()).resolveMatrixAuth({
|
||||
cfg: cfgLocal,
|
||||
accountId,
|
||||
}),
|
||||
probeMatrix: async (params) =>
|
||||
await (await loadMatrixChannelRuntime()).probeMatrix(params),
|
||||
})({
|
||||
account,
|
||||
timeoutMs,
|
||||
cfg,
|
||||
}),
|
||||
resolveAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
baseUrl: account.homeserver,
|
||||
lastProbeAt: runtime?.lastProbeAt ?? null,
|
||||
...buildMatrixTrafficStatusSummary(runtime),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
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/monitor/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/monitor/index.js").monitorMatrixProvider;
|
||||
try {
|
||||
const module = await import("./matrix/monitor/index.js");
|
||||
monitorMatrixProvider = module.monitorMatrixProvider;
|
||||
} finally {
|
||||
// Release lock after import completes or fails
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
return monitorMatrixProvider({
|
||||
runtime: ctx.runtime,
|
||||
channelRuntime: ctx.channelRuntime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
mediaMaxMb: account.config.mediaMaxMb,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
},
|
||||
},
|
||||
doctor: matrixDoctor,
|
||||
lifecycle: {
|
||||
runStartupMaintenance: runMatrixStartupMaintenance,
|
||||
},
|
||||
heartbeat: {
|
||||
sendTyping: async ({ cfg, to, accountId }) => {
|
||||
await (
|
||||
await loadMatrixChannelRuntime()
|
||||
).sendTypingMatrix(to, true, {
|
||||
cfg: cfg as CoreConfig,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
clearTyping: async ({ cfg, to, accountId }) => {
|
||||
await (
|
||||
await loadMatrixChannelRuntime()
|
||||
).sendTypingMatrix(to, false, {
|
||||
cfg: cfg as CoreConfig,
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveMatrixDmPolicy,
|
||||
collectWarnings: projectAccountConfigWarningCollector(
|
||||
(cfg) => cfg as CoreConfig,
|
||||
collectMatrixSecurityWarningsForAccount,
|
||||
),
|
||||
},
|
||||
pairing: {
|
||||
text: createMatrixPairingText(
|
||||
async (to, message, options) =>
|
||||
await (await loadMatrixChannelRuntime()).sendMessageMatrix(to, message, options),
|
||||
),
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: createScopedAccountReplyToModeResolver<
|
||||
ReturnType<typeof resolveMatrixAccountConfig>
|
||||
>({
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccountConfig),
|
||||
resolveReplyToMode: (account) => account.replyToMode,
|
||||
}),
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const currentTarget = context.To;
|
||||
return {
|
||||
currentChannelId: normalizeOptionalString(currentTarget),
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||
currentDirectUserId: resolveMatrixDirectUserId({
|
||||
from: context.From,
|
||||
to: context.To,
|
||||
chatType: context.ChatType,
|
||||
}),
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
},
|
||||
outbound: matrixChannelOutbound,
|
||||
});
|
||||
20
extensions/matrix/src/cli-metadata.ts
Normal file
20
extensions/matrix/src/cli-metadata.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Matrix plugin module implements cli metadata behavior.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
|
||||
export function registerMatrixCliMetadata(api: OpenClawPluginApi) {
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerMatrixCli } = await import("./cli.js");
|
||||
registerMatrixCli({ program });
|
||||
},
|
||||
{
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Manage Matrix accounts, verification, devices, and profile state",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
1988
extensions/matrix/src/cli.test.ts
Normal file
1988
extensions/matrix/src/cli.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2318
extensions/matrix/src/cli.ts
Normal file
2318
extensions/matrix/src/cli.ts
Normal file
File diff suppressed because it is too large
Load Diff
42
extensions/matrix/src/config-adapter.ts
Normal file
42
extensions/matrix/src/config-adapter.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Matrix helper module supports config adapter behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
resolveMatrixAccountConfig,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { normalizeMatrixAllowList } from "./matrix/monitor/allowlist.js";
|
||||
|
||||
export { DEFAULT_ACCOUNT_ID };
|
||||
|
||||
export const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
||||
ResolvedMatrixAccount,
|
||||
ReturnType<typeof resolveMatrixAccountConfig>
|
||||
>({
|
||||
sectionKey: "matrix",
|
||||
listAccountIds: listMatrixAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveMatrixAccount),
|
||||
resolveAccessorAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultMatrixAccountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"homeserver",
|
||||
"network",
|
||||
"proxy",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
"avatarUrl",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
||||
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
|
||||
});
|
||||
128
extensions/matrix/src/config-schema.test.ts
Normal file
128
extensions/matrix/src/config-schema.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Matrix tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("MatrixConfigSchema SecretInput", () => {
|
||||
it("accepts SecretRef accessToken at top-level", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password at top-level", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts dm threadReplies overrides", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
threadReplies: "off",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts dm sessionScope overrides", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
sessionScope: "per-room",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the Matrix name matching compatibility flag", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
dangerouslyAllowNameMatching: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts room-level account assignments", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
groups: {
|
||||
"!room:example.org": {
|
||||
enabled: true,
|
||||
account: "axis",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
throw new Error("expected schema parse to succeed");
|
||||
}
|
||||
expect(result.data.groups?.["!room:example.org"]?.account).toBe("axis");
|
||||
});
|
||||
|
||||
it("accepts legacy room-level account assignments", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
rooms: {
|
||||
"!room:example.org": {
|
||||
enabled: true,
|
||||
account: "axis",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
throw new Error("expected schema parse to succeed");
|
||||
}
|
||||
expect(result.data.rooms?.["!room:example.org"]?.account).toBe("axis");
|
||||
});
|
||||
|
||||
it("accepts quiet Matrix streaming mode", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
streaming: "quiet",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts scalar progress Matrix streaming mode", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
streaming: "progress",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts Matrix streaming preview tool progress config", () => {
|
||||
const result = MatrixConfigSchema.safeParse({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Shelling",
|
||||
maxLines: 4,
|
||||
toolProgress: false,
|
||||
},
|
||||
preview: {
|
||||
toolProgress: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
162
extensions/matrix/src/config-schema.ts
Normal file
162
extensions/matrix/src/config-schema.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Matrix helper module supports config schema behavior.
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
buildNestedDmConfigSchema,
|
||||
ContextVisibilityModeSchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
MentionPatternsPolicySchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
||||
import { z } from "zod";
|
||||
import { matrixChannelConfigUiHints } from "./config-ui-hints.js";
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
profile: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
verification: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixThreadBindingsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
idleHours: z.number().nonnegative().optional(),
|
||||
maxAgeHours: z.number().nonnegative().optional(),
|
||||
spawnSessions: z.boolean().optional(),
|
||||
defaultSpawnContext: z.enum(["isolated", "fork"]).optional(),
|
||||
spawnSubagentSessions: z.boolean().optional(),
|
||||
spawnAcpSessions: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixExecApprovalsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
approvers: AllowFromListSchema,
|
||||
agentFilter: z.array(z.string()).optional(),
|
||||
sessionFilter: z.array(z.string()).optional(),
|
||||
target: z.enum(["dm", "channel", "both"]).optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const botLoopProtectionSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
maxEventsPerWindow: z.number().int().positive().optional(),
|
||||
windowSeconds: z.number().int().positive().optional(),
|
||||
cooldownSeconds: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const matrixRoomSchema = z
|
||||
.object({
|
||||
account: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
|
||||
botLoopProtection: botLoopProtectionSchema,
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: AllowFromListSchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const matrixNetworkSchema = z
|
||||
.object({
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const matrixStreamingSchema = z
|
||||
.object({
|
||||
mode: z.enum(["partial", "quiet", "progress", "off"]).optional(),
|
||||
progress: z
|
||||
.object({
|
||||
label: z.union([z.string(), z.literal(false)]).optional(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
maxLines: z.number().int().positive().optional(),
|
||||
maxLineChars: z.number().int().positive().optional(),
|
||||
toolProgress: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
preview: z
|
||||
.object({
|
||||
toolProgress: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const MatrixConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
accounts: z.record(z.string(), z.unknown()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
homeserver: z.string().optional(),
|
||||
network: matrixNetworkSchema,
|
||||
proxy: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
accessToken: buildSecretInputSchema().optional(),
|
||||
password: buildSecretInputSchema().optional(),
|
||||
deviceId: z.string().optional(),
|
||||
deviceName: z.string().optional(),
|
||||
avatarUrl: z.string().optional(),
|
||||
initialSyncLimit: z.number().optional(),
|
||||
encryption: z.boolean().optional(),
|
||||
allowlistOnly: z.boolean().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
|
||||
botLoopProtection: botLoopProtectionSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
mentionPatterns: MentionPatternsPolicySchema.optional(),
|
||||
contextVisibility: ContextVisibilityModeSchema.optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
streaming: z
|
||||
.union([z.enum(["partial", "quiet", "progress", "off"]), z.boolean(), matrixStreamingSchema])
|
||||
.optional(),
|
||||
replyToMode: z.enum(["off", "first", "all", "batched"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
textChunkLimit: z.number().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z
|
||||
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
|
||||
.optional(),
|
||||
reactionNotifications: z.enum(["off", "own"]).optional(),
|
||||
threadBindings: matrixThreadBindingsSchema,
|
||||
startupVerification: z.enum(["off", "if-unverified"]).optional(),
|
||||
startupVerificationCooldownHours: z.number().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
|
||||
autoJoinAllowlist: AllowFromListSchema,
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
dm: buildNestedDmConfigSchema({
|
||||
sessionScope: z.enum(["per-user", "per-room"]).optional(),
|
||||
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
|
||||
}),
|
||||
execApprovals: matrixExecApprovalsSchema,
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
});
|
||||
|
||||
export const MatrixChannelConfigSchema = buildChannelConfigSchema(MatrixConfigSchema, {
|
||||
uiHints: matrixChannelConfigUiHints,
|
||||
});
|
||||
73
extensions/matrix/src/config-ui-hints.ts
Normal file
73
extensions/matrix/src/config-ui-hints.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Matrix helper module supports config ui hints behavior.
|
||||
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
|
||||
|
||||
export const matrixChannelConfigUiHints = {
|
||||
mentionPatterns: {
|
||||
label: "Matrix Mention Pattern Policy",
|
||||
help: "Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied.",
|
||||
},
|
||||
"mentionPatterns.mode": {
|
||||
label: "Matrix Mention Pattern Mode",
|
||||
help: '"allow" enables configured regex mention patterns unless denyIn matches; "deny" disables them unless allowIn matches.',
|
||||
},
|
||||
"mentionPatterns.allowIn": {
|
||||
label: "Matrix Mention Pattern Allowlist",
|
||||
help: "Matrix room IDs where configured regex mention patterns are enabled when mode is deny.",
|
||||
},
|
||||
"mentionPatterns.denyIn": {
|
||||
label: "Matrix Mention Pattern Denylist",
|
||||
help: "Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers.",
|
||||
},
|
||||
allowBots: {
|
||||
label: "Matrix Allow Bot Messages",
|
||||
help: 'Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set "mentions" to require a visible room mention.',
|
||||
},
|
||||
botLoopProtection: {
|
||||
label: "Matrix Bot Loop Protection",
|
||||
help: "Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch.",
|
||||
},
|
||||
"botLoopProtection.enabled": {
|
||||
label: "Matrix Bot Loop Protection Enabled",
|
||||
help: 'Enable the bot-pair loop guard. Defaults to true when allowBots is true or "mentions", and false when configured bot messages are ignored.',
|
||||
},
|
||||
"botLoopProtection.maxEventsPerWindow": {
|
||||
label: "Matrix Bot Loop Events per Window",
|
||||
help: "Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20.",
|
||||
},
|
||||
"botLoopProtection.windowSeconds": {
|
||||
label: "Matrix Bot Loop Window Seconds",
|
||||
help: "Sliding window length for counting bot-pair messages. Default: 60.",
|
||||
},
|
||||
"botLoopProtection.cooldownSeconds": {
|
||||
label: "Matrix Bot Loop Cooldown Seconds",
|
||||
help: "How long to suppress the bot pair after it exceeds the budget. Default: 60.",
|
||||
},
|
||||
dangerouslyAllowNameMatching: {
|
||||
label: "Matrix Display Name Matching",
|
||||
help: "Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable.",
|
||||
},
|
||||
"streaming.progress.label": {
|
||||
label: "Matrix Progress Label",
|
||||
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
|
||||
},
|
||||
"streaming.progress.labels": {
|
||||
label: "Matrix Progress Label Pool",
|
||||
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
|
||||
},
|
||||
"streaming.progress.maxLines": {
|
||||
label: "Matrix Progress Max Lines",
|
||||
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
|
||||
},
|
||||
"streaming.progress.maxLineChars": {
|
||||
label: "Matrix Progress Max Line Chars",
|
||||
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
|
||||
},
|
||||
"streaming.progress.toolProgress": {
|
||||
label: "Matrix Progress Tool Lines",
|
||||
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
|
||||
},
|
||||
"streaming.progress.commandText": {
|
||||
label: "Matrix Progress Command Text",
|
||||
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
198
extensions/matrix/src/directory-live.test.ts
Normal file
198
extensions/matrix/src/directory-live.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Matrix tests cover directory live plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { matrixAuthedHttpClientCtorMock, requestJsonMock } = vi.hoisted(() => ({
|
||||
matrixAuthedHttpClientCtorMock: vi.fn(),
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/sdk/http-client.js", () => ({
|
||||
MatrixAuthedHttpClient: class {
|
||||
constructor(params: unknown) {
|
||||
matrixAuthedHttpClientCtorMock(params);
|
||||
}
|
||||
|
||||
requestJson(params: unknown) {
|
||||
return requestJsonMock(params);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
let listMatrixDirectoryGroupsLive: typeof import("./directory-live.js").listMatrixDirectoryGroupsLive;
|
||||
let listMatrixDirectoryPeersLive: typeof import("./directory-live.js").listMatrixDirectoryPeersLive;
|
||||
let resolveMatrixAuth: typeof import("./matrix/client.js").resolveMatrixAuth;
|
||||
|
||||
describe("matrix directory live", () => {
|
||||
const cfg = { channels: { matrix: {} } };
|
||||
|
||||
beforeAll(async () => {
|
||||
({ listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } =
|
||||
await import("./directory-live.js"));
|
||||
({ resolveMatrixAuth } = await import("./matrix/client.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveMatrixAuth).mockReset();
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
accountId: "assistant",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
matrixAuthedHttpClientCtorMock.mockReset();
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
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: "channel:#room:example.org",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("passes dispatcherPolicy through to the live directory client", async () => {
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
accountId: "assistant",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "test-token",
|
||||
dispatcherPolicy: {
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://proxy.internal:8080",
|
||||
},
|
||||
});
|
||||
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "alice",
|
||||
});
|
||||
|
||||
expect(matrixAuthedHttpClientCtorMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "test-token",
|
||||
ssrfPolicy: undefined,
|
||||
dispatcherPolicy: {
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://proxy.internal:8080",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no peer results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: " ",
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no group results for empty query without resolving auth", async () => {
|
||||
const result = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "",
|
||||
});
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves query casing when searching the Matrix user directory", async () => {
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: "Alice",
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith({
|
||||
method: "POST",
|
||||
endpoint: "/_matrix/client/v3/user_directory/search",
|
||||
timeoutMs: 10_000,
|
||||
body: {
|
||||
search_term: "Alice",
|
||||
limit: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts prefixed fully qualified user ids without hitting Matrix", async () => {
|
||||
const results = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: "matrix:user:@Alice:Example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
id: "@Alice:Example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves prefixed room aliases through the hardened Matrix HTTP client", async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
room_id: "!team:example.org",
|
||||
});
|
||||
|
||||
const results = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "channel:#Team:Example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "!team:example.org",
|
||||
name: "#Team:Example.org",
|
||||
handle: "#Team:Example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
|
||||
timeoutMs: 10_000,
|
||||
body: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts prefixed room ids without additional Matrix lookups", async () => {
|
||||
const results = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "matrix:room:!team:example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "!team:example.org",
|
||||
name: "!team:example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
239
extensions/matrix/src/directory-live.ts
Normal file
239
extensions/matrix/src/directory-live.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
// Matrix plugin module implements directory live behavior.
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
|
||||
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
|
||||
import type { ChannelDirectoryEntry } from "./runtime-api.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>>;
|
||||
|
||||
const MATRIX_DIRECTORY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function resolveMatrixDirectoryLimit(limit?: number | null): number {
|
||||
return typeof limit === "number" && Number.isFinite(limit) && limit > 0
|
||||
? Math.max(1, Math.floor(limit))
|
||||
: 20;
|
||||
}
|
||||
|
||||
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
|
||||
return new MatrixAuthedHttpClient({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
ssrfPolicy: auth.ssrfPolicy,
|
||||
dispatcherPolicy: auth.dispatcherPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
|
||||
auth: MatrixResolvedAuth;
|
||||
client: MatrixAuthedHttpClient;
|
||||
query: string;
|
||||
queryLower: string;
|
||||
} | null> {
|
||||
const query = normalizeOptionalString(params.query) ?? "";
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
|
||||
return {
|
||||
auth,
|
||||
client: createMatrixDirectoryClient(auth),
|
||||
query,
|
||||
queryLower: normalizeLowercaseStringOrEmpty(query),
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupDirectoryEntry(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
handle?: string;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
handle: params.handle,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
}
|
||||
|
||||
async function requestMatrixJson<T>(
|
||||
client: MatrixAuthedHttpClient,
|
||||
params: {
|
||||
method: "GET" | "POST";
|
||||
endpoint: string;
|
||||
body?: unknown;
|
||||
},
|
||||
): Promise<T> {
|
||||
return (await client.requestJson({
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
body: params.body,
|
||||
timeoutMs: MATRIX_DIRECTORY_TIMEOUT_MS,
|
||||
})) as T;
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const query = normalizeOptionalString(params.query) ?? "";
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const directUserId = normalizeMatrixMessagingTarget(query);
|
||||
if (directUserId && isMatrixQualifiedUserId(directUserId)) {
|
||||
return [{ kind: "user", id: directUserId }];
|
||||
}
|
||||
const context = await resolveMatrixDirectoryContext({
|
||||
...params,
|
||||
query,
|
||||
});
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res = await requestMatrixJson<MatrixUserDirectoryResponse>(context.client, {
|
||||
method: "POST",
|
||||
endpoint: "/_matrix/client/v3/user_directory/search",
|
||||
body: {
|
||||
search_term: context.query,
|
||||
limit: resolveMatrixDirectoryLimit(params.limit),
|
||||
},
|
||||
});
|
||||
const results = res.results ?? [];
|
||||
return results
|
||||
.map((entry) => {
|
||||
const userId = normalizeOptionalString(entry.user_id);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const displayName = normalizeOptionalString(entry.display_name);
|
||||
return {
|
||||
kind: "user",
|
||||
id: userId,
|
||||
name: displayName,
|
||||
handle: displayName ? `@${displayName}` : undefined,
|
||||
raw: entry,
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
})
|
||||
.filter(Boolean) as ChannelDirectoryEntry[];
|
||||
}
|
||||
|
||||
async function resolveMatrixRoomAlias(
|
||||
client: MatrixAuthedHttpClient,
|
||||
alias: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await requestMatrixJson<MatrixAliasLookup>(client, {
|
||||
method: "GET",
|
||||
endpoint: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
});
|
||||
return normalizeOptionalString(res.room_id) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMatrixRoomName(
|
||||
client: MatrixAuthedHttpClient,
|
||||
roomId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await requestMatrixJson<MatrixRoomNameState>(client, {
|
||||
method: "GET",
|
||||
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
});
|
||||
return normalizeOptionalString(res.name) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryGroupsLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const query = normalizeOptionalString(params.query) ?? "";
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const directTarget = normalizeMatrixMessagingTarget(query);
|
||||
|
||||
if (directTarget?.startsWith("!")) {
|
||||
return [createGroupDirectoryEntry({ id: directTarget, name: directTarget })];
|
||||
}
|
||||
|
||||
const context = await resolveMatrixDirectoryContext({
|
||||
...params,
|
||||
query,
|
||||
});
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { client, queryLower } = context;
|
||||
const limit = resolveMatrixDirectoryLimit(params.limit);
|
||||
|
||||
if (directTarget?.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(client, directTarget);
|
||||
if (!roomId) {
|
||||
return [];
|
||||
}
|
||||
return [createGroupDirectoryEntry({ id: roomId, name: directTarget, handle: directTarget })];
|
||||
}
|
||||
|
||||
const joined = await requestMatrixJson<MatrixJoinedRoomsResponse>(client, {
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/joined_rooms",
|
||||
});
|
||||
const rooms = (joined.joined_rooms ?? [])
|
||||
.map((roomId) => normalizeOptionalString(roomId))
|
||||
.filter((roomId): roomId is string => Boolean(roomId));
|
||||
const results: ChannelDirectoryEntry[] = [];
|
||||
|
||||
for (const roomId of rooms) {
|
||||
const name = await fetchMatrixRoomName(client, roomId);
|
||||
if (!name || !normalizeLowercaseStringOrEmpty(name).includes(queryLower)) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
name,
|
||||
handle: `#${name}`,
|
||||
});
|
||||
if (results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
288
extensions/matrix/src/doctor-contract.ts
Normal file
288
extensions/matrix/src/doctor-contract.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Matrix plugin module implements doctor contract behavior.
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
hasLegacyFlatAllowPrivateNetworkAlias,
|
||||
migrateLegacyFlatAllowPrivateNetworkAlias,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { isRecord } from "./record-shared.js";
|
||||
|
||||
function hasLegacyMatrixRoomAllowAlias(value: unknown): boolean {
|
||||
const room = isRecord(value) ? value : null;
|
||||
return Boolean(room && typeof room.allow === "boolean");
|
||||
}
|
||||
|
||||
function hasLegacyMatrixRoomMapAllowAliases(value: unknown): boolean {
|
||||
const rooms = isRecord(value) ? value : null;
|
||||
return Boolean(rooms && Object.values(rooms).some((room) => hasLegacyMatrixRoomAllowAlias(room)));
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountRoomAllowAliases(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => {
|
||||
if (!isRecord(account)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
hasLegacyMatrixRoomMapAllowAliases(account.groups) ||
|
||||
hasLegacyMatrixRoomMapAllowAliases(account.rooms)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountPrivateNetworkAliases(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) =>
|
||||
hasLegacyFlatAllowPrivateNetworkAlias(isRecord(account) ? account : {}),
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyTrustedDmPolicy(value: unknown): boolean {
|
||||
const root = isRecord(value) ? value : null;
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const dm = isRecord(root.dm) ? root.dm : null;
|
||||
return dm?.policy === "trusted";
|
||||
}
|
||||
|
||||
function hasLegacyMatrixAccountTrustedDmPolicies(value: unknown): boolean {
|
||||
const accounts = isRecord(value) ? value : null;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => hasLegacyTrustedDmPolicy(account));
|
||||
}
|
||||
|
||||
function migrateLegacyTrustedDmPolicy(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
const dm = isRecord(params.entry.dm) ? params.entry.dm : null;
|
||||
if (!dm || dm.policy !== "trusted") {
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
const allowFromRaw = dm.allowFrom;
|
||||
// Trim before counting: downstream allowlist normalization drops whitespace-only
|
||||
// entries, so a config like [" "] must still fall back to "pairing"
|
||||
// instead of becoming an effectively empty allowlist.
|
||||
const allowFromEntries = Array.isArray(allowFromRaw)
|
||||
? allowFromRaw.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
).length
|
||||
: 0;
|
||||
// Preserve the operator's existing trust boundary when an explicit allowFrom
|
||||
// list is present; only fall back to pairing when the effective allowlist is
|
||||
// empty.
|
||||
const nextPolicy: "allowlist" | "pairing" = allowFromEntries > 0 ? "allowlist" : "pairing";
|
||||
const nextDm = { ...dm, policy: nextPolicy };
|
||||
params.changes.push(
|
||||
`Migrated ${params.pathPrefix}.dm.policy "trusted" → "${nextPolicy}" (legacy alias removed; ` +
|
||||
`${allowFromEntries > 0 ? `preserved ${allowFromEntries} ${params.pathPrefix}.dm.allowFrom ${allowFromEntries === 1 ? "entry" : "entries"}` : "no allowFrom entries present, defaulting to pairing for safety"}).`,
|
||||
);
|
||||
return { entry: { ...params.entry, dm: nextDm }, changed: true };
|
||||
}
|
||||
|
||||
function normalizeMatrixRoomAllowAliases(params: {
|
||||
rooms: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { rooms: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextRooms: Record<string, unknown> = { ...params.rooms };
|
||||
for (const [roomId, roomValue] of Object.entries(params.rooms)) {
|
||||
const room = isRecord(roomValue) ? roomValue : null;
|
||||
if (!room || typeof room.allow !== "boolean") {
|
||||
continue;
|
||||
}
|
||||
const nextRoom = { ...room };
|
||||
if (typeof nextRoom.enabled !== "boolean") {
|
||||
nextRoom.enabled = room.allow;
|
||||
}
|
||||
delete nextRoom.allow;
|
||||
nextRooms[roomId] = nextRoom;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.${roomId}.allow → ${params.pathPrefix}.${roomId}.enabled (${String(nextRoom.enabled)}).`,
|
||||
);
|
||||
}
|
||||
return { rooms: nextRooms, changed };
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "matrix"],
|
||||
message:
|
||||
'channels.matrix.allowPrivateNetwork is legacy; use channels.matrix.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyFlatAllowPrivateNetworkAlias(isRecord(value) ? value : {}),
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
'channels.matrix.accounts.<id>.allowPrivateNetwork is legacy; use channels.matrix.accounts.<id>.network.dangerouslyAllowPrivateNetwork instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyMatrixAccountPrivateNetworkAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "groups"],
|
||||
message:
|
||||
'channels.matrix.groups.<room>.allow is legacy; use channels.matrix.groups.<room>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyMatrixRoomMapAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "rooms"],
|
||||
message:
|
||||
'channels.matrix.rooms.<room>.allow is legacy; use channels.matrix.rooms.<room>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyMatrixRoomMapAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
'channels.matrix.accounts.<id>.{groups,rooms}.<room>.allow is legacy; use channels.matrix.accounts.<id>.{groups,rooms}.<room>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyMatrixAccountRoomAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix"],
|
||||
message:
|
||||
'channels.matrix.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyTrustedDmPolicy,
|
||||
},
|
||||
{
|
||||
path: ["channels", "matrix", "accounts"],
|
||||
message:
|
||||
'channels.matrix.accounts.<id>.dm.policy "trusted" is legacy; use "allowlist" (with allowFrom entries) or "pairing" instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyMatrixAccountTrustedDmPolicies,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({
|
||||
cfg,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
const channels = isRecord(cfg.channels) ? cfg.channels : null;
|
||||
const matrix = isRecord(channels?.matrix) ? channels.matrix : null;
|
||||
if (!matrix) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedMatrix: Record<string, unknown> = matrix;
|
||||
let changed = false;
|
||||
|
||||
const topLevelPrivateNetwork = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: updatedMatrix,
|
||||
pathPrefix: "channels.matrix",
|
||||
changes,
|
||||
});
|
||||
updatedMatrix = topLevelPrivateNetwork.entry;
|
||||
changed = changed || topLevelPrivateNetwork.changed;
|
||||
|
||||
const topLevelTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
|
||||
entry: updatedMatrix,
|
||||
pathPrefix: "channels.matrix",
|
||||
changes,
|
||||
});
|
||||
updatedMatrix = topLevelTrustedDmPolicy.entry;
|
||||
changed = changed || topLevelTrustedDmPolicy.changed;
|
||||
|
||||
const normalizeTopLevelRoomScope = (key: "groups" | "rooms") => {
|
||||
const rooms = isRecord(updatedMatrix[key]) ? updatedMatrix[key] : null;
|
||||
if (!rooms) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeMatrixRoomAllowAliases({
|
||||
rooms,
|
||||
pathPrefix: `channels.matrix.${key}`,
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
updatedMatrix = { ...updatedMatrix, [key]: normalized.rooms };
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
|
||||
normalizeTopLevelRoomScope("groups");
|
||||
normalizeTopLevelRoomScope("rooms");
|
||||
|
||||
const accounts = isRecord(updatedMatrix.accounts) ? updatedMatrix.accounts : null;
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts: Record<string, unknown> = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = isRecord(accountValue) ? accountValue : null;
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
let nextAccount: Record<string, unknown> = account;
|
||||
let accountChanged = false;
|
||||
|
||||
const privateNetworkMigration = migrateLegacyFlatAllowPrivateNetworkAlias({
|
||||
entry: nextAccount,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (privateNetworkMigration.changed) {
|
||||
nextAccount = privateNetworkMigration.entry;
|
||||
accountChanged = true;
|
||||
}
|
||||
|
||||
const accountTrustedDmPolicy = migrateLegacyTrustedDmPolicy({
|
||||
entry: nextAccount,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
if (accountTrustedDmPolicy.changed) {
|
||||
nextAccount = accountTrustedDmPolicy.entry;
|
||||
accountChanged = true;
|
||||
}
|
||||
|
||||
for (const key of ["groups", "rooms"] as const) {
|
||||
const rooms = isRecord(nextAccount[key]) ? nextAccount[key] : null;
|
||||
if (!rooms) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeMatrixRoomAllowAliases({
|
||||
rooms,
|
||||
pathPrefix: `channels.matrix.accounts.${accountId}.${key}`,
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
nextAccount = { ...nextAccount, [key]: normalized.rooms };
|
||||
accountChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountChanged) {
|
||||
nextAccounts[accountId] = nextAccount;
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedMatrix = { ...updatedMatrix, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: updatedMatrix as NonNullable<OpenClawConfig["channels"]>["matrix"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
406
extensions/matrix/src/doctor.test.ts
Normal file
406
extensions/matrix/src/doctor.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
// Matrix tests cover doctor plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyMatrixDoctorRepair,
|
||||
cleanStaleMatrixPluginConfig,
|
||||
collectMatrixInstallPathWarnings,
|
||||
formatMatrixLegacyCryptoPreview,
|
||||
formatMatrixLegacyStatePreview,
|
||||
matrixDoctor,
|
||||
runMatrixDoctorSequence,
|
||||
} from "./doctor.js";
|
||||
|
||||
vi.mock("./matrix-migration.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix-migration.runtime.js")>(
|
||||
"./matrix-migration.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
maybeCreateMatrixMigrationSnapshot: vi.fn(),
|
||||
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
|
||||
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
|
||||
resolveMatrixMigrationStatus: vi.fn(() => ({
|
||||
legacyState: null,
|
||||
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
|
||||
pending: false,
|
||||
actionable: false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("matrix doctor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function runMatrixCompatibilityNormalize(
|
||||
params: Parameters<NonNullable<typeof matrixDoctor.normalizeCompatibilityConfig>>[0],
|
||||
) {
|
||||
const normalize = matrixDoctor.normalizeCompatibilityConfig;
|
||||
if (!normalize) {
|
||||
throw new Error("expected Matrix doctor compatibility normalizer");
|
||||
}
|
||||
return normalize(params);
|
||||
}
|
||||
|
||||
function normalizeMatrixDmConfig(dm: Record<string, unknown>) {
|
||||
return runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
function expectChangeContaining(changes: readonly string[], fragment: string): void {
|
||||
expect(changes.join("\n")).toContain(fragment);
|
||||
}
|
||||
|
||||
it("formats state and crypto previews", () => {
|
||||
expect(
|
||||
formatMatrixLegacyStatePreview({
|
||||
accountId: "default",
|
||||
legacyStoragePath: "/tmp/legacy-sync.json",
|
||||
targetStoragePath: "/tmp/new-sync.json",
|
||||
legacyCryptoPath: "/tmp/legacy-crypto.json",
|
||||
targetCryptoPath: "/tmp/new-crypto.json",
|
||||
selectionNote: "Picked the newest account.",
|
||||
targetRootDir: "/tmp/account-root",
|
||||
}),
|
||||
).toContain("Matrix plugin upgraded in place.");
|
||||
|
||||
const previews = formatMatrixLegacyCryptoPreview({
|
||||
inspectorAvailable: true,
|
||||
warnings: ["matrix warning"],
|
||||
plans: [
|
||||
{
|
||||
accountId: "default",
|
||||
rootDir: "/tmp/account-root",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
legacyCryptoPath: "/tmp/legacy-crypto.json",
|
||||
recoveryKeyPath: "/tmp/recovery-key.txt",
|
||||
statePath: "/tmp/state.json",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(previews[0]).toBe("- matrix warning");
|
||||
expect(previews[1]).toContain("/tmp/recovery-key.txt");
|
||||
expect(previews[1]).toContain("Recovery key state: Matrix SQLite state");
|
||||
expect(previews[1]).toContain("Migration state: Matrix SQLite state");
|
||||
});
|
||||
|
||||
it("warns on stale custom Matrix plugin paths and cleans them", async () => {
|
||||
const missingPath = path.join(tmpdir(), `openclaw-matrix-missing-${Date.now()}`);
|
||||
await fs.rm(missingPath, { recursive: true, force: true });
|
||||
|
||||
const warnings = await collectMatrixInstallPathWarnings({
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(warnings[0]).toContain("custom path that no longer exists");
|
||||
|
||||
const cleaned = await cleanStaleMatrixPluginConfig({
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: { source: "path", sourcePath: missingPath, installPath: missingPath },
|
||||
},
|
||||
load: { paths: [missingPath, "/other/path"] },
|
||||
allow: ["matrix", "other-plugin"],
|
||||
},
|
||||
});
|
||||
expect(cleaned.changes[0]).toContain("Removed stale Matrix plugin references");
|
||||
expect(cleaned.config.plugins?.load?.paths).toEqual(["/other/path"]);
|
||||
expect(cleaned.config.plugins?.allow).toEqual(["other-plugin"]);
|
||||
});
|
||||
|
||||
it("surfaces matrix sequence warnings and repair changes", async () => {
|
||||
const runtimeApi = await import("./matrix-migration.runtime.js");
|
||||
vi.mocked(runtimeApi.resolveMatrixMigrationStatus).mockReturnValue({
|
||||
legacyState: null,
|
||||
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
|
||||
pending: true,
|
||||
actionable: true,
|
||||
});
|
||||
vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
|
||||
archivePath: "/tmp/matrix-backup.tgz",
|
||||
created: true,
|
||||
markerPath: "/tmp/marker.json",
|
||||
});
|
||||
vi.mocked(runtimeApi.autoMigrateLegacyMatrixState).mockResolvedValue({
|
||||
migrated: true,
|
||||
changes: ["Migrated legacy sync state"],
|
||||
warnings: [],
|
||||
});
|
||||
vi.mocked(runtimeApi.autoPrepareLegacyMatrixCrypto).mockResolvedValue({
|
||||
migrated: true,
|
||||
changes: ["Prepared recovery key export"],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as never;
|
||||
|
||||
const repair = await applyMatrixDoctorRepair({ cfg, env: process.env });
|
||||
expect(repair.changes.join("\n")).toContain("Matrix migration snapshot");
|
||||
|
||||
const sequence = await runMatrixDoctorSequence({
|
||||
cfg,
|
||||
env: process.env,
|
||||
shouldRepair: true,
|
||||
});
|
||||
expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot");
|
||||
});
|
||||
|
||||
it("normalizes legacy Matrix room allow aliases to enabled", () => {
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!ops:example.org": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
rooms: {
|
||||
"!legacy:example.org": {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const matrixConfig = result.config.channels?.matrix as
|
||||
| {
|
||||
groups?: Record<string, unknown>;
|
||||
accounts?: Record<string, unknown>;
|
||||
network?: { dangerouslyAllowPrivateNetwork?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
const workAccount = matrixConfig?.accounts?.work as
|
||||
| {
|
||||
rooms?: Record<string, unknown>;
|
||||
network?: { dangerouslyAllowPrivateNetwork?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(matrixConfig?.groups?.["!ops:example.org"]).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(workAccount?.rooms?.["!legacy:example.org"]).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.changes).toContain(
|
||||
"Moved channels.matrix.groups.!ops:example.org.allow → channels.matrix.groups.!ops:example.org.enabled (true).",
|
||||
);
|
||||
expect(result.changes).toContain(
|
||||
"Moved channels.matrix.accounts.work.rooms.!legacy:example.org.allow → channels.matrix.accounts.work.rooms.!legacy:example.org.enabled (false).",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes legacy Matrix private-network aliases", () => {
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const matrixConfig = result.config.channels?.matrix as
|
||||
| {
|
||||
accounts?: Record<string, unknown>;
|
||||
network?: { dangerouslyAllowPrivateNetwork?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
const workAccount = matrixConfig?.accounts?.work as
|
||||
| {
|
||||
network?: { dangerouslyAllowPrivateNetwork?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(matrixConfig?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(workAccount?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
expect(result.changes).toContain(
|
||||
"Moved channels.matrix.allowPrivateNetwork → channels.matrix.network.dangerouslyAllowPrivateNetwork (true).",
|
||||
);
|
||||
expect(result.changes).toContain(
|
||||
"Moved channels.matrix.accounts.work.allowPrivateNetwork → channels.matrix.accounts.work.network.dangerouslyAllowPrivateNetwork (false).",
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy channels.matrix.dm.policy 'trusted' with allowFrom to 'allowlist'", () => {
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "trusted",
|
||||
allowFrom: ["@alice:example.org", "@bob:example.org"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const matrixDm = (
|
||||
result.config.channels?.matrix as { dm?: { policy?: string; allowFrom?: string[] } }
|
||||
)?.dm;
|
||||
|
||||
expect(matrixDm?.policy).toBe("allowlist");
|
||||
expect(matrixDm?.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]);
|
||||
expectChangeContaining(
|
||||
result.changes,
|
||||
'Migrated channels.matrix.dm.policy "trusted" → "allowlist"',
|
||||
);
|
||||
expectChangeContaining(result.changes, "preserved 2 channels.matrix.dm.allowFrom entries");
|
||||
});
|
||||
|
||||
it("migrates legacy 'trusted' policy with whitespace-only allowFrom entries to 'pairing'", () => {
|
||||
// Whitespace-only entries are dropped by downstream allowlist normalization,
|
||||
// so they must not count toward the allowFrom population check — otherwise
|
||||
// the migration would emit policy="allowlist" with an effectively empty
|
||||
// allowlist, silently blocking all DMs.
|
||||
const result = normalizeMatrixDmConfig({
|
||||
enabled: true,
|
||||
policy: "trusted",
|
||||
allowFrom: [" ", "\t", ""],
|
||||
});
|
||||
|
||||
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
|
||||
expect(matrixDm?.policy).toBe("pairing");
|
||||
expectChangeContaining(
|
||||
result.changes,
|
||||
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy channels.matrix.dm.policy 'trusted' without allowFrom to 'pairing'", () => {
|
||||
const result = normalizeMatrixDmConfig({
|
||||
enabled: true,
|
||||
policy: "trusted",
|
||||
});
|
||||
|
||||
const matrixDm = (result.config.channels?.matrix as { dm?: { policy?: string } })?.dm;
|
||||
expect(matrixDm?.policy).toBe("pairing");
|
||||
expectChangeContaining(
|
||||
result.changes,
|
||||
'Migrated channels.matrix.dm.policy "trusted" → "pairing"',
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates legacy per-account channels.matrix.accounts.<id>.dm.policy 'trusted'", () => {
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
work: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "trusted",
|
||||
allowFrom: ["@boss:example.org"],
|
||||
},
|
||||
},
|
||||
personal: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "trusted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const accounts = (
|
||||
result.config.channels?.matrix as {
|
||||
accounts?: Record<string, { dm?: { policy?: string; allowFrom?: string[] } }>;
|
||||
}
|
||||
)?.accounts;
|
||||
|
||||
expect(accounts?.work?.dm?.policy).toBe("allowlist");
|
||||
expect(accounts?.work?.dm?.allowFrom).toEqual(["@boss:example.org"]);
|
||||
expect(accounts?.personal?.dm?.policy).toBe("pairing");
|
||||
expectChangeContaining(
|
||||
result.changes,
|
||||
'Migrated channels.matrix.accounts.work.dm.policy "trusted" → "allowlist"',
|
||||
);
|
||||
expectChangeContaining(
|
||||
result.changes,
|
||||
'Migrated channels.matrix.accounts.personal.dm.policy "trusted" → "pairing"',
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves modern dm.policy values untouched", () => {
|
||||
const result = runMatrixCompatibilityNormalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@alice:example.org"],
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
dm: { enabled: true, policy: "pairing" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.changes).toStrictEqual([]);
|
||||
expect(result.config).toEqual({
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@alice:example.org"],
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
dm: { enabled: true, policy: "pairing" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
263
extensions/matrix/src/doctor.ts
Normal file
263
extensions/matrix/src/doctor.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Matrix plugin module implements doctor behavior.
|
||||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
removePluginFromConfig,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import {
|
||||
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
import {
|
||||
autoMigrateLegacyMatrixState,
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
detectLegacyMatrixCrypto,
|
||||
detectLegacyMatrixState,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveMatrixMigrationStatus,
|
||||
} from "./matrix-migration.runtime.js";
|
||||
import { isRecord } from "./record-shared.js";
|
||||
|
||||
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
return isRecord(channels?.matrix);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
|
||||
return Boolean(
|
||||
cfg.plugins?.installs?.matrix ||
|
||||
cfg.plugins?.entries?.matrix ||
|
||||
cfg.plugins?.allow?.includes("matrix") ||
|
||||
cfg.plugins?.deny?.includes("matrix"),
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
|
||||
return Object.entries(env).some(
|
||||
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
hasConfiguredMatrixChannel(cfg) ||
|
||||
hasConfiguredMatrixPluginSurface(cfg) ||
|
||||
hasConfiguredMatrixEnv(env)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMatrixLegacyStatePreview(
|
||||
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
|
||||
): string {
|
||||
return [
|
||||
"- Matrix plugin upgraded in place.",
|
||||
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
|
||||
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
|
||||
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
|
||||
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function formatMatrixLegacyCryptoPreview(
|
||||
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
|
||||
): string[] {
|
||||
const notes: string[] = [];
|
||||
for (const warning of detection.warnings) {
|
||||
notes.push(`- ${warning}`);
|
||||
}
|
||||
for (const plan of detection.plans) {
|
||||
notes.push(
|
||||
[
|
||||
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
|
||||
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
|
||||
`- Recovery key state: Matrix SQLite state (imports ${plan.recoveryKeyPath} if present)`,
|
||||
`- Migration state: Matrix SQLite state (imports ${plan.statePath} if present)`,
|
||||
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: cfg.plugins?.installs?.matrix,
|
||||
});
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
return formatPluginInstallPathIssue({
|
||||
issue,
|
||||
pluginLabel: "Matrix",
|
||||
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||
}).map((entry) => `- ${entry}`);
|
||||
}
|
||||
|
||||
export async function cleanStaleMatrixPluginConfig(cfg: OpenClawConfig) {
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: cfg.plugins?.installs?.matrix,
|
||||
});
|
||||
if (!issue || issue.kind !== "missing-path") {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
const { config, actions } = removePluginFromConfig(cfg, "matrix");
|
||||
const removed: string[] = [];
|
||||
if (actions.install) {
|
||||
removed.push("install record");
|
||||
}
|
||||
if (actions.loadPath) {
|
||||
removed.push("load path");
|
||||
}
|
||||
if (actions.entry) {
|
||||
removed.push("plugin entry");
|
||||
}
|
||||
if (actions.allowlist) {
|
||||
removed.push("allowlist entry");
|
||||
}
|
||||
if (removed.length === 0) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config,
|
||||
changes: [
|
||||
`Removed stale Matrix plugin references (${removed.join(", ")}). The previous install path no longer exists: ${issue.path}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyMatrixDoctorRepair(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const migrationStatus = resolveMatrixMigrationStatus({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
|
||||
let matrixSnapshotReady = true;
|
||||
if (migrationStatus.actionable) {
|
||||
try {
|
||||
const snapshot = await maybeCreateMatrixMigrationSnapshot({
|
||||
trigger: "doctor-fix",
|
||||
env: params.env,
|
||||
});
|
||||
changes.push(
|
||||
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
|
||||
);
|
||||
} catch (error) {
|
||||
matrixSnapshotReady = false;
|
||||
warnings.push(
|
||||
`- Failed creating a Matrix migration snapshot before repair: ${String(error)}`,
|
||||
);
|
||||
warnings.push(
|
||||
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
|
||||
);
|
||||
}
|
||||
} else if (migrationStatus.pending) {
|
||||
warnings.push(
|
||||
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!matrixSnapshotReady) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const matrixStateRepair = await autoMigrateLegacyMatrixState({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
if (matrixStateRepair.changes.length > 0) {
|
||||
changes.push(
|
||||
[
|
||||
"Matrix plugin upgraded in place.",
|
||||
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
|
||||
"- No user action required.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (matrixStateRepair.warnings.length > 0) {
|
||||
warnings.push(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
|
||||
}
|
||||
|
||||
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
if (matrixCryptoRepair.changes.length > 0) {
|
||||
changes.push(
|
||||
[
|
||||
"Matrix encrypted-state migration prepared.",
|
||||
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
if (matrixCryptoRepair.warnings.length > 0) {
|
||||
warnings.push(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
export async function runMatrixDoctorSequence(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
shouldRepair: boolean;
|
||||
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
|
||||
const warningNotes: string[] = [];
|
||||
const changeNotes: string[] = [];
|
||||
const installWarnings = await collectMatrixInstallPathWarnings(params.cfg);
|
||||
if (installWarnings.length > 0) {
|
||||
warningNotes.push(installWarnings.join("\n"));
|
||||
}
|
||||
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
|
||||
return { changeNotes, warningNotes };
|
||||
}
|
||||
|
||||
if (params.shouldRepair) {
|
||||
const repair = await applyMatrixDoctorRepair({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
changeNotes.push(...repair.changes);
|
||||
warningNotes.push(...repair.warnings);
|
||||
} else {
|
||||
const migrationStatus = resolveMatrixMigrationStatus({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
});
|
||||
if (migrationStatus.legacyState) {
|
||||
if ("warning" in migrationStatus.legacyState) {
|
||||
warningNotes.push(`- ${migrationStatus.legacyState.warning}`);
|
||||
} else {
|
||||
warningNotes.push(formatMatrixLegacyStatePreview(migrationStatus.legacyState));
|
||||
}
|
||||
}
|
||||
if (
|
||||
migrationStatus.legacyCrypto.warnings.length > 0 ||
|
||||
migrationStatus.legacyCrypto.plans.length > 0
|
||||
) {
|
||||
warningNotes.push(...formatMatrixLegacyCryptoPreview(migrationStatus.legacyCrypto));
|
||||
}
|
||||
}
|
||||
|
||||
return { changeNotes, warningNotes };
|
||||
}
|
||||
|
||||
export const matrixDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "sender",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
|
||||
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
|
||||
await runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
cleanStaleConfig: async ({ cfg }) => await cleanStaleMatrixPluginConfig(cfg),
|
||||
};
|
||||
93
extensions/matrix/src/env-vars.ts
Normal file
93
extensions/matrix/src/env-vars.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Matrix plugin module implements env vars behavior.
|
||||
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
const MATRIX_SCOPED_ENV_SUFFIXES = [
|
||||
"HOMESERVER",
|
||||
"USER_ID",
|
||||
"ACCESS_TOKEN",
|
||||
"PASSWORD",
|
||||
"DEVICE_ID",
|
||||
"DEVICE_NAME",
|
||||
] as const;
|
||||
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
|
||||
|
||||
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
|
||||
|
||||
export function resolveMatrixEnvAccountToken(accountId: string): string {
|
||||
return Array.from(normalizeAccountId(accountId))
|
||||
.map((char) =>
|
||||
/[a-z0-9]/.test(char)
|
||||
? char.toUpperCase()
|
||||
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function getMatrixScopedEnvVarNames(accountId: string): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
} {
|
||||
const token = resolveMatrixEnvAccountToken(accountId);
|
||||
return {
|
||||
homeserver: `MATRIX_${token}_HOMESERVER`,
|
||||
userId: `MATRIX_${token}_USER_ID`,
|
||||
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
|
||||
password: `MATRIX_${token}_PASSWORD`,
|
||||
deviceId: `MATRIX_${token}_DEVICE_ID`,
|
||||
deviceName: `MATRIX_${token}_DEVICE_NAME`,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeMatrixEnvAccountToken(token: string): string | undefined {
|
||||
let decoded = "";
|
||||
for (let index = 0; index < token.length; ) {
|
||||
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
|
||||
if (hexEscape) {
|
||||
const hex = hexEscape[1];
|
||||
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
return undefined;
|
||||
}
|
||||
const char = String.fromCodePoint(codePoint);
|
||||
decoded += char;
|
||||
index += hexEscape[0].length;
|
||||
continue;
|
||||
}
|
||||
const char = token[index];
|
||||
if (!char || !/[A-Z0-9]/.test(char)) {
|
||||
return undefined;
|
||||
}
|
||||
decoded += char.toLowerCase();
|
||||
index += 1;
|
||||
}
|
||||
const normalized = normalizeOptionalAccountId(decoded);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
|
||||
if (typeof env[key] === "string" && env[key]?.trim()) {
|
||||
ids.add(normalizeAccountId("default"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(env)) {
|
||||
const match = MATRIX_SCOPED_ENV_RE.exec(key);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const accountId = decodeMatrixEnvAccountToken(match[1]);
|
||||
if (accountId) {
|
||||
ids.add(accountId);
|
||||
}
|
||||
}
|
||||
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
69
extensions/matrix/src/exec-approval-resolver.test.ts
Normal file
69
extensions/matrix/src/exec-approval-resolver.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Matrix tests cover exec approval resolver plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const approvalRuntimeHoisted = vi.hoisted(() => ({
|
||||
resolveApprovalOverGatewaySpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
|
||||
resolveApprovalOverGateway: (...args: unknown[]) =>
|
||||
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy(...args),
|
||||
}));
|
||||
|
||||
describe("resolveMatrixApproval", () => {
|
||||
beforeEach(() => {
|
||||
approvalRuntimeHoisted.resolveApprovalOverGatewaySpy.mockReset();
|
||||
});
|
||||
|
||||
it("submits exec approval resolutions through the shared gateway resolver", async () => {
|
||||
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
|
||||
|
||||
await resolveMatrixApproval({
|
||||
cfg: {} as never,
|
||||
approvalId: "req-123",
|
||||
decision: "allow-once",
|
||||
senderId: "@owner:example.org",
|
||||
});
|
||||
|
||||
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
approvalId: "req-123",
|
||||
decision: "allow-once",
|
||||
senderId: "@owner:example.org",
|
||||
gatewayUrl: undefined,
|
||||
clientDisplayName: "Matrix approval (@owner:example.org)",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes plugin approval ids through unchanged", async () => {
|
||||
const { resolveMatrixApproval } = await import("./exec-approval-resolver.js");
|
||||
|
||||
await resolveMatrixApproval({
|
||||
cfg: {} as never,
|
||||
approvalId: "plugin:req-123",
|
||||
decision: "deny",
|
||||
senderId: "@owner:example.org",
|
||||
});
|
||||
|
||||
expect(approvalRuntimeHoisted.resolveApprovalOverGatewaySpy).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
approvalId: "plugin:req-123",
|
||||
decision: "deny",
|
||||
senderId: "@owner:example.org",
|
||||
gatewayUrl: undefined,
|
||||
clientDisplayName: "Matrix approval (@owner:example.org)",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognizes structured approval-not-found errors", async () => {
|
||||
const { isApprovalNotFoundError } = await import("./exec-approval-resolver.js");
|
||||
const err = new Error("approval not found");
|
||||
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).gatewayCode =
|
||||
"INVALID_REQUEST";
|
||||
(err as Error & { gatewayCode?: string; details?: { reason?: string } }).details = {
|
||||
reason: "APPROVAL_NOT_FOUND",
|
||||
};
|
||||
|
||||
expect(isApprovalNotFoundError(err)).toBe(true);
|
||||
});
|
||||
});
|
||||
24
extensions/matrix/src/exec-approval-resolver.ts
Normal file
24
extensions/matrix/src/exec-approval-resolver.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Matrix plugin module implements exec approval resolver behavior.
|
||||
import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
export { isApprovalNotFoundError };
|
||||
|
||||
export async function resolveMatrixApproval(params: {
|
||||
cfg: OpenClawConfig;
|
||||
approvalId: string;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
senderId?: string | null;
|
||||
gatewayUrl?: string;
|
||||
}): Promise<void> {
|
||||
await resolveApprovalOverGateway({
|
||||
cfg: params.cfg,
|
||||
approvalId: params.approvalId,
|
||||
decision: params.decision,
|
||||
senderId: params.senderId,
|
||||
gatewayUrl: params.gatewayUrl,
|
||||
clientDisplayName: `Matrix approval (${params.senderId?.trim() || "unknown"})`,
|
||||
});
|
||||
}
|
||||
485
extensions/matrix/src/exec-approvals.test.ts
Normal file
485
extensions/matrix/src/exec-approvals.test.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
// Matrix tests cover exec approvals plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getMatrixExecApprovalApprovers,
|
||||
isMatrixExecApprovalApprover,
|
||||
isMatrixExecApprovalAuthorizedSender,
|
||||
isMatrixExecApprovalClientEnabled,
|
||||
isMatrixExecApprovalTargetRecipient,
|
||||
normalizeMatrixApproverId,
|
||||
resolveMatrixExecApprovalTarget,
|
||||
shouldHandleMatrixExecApprovalRequest,
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt,
|
||||
} from "./exec-approvals.js";
|
||||
import type { MatrixAccountConfig, MatrixExecApprovalConfig } from "./types.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
type MatrixExecApprovalRequest = Parameters<
|
||||
typeof shouldHandleMatrixExecApprovalRequest
|
||||
>[0]["request"];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-exec-approvals-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
execApprovals?: NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>["execApprovals"],
|
||||
channelOverrides?: Partial<NonNullable<NonNullable<OpenClawConfig["channels"]>["matrix"]>>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
...channelOverrides,
|
||||
execApprovals,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function matrixAccount(
|
||||
accountId: string,
|
||||
execApprovals: MatrixExecApprovalConfig,
|
||||
overrides: Partial<MatrixAccountConfig> = {},
|
||||
): MatrixAccountConfig {
|
||||
return {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: `@bot-${accountId}:example.org`,
|
||||
accessToken: `tok-${accountId}`,
|
||||
...overrides,
|
||||
execApprovals,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMultiAccountMatrixConfig(params: {
|
||||
sessionStorePath?: string;
|
||||
defaultExecApprovals?: MatrixExecApprovalConfig;
|
||||
opsExecApprovals?: MatrixExecApprovalConfig;
|
||||
defaultOverrides?: Partial<MatrixAccountConfig>;
|
||||
opsOverrides?: Partial<MatrixAccountConfig>;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
...(params.sessionStorePath ? { session: { store: params.sessionStorePath } } : {}),
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: matrixAccount(
|
||||
"default",
|
||||
params.defaultExecApprovals ?? {
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
},
|
||||
params.defaultOverrides,
|
||||
),
|
||||
ops: matrixAccount(
|
||||
"ops",
|
||||
params.opsExecApprovals ?? {
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
},
|
||||
params.opsOverrides,
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function makeForeignChannelApprovalRequest(params: {
|
||||
id: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
}): MatrixExecApprovalRequest {
|
||||
return {
|
||||
id: params.id,
|
||||
request: {
|
||||
command: "echo hi",
|
||||
agentId: params.agentId ?? "ops-agent",
|
||||
sessionKey: params.sessionKey ?? "agent:ops-agent:missing",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "channel:C123",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix exec approvals", () => {
|
||||
it("requires enablement and approvers before enabling the client", () => {
|
||||
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig() })).toBe(false);
|
||||
expect(
|
||||
isMatrixExecApprovalClientEnabled({
|
||||
cfg: buildConfig(undefined, { dm: { allowFrom: ["@owner:example.org"] } }),
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isMatrixExecApprovalClientEnabled({ cfg: buildConfig({ enabled: true }) })).toBe(false);
|
||||
expect(
|
||||
isMatrixExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true }, { dm: { allowFrom: ["@owner:example.org"] } }),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMatrixExecApprovalClientEnabled({
|
||||
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers explicit approvers when configured", () => {
|
||||
const cfg = buildConfig(
|
||||
{ enabled: true, approvers: ["user:@override:example.org"] },
|
||||
{ dm: { allowFrom: ["@owner:example.org"] } },
|
||||
);
|
||||
|
||||
expect(getMatrixExecApprovalApprovers({ cfg })).toEqual(["@override:example.org"]);
|
||||
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@override:example.org" })).toBe(true);
|
||||
expect(isMatrixExecApprovalApprover({ cfg, senderId: "@owner:example.org" })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores wildcard allowlist entries when inferring exec approvers", () => {
|
||||
const cfg = buildConfig({ enabled: true }, { dm: { allowFrom: ["*"] } });
|
||||
|
||||
expect(getMatrixExecApprovalApprovers({ cfg })).toStrictEqual([]);
|
||||
expect(isMatrixExecApprovalClientEnabled({ cfg })).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults target to dm", () => {
|
||||
expect(
|
||||
resolveMatrixExecApprovalTarget({
|
||||
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
|
||||
}),
|
||||
).toBe("dm");
|
||||
});
|
||||
|
||||
it("matches matrix target recipients from generic approval forwarding targets", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [
|
||||
{ channel: "matrix", to: "user:@target:example.org" },
|
||||
{ channel: "matrix", to: "room:!ops:example.org" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@target:example.org" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isMatrixExecApprovalTargetRecipient({ cfg, senderId: "@other:example.org" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isMatrixExecApprovalAuthorizedSender({ cfg, senderId: "@target:example.org" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses local prompts only when the native client is enabled", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "req-1",
|
||||
approvalSlug: "req-1",
|
||||
agentId: "ops-agent",
|
||||
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg: buildConfig({ enabled: true, approvers: ["@owner:example.org"] }),
|
||||
payload,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg: buildConfig(),
|
||||
payload,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps local prompts when filters exclude the request", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "req-1",
|
||||
approvalSlug: "req-1",
|
||||
agentId: "other-agent",
|
||||
sessionKey: "agent:other-agent:matrix:channel:!ops:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg: buildConfig({
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
agentFilter: ["ops-agent"],
|
||||
}),
|
||||
payload,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses local prompts for generic exec payloads when metadata matches filters", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "req-1",
|
||||
approvalSlug: "req-1",
|
||||
approvalKind: "exec",
|
||||
agentId: "ops-agent",
|
||||
sessionKey: "agent:ops-agent:matrix:channel:!ops:example.org",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg: buildConfig({
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
agentFilter: ["ops-agent"],
|
||||
sessionFilter: ["matrix:channel:"],
|
||||
}),
|
||||
payload,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses local prompts for plugin approval payloads when DM approvers are configured", () => {
|
||||
const payload = {
|
||||
channelData: {
|
||||
execApproval: {
|
||||
approvalId: "plugin:req-1",
|
||||
approvalSlug: "plugin:r",
|
||||
approvalKind: "plugin",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
shouldSuppressLocalMatrixExecApprovalPrompt({
|
||||
cfg: buildConfig(
|
||||
{ enabled: true, approvers: ["@owner:example.org"] },
|
||||
{ dm: { allowFrom: ["@owner:example.org"] } },
|
||||
),
|
||||
payload,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes prefixed approver ids", () => {
|
||||
expect(normalizeMatrixApproverId("matrix:@owner:example.org")).toBe("@owner:example.org");
|
||||
expect(normalizeMatrixApproverId("user:@owner:example.org")).toBe("@owner:example.org");
|
||||
});
|
||||
|
||||
it("applies agent and session filters to request handling", () => {
|
||||
const cfg = buildConfig({
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
agentFilter: ["ops-agent"],
|
||||
sessionFilter: ["matrix:channel:", "ops$"],
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
request: {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
agentId: "ops-agent",
|
||||
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org:ops",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
request: {
|
||||
id: "req-2",
|
||||
request: {
|
||||
command: "echo hi",
|
||||
agentId: "other-agent",
|
||||
sessionKey: "agent:other-agent:matrix:channel:!room:example.org:ops",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 1000,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes non-matrix turn sources to the stored matrix account", async () => {
|
||||
const tmpDir = createTempDir();
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
"agent:ops-agent:matrix:channel:!room:example.org": {
|
||||
sessionId: "main",
|
||||
updatedAt: 1,
|
||||
origin: {
|
||||
provider: "matrix",
|
||||
accountId: "ops",
|
||||
},
|
||||
lastChannel: "slack",
|
||||
lastTo: "channel:C999",
|
||||
lastAccountId: "work",
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
const cfg = buildMultiAccountMatrixConfig({ sessionStorePath: storePath });
|
||||
const request = makeForeignChannelApprovalRequest({
|
||||
id: "req-3",
|
||||
sessionKey: "agent:ops-agent:matrix:channel:!room:example.org",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
request,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unbound foreign-channel approvals in multi-account matrix configs", () => {
|
||||
const cfg = buildMultiAccountMatrixConfig({});
|
||||
const request = makeForeignChannelApprovalRequest({ id: "req-4" });
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows unbound foreign-channel approvals when only one matrix account can handle them", () => {
|
||||
const cfg = buildMultiAccountMatrixConfig({
|
||||
opsExecApprovals: {
|
||||
enabled: false,
|
||||
approvers: ["@owner:example.org"],
|
||||
},
|
||||
});
|
||||
const request = makeForeignChannelApprovalRequest({ id: "req-5" });
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
request,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses request filters when checking foreign-channel matrix ambiguity", () => {
|
||||
const cfg = buildMultiAccountMatrixConfig({
|
||||
defaultExecApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
agentFilter: ["ops-agent"],
|
||||
},
|
||||
opsExecApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["@owner:example.org"],
|
||||
agentFilter: ["other-agent"],
|
||||
},
|
||||
});
|
||||
const request = makeForeignChannelApprovalRequest({ id: "req-6" });
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
request,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores disabled matrix accounts when checking foreign-channel ambiguity", () => {
|
||||
const cfg = buildMultiAccountMatrixConfig({
|
||||
opsOverrides: { enabled: false },
|
||||
});
|
||||
const request = makeForeignChannelApprovalRequest({ id: "req-7" });
|
||||
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
request,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldHandleMatrixExecApprovalRequest({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
request,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
294
extensions/matrix/src/exec-approvals.ts
Normal file
294
extensions/matrix/src/exec-approvals.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// Matrix plugin module implements exec approvals behavior.
|
||||
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import {
|
||||
createChannelExecApprovalProfile,
|
||||
getExecApprovalReplyMetadata,
|
||||
isChannelExecApprovalClientEnabledFromConfig,
|
||||
isChannelExecApprovalTargetRecipient,
|
||||
matchesApprovalRequestFilters,
|
||||
} from "openclaw/plugin-sdk/approval-client-runtime";
|
||||
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
PluginApprovalRequest,
|
||||
} from "openclaw/plugin-sdk/approval-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
|
||||
import { normalizeMatrixApproverId } from "./approval-ids.js";
|
||||
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
|
||||
export { normalizeMatrixApproverId };
|
||||
|
||||
function normalizeMatrixExecApproverId(value: string | number): string | undefined {
|
||||
const normalized = normalizeMatrixApproverId(value);
|
||||
return normalized === "*" ? undefined : normalized;
|
||||
}
|
||||
|
||||
function resolveMatrixExecApprovalConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const account = resolveMatrixAccount(params);
|
||||
const config = account.config.execApprovals;
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
enabled: account.enabled && account.configured ? config.enabled : false,
|
||||
};
|
||||
}
|
||||
|
||||
function countMatrixExecApprovalEligibleAccounts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
request: ApprovalRequest;
|
||||
approvalKind: ApprovalKind;
|
||||
}): number {
|
||||
return listMatrixAccountIds(params.cfg).filter((accountId) => {
|
||||
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
|
||||
if (!account.enabled || !account.configured) {
|
||||
return false;
|
||||
}
|
||||
const config = resolveMatrixExecApprovalConfig({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
});
|
||||
const filters = config?.enabled
|
||||
? {
|
||||
agentFilter: config.agentFilter,
|
||||
sessionFilter: config.sessionFilter,
|
||||
}
|
||||
: {
|
||||
agentFilter: undefined,
|
||||
sessionFilter: undefined,
|
||||
};
|
||||
return (
|
||||
isChannelExecApprovalClientEnabledFromConfig({
|
||||
enabled: config?.enabled,
|
||||
approverCount: getMatrixApprovalApprovers({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
}).length,
|
||||
}) &&
|
||||
matchesApprovalRequestFilters({
|
||||
request: params.request.request,
|
||||
agentFilter: filters.agentFilter,
|
||||
sessionFilter: filters.sessionFilter,
|
||||
})
|
||||
);
|
||||
}).length;
|
||||
}
|
||||
|
||||
function matchesMatrixRequestAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
approvalKind: ApprovalKind;
|
||||
}): boolean {
|
||||
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
|
||||
params.request.request.turnSourceChannel,
|
||||
);
|
||||
const boundAccountId = resolveApprovalRequestChannelAccountId({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
channel: "matrix",
|
||||
});
|
||||
if (turnSourceChannel && turnSourceChannel !== "matrix" && !boundAccountId) {
|
||||
return (
|
||||
countMatrixExecApprovalEligibleAccounts({
|
||||
cfg: params.cfg,
|
||||
request: params.request,
|
||||
approvalKind: params.approvalKind,
|
||||
}) <= 1
|
||||
);
|
||||
}
|
||||
return (
|
||||
!boundAccountId ||
|
||||
!params.accountId ||
|
||||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
|
||||
);
|
||||
}
|
||||
|
||||
export function getMatrixExecApprovalApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): string[] {
|
||||
const account = resolveMatrixAccount(params).config;
|
||||
return resolveApprovalApprovers({
|
||||
explicit: account.execApprovals?.approvers,
|
||||
allowFrom: account.dm?.allowFrom,
|
||||
normalizeApprover: normalizeMatrixExecApproverId,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMatrixApprovalKind(request: ApprovalRequest): ApprovalKind {
|
||||
return request.id.startsWith("plugin:") ? "plugin" : "exec";
|
||||
}
|
||||
|
||||
export function getMatrixApprovalApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
}): string[] {
|
||||
if (params.approvalKind === "plugin") {
|
||||
return getMatrixApprovalAuthApprovers({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
return getMatrixExecApprovalApprovers(params);
|
||||
}
|
||||
|
||||
export function isMatrixExecApprovalTargetRecipient(params: {
|
||||
cfg: OpenClawConfig;
|
||||
senderId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return isChannelExecApprovalTargetRecipient({
|
||||
...params,
|
||||
channel: "matrix",
|
||||
normalizeSenderId: normalizeMatrixApproverId,
|
||||
matchTarget: ({ target, normalizedSenderId }) =>
|
||||
normalizeMatrixApproverId(target.to) === normalizedSenderId,
|
||||
});
|
||||
}
|
||||
|
||||
const matrixExecApprovalProfile = createChannelExecApprovalProfile({
|
||||
resolveConfig: resolveMatrixExecApprovalConfig,
|
||||
resolveApprovers: getMatrixExecApprovalApprovers,
|
||||
normalizeSenderId: normalizeMatrixApproverId,
|
||||
isTargetRecipient: isMatrixExecApprovalTargetRecipient,
|
||||
matchesRequestAccount: (params) =>
|
||||
matchesMatrixRequestAccount({
|
||||
...params,
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
});
|
||||
|
||||
export const isMatrixExecApprovalClientEnabled = matrixExecApprovalProfile.isClientEnabled;
|
||||
export const isMatrixExecApprovalApprover = matrixExecApprovalProfile.isApprover;
|
||||
export const isMatrixExecApprovalAuthorizedSender = matrixExecApprovalProfile.isAuthorizedSender;
|
||||
export const resolveMatrixExecApprovalTarget = matrixExecApprovalProfile.resolveTarget;
|
||||
export const shouldHandleMatrixExecApprovalRequest = matrixExecApprovalProfile.shouldHandleRequest;
|
||||
|
||||
export function isMatrixApprovalClientEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
}): boolean {
|
||||
if (params.approvalKind === "exec") {
|
||||
return isMatrixExecApprovalClientEnabled(params);
|
||||
}
|
||||
const config = resolveMatrixExecApprovalConfig(params);
|
||||
return isChannelExecApprovalClientEnabledFromConfig({
|
||||
enabled: config?.enabled,
|
||||
approverCount: getMatrixApprovalApprovers(params).length,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMatrixAnyApprovalClientEnabled(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
isMatrixApprovalClientEnabled({
|
||||
...params,
|
||||
approvalKind: "exec",
|
||||
}) ||
|
||||
isMatrixApprovalClientEnabled({
|
||||
...params,
|
||||
approvalKind: "plugin",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHandleMatrixApprovalRequest(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
request: ApprovalRequest;
|
||||
}): boolean {
|
||||
const approvalKind = resolveMatrixApprovalKind(params.request);
|
||||
if (
|
||||
!matchesMatrixRequestAccount({
|
||||
...params,
|
||||
approvalKind,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const config = resolveMatrixExecApprovalConfig(params);
|
||||
if (
|
||||
!isChannelExecApprovalClientEnabledFromConfig({
|
||||
enabled: config?.enabled,
|
||||
approverCount: getMatrixApprovalApprovers({
|
||||
...params,
|
||||
approvalKind,
|
||||
}).length,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return matchesApprovalRequestFilters({
|
||||
request: params.request.request,
|
||||
agentFilter: config?.agentFilter,
|
||||
sessionFilter: config?.sessionFilter,
|
||||
});
|
||||
}
|
||||
|
||||
function buildFilterCheckRequest(params: {
|
||||
metadata: NonNullable<ReturnType<typeof getExecApprovalReplyMetadata>>;
|
||||
}): ApprovalRequest {
|
||||
if (params.metadata.approvalKind === "plugin") {
|
||||
return {
|
||||
id: params.metadata.approvalId,
|
||||
request: {
|
||||
title: "Plugin Approval Required",
|
||||
description: "",
|
||||
agentId: params.metadata.agentId ?? null,
|
||||
sessionKey: params.metadata.sessionKey ?? null,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: params.metadata.approvalId,
|
||||
request: {
|
||||
command: "",
|
||||
agentId: params.metadata.agentId ?? null,
|
||||
sessionKey: params.metadata.sessionKey ?? null,
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldSuppressLocalMatrixExecApprovalPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
payload: ReplyPayload;
|
||||
}): boolean {
|
||||
if (!matrixExecApprovalProfile.shouldSuppressLocalPrompt(params)) {
|
||||
return false;
|
||||
}
|
||||
const metadata = getExecApprovalReplyMetadata(params.payload);
|
||||
if (!metadata) {
|
||||
return false;
|
||||
}
|
||||
const request = buildFilterCheckRequest({
|
||||
metadata,
|
||||
});
|
||||
return shouldHandleMatrixApprovalRequest({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
request,
|
||||
});
|
||||
}
|
||||
30
extensions/matrix/src/group-mentions.test.ts
Normal file
30
extensions/matrix/src/group-mentions.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Matrix tests cover group mentions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||
|
||||
describe("Matrix group policy", () => {
|
||||
it("resolves room tool policy from the case-preserved Matrix room id", () => {
|
||||
const policy = resolveMatrixGroupToolPolicy({
|
||||
accountId: "default",
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
groups: {
|
||||
"!RoomABC:example.org": {
|
||||
tools: { allow: ["sessions_spawn"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groupId: "!roomabc:example.org",
|
||||
groupChannel: "!RoomABC:example.org",
|
||||
});
|
||||
|
||||
expect(policy).toEqual({ allow: ["sessions_spawn"] });
|
||||
});
|
||||
});
|
||||
42
extensions/matrix/src/group-mentions.ts
Normal file
42
extensions/matrix/src/group-mentions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Matrix plugin module implements group mentions behavior.
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import { normalizeMatrixResolvableTarget } from "./matrix/target-ids.js";
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "./runtime-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
|
||||
const roomId = normalizeMatrixResolvableTarget(params.groupId?.trim() ?? "");
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [normalizeMatrixResolvableTarget(groupChannel)] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
return resolveMatrixRoomConfig({
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
}).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;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Matrix tests cover legacy crypto inspector availability plugin behavior.
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const availabilityState = vi.hoisted(() => ({
|
||||
currentFilePath: "/virtual/dist/matrix-migration.runtime.js",
|
||||
existingPaths: new Set<string>(),
|
||||
dirEntries: [] as Array<{ name: string; isFile: () => boolean }>,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:fs")>("node:fs"),
|
||||
{
|
||||
existsSync: (candidate: unknown) => availabilityState.existingPaths.has(String(candidate)),
|
||||
readdirSync: () => availabilityState.dirEntries as never,
|
||||
},
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock("node:url", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:url")>("node:url");
|
||||
return {
|
||||
...actual,
|
||||
fileURLToPath: () => availabilityState.currentFilePath,
|
||||
};
|
||||
});
|
||||
|
||||
const { isMatrixLegacyCryptoInspectorAvailable } =
|
||||
await import("./legacy-crypto-inspector-availability.js");
|
||||
|
||||
describe("isMatrixLegacyCryptoInspectorAvailable", () => {
|
||||
beforeEach(() => {
|
||||
availabilityState.currentFilePath = "/virtual/dist/matrix-migration.runtime.js";
|
||||
availabilityState.existingPaths.clear();
|
||||
availabilityState.dirEntries = [];
|
||||
});
|
||||
|
||||
it("detects the source inspector module directly", () => {
|
||||
availabilityState.currentFilePath = path.resolve(
|
||||
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js",
|
||||
);
|
||||
availabilityState.existingPaths.add(
|
||||
path.resolve("/virtual/extensions/matrix/src/matrix/legacy-crypto-inspector.ts"),
|
||||
);
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("detects hashed built inspector chunks", () => {
|
||||
availabilityState.dirEntries = [
|
||||
{
|
||||
name: "legacy-crypto-inspector-TPlLnFSE.js",
|
||||
isFile: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not confuse the availability helper artifact with the real inspector", () => {
|
||||
availabilityState.dirEntries = [
|
||||
{
|
||||
name: "legacy-crypto-inspector-availability.js",
|
||||
isFile: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not confuse hashed availability helper chunks with the real inspector", () => {
|
||||
availabilityState.dirEntries = [
|
||||
{
|
||||
name: "legacy-crypto-inspector-availability-TPlLnFSE.js",
|
||||
isFile: () => true,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// Matrix plugin module implements legacy crypto inspector availability behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const LEGACY_CRYPTO_INSPECTOR_FILE = "legacy-crypto-inspector.js";
|
||||
const LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX = "legacy-crypto-inspector-";
|
||||
const LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX = "availability-";
|
||||
const JAVASCRIPT_MODULE_SUFFIX = ".js";
|
||||
|
||||
function isLegacyCryptoInspectorArtifactName(name: string): boolean {
|
||||
if (name === LEGACY_CRYPTO_INSPECTOR_FILE) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!name.startsWith(LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX) ||
|
||||
!name.endsWith(JAVASCRIPT_MODULE_SUFFIX)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const chunkSuffix = name.slice(
|
||||
LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX.length,
|
||||
-JAVASCRIPT_MODULE_SUFFIX.length,
|
||||
);
|
||||
return (
|
||||
chunkSuffix.length > 0 &&
|
||||
chunkSuffix !== "availability" &&
|
||||
!chunkSuffix.startsWith(LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX)
|
||||
);
|
||||
}
|
||||
|
||||
function hasSourceInspectorArtifact(currentDir: string): boolean {
|
||||
return [
|
||||
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
|
||||
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
|
||||
].some((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
function hasBuiltInspectorArtifact(currentDir: string): boolean {
|
||||
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
|
||||
return true;
|
||||
}
|
||||
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
|
||||
return true;
|
||||
}
|
||||
return fs
|
||||
.readdirSync(currentDir, { withFileTypes: true })
|
||||
.some((entry) => entry.isFile() && isLegacyCryptoInspectorArtifactName(entry.name));
|
||||
}
|
||||
|
||||
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
if (hasSourceInspectorArtifact(currentDir)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return hasBuiltInspectorArtifact(currentDir);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
243
extensions/matrix/src/legacy-crypto.test.ts
Normal file
243
extensions/matrix/src/legacy-crypto.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// Matrix tests cover legacy crypto plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { withTempHome } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
|
||||
available: true,
|
||||
}));
|
||||
|
||||
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
|
||||
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
|
||||
}));
|
||||
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
import {
|
||||
readMatrixLegacyCryptoMigrationState,
|
||||
readMatrixRecoveryKeyState,
|
||||
} from "./matrix/crypto-state-store.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
|
||||
import {
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_DEVICE_ID,
|
||||
MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_ACCOUNT_ID,
|
||||
MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_USER_ID,
|
||||
MATRIX_TEST_HOMESERVER,
|
||||
writeFile,
|
||||
writeMatrixCredentials,
|
||||
} from "./test-helpers.js";
|
||||
import { installMatrixTestRuntime } from "./test-runtime.js";
|
||||
|
||||
function createDefaultMatrixConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeDefaultLegacyCryptoFixture(home: string) {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg = createDefaultMatrixConfig();
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_DEFAULT_USER_ID,
|
||||
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
});
|
||||
writeFile(
|
||||
path.join(rootDir, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
|
||||
);
|
||||
return { cfg, rootDir };
|
||||
}
|
||||
|
||||
function createOpsLegacyCryptoFixture(params: {
|
||||
home: string;
|
||||
accessToken?: string;
|
||||
includeStoredCredentials?: boolean;
|
||||
}) {
|
||||
const stateDir = path.join(params.home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
|
||||
);
|
||||
if (params.includeStoredCredentials) {
|
||||
writeMatrixCredentials(stateDir, {
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
});
|
||||
}
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
userId: MATRIX_OPS_USER_ID,
|
||||
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
|
||||
accountId: MATRIX_OPS_ACCOUNT_ID,
|
||||
});
|
||||
return { rootDir };
|
||||
}
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
beforeEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
legacyCryptoInspectorAvailability.available = true;
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.inspectorAvailable).toBe(true);
|
||||
expect(detection.warnings).toStrictEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
|
||||
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("YWJjZA==");
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips migration when no legacy Matrix plans exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: createDefaultMatrixConfig(),
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_DEFAULT_DEVICE_ID,
|
||||
roomKeyCounts: { total: 15, backedUp: 10 },
|
||||
backupVersion: null,
|
||||
decryptionKeyBase64: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
|
||||
);
|
||||
expect(result.warnings).toContain(
|
||||
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
|
||||
);
|
||||
expect(readMatrixLegacyCryptoMigrationState(rootDir)?.restoreStatus).toBe(
|
||||
"manual-action-required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers stored credentials for named accounts when config is token-only", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const { rootDir } = createOpsLegacyCryptoFixture({
|
||||
home,
|
||||
includeStoredCredentials: true,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: MATRIX_TEST_HOMESERVER,
|
||||
accessToken: MATRIX_OPS_ACCESS_TOKEN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: MATRIX_OPS_DEVICE_ID,
|
||||
roomKeyCounts: { total: 1, backedUp: 1 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "b3Bz",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("b3Bz");
|
||||
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
|
||||
legacyCryptoInspectorAvailability.available = false;
|
||||
|
||||
await withTempHome(async (home) => {
|
||||
const { cfg } = writeDefaultLegacyCryptoFixture(home);
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.inspectorAvailable).toBe(false);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.warnings).toContain(
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
|
||||
);
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
migrated: false,
|
||||
changes: [],
|
||||
warnings: [
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
502
extensions/matrix/src/legacy-crypto.ts
Normal file
502
extensions/matrix/src/legacy-crypto.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
// Matrix plugin module implements legacy crypto behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
|
||||
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
|
||||
import {
|
||||
migrateLegacyMatrixLegacyCryptoMigrationFileToStore,
|
||||
migrateLegacyMatrixRecoveryKeyFileToStore,
|
||||
readMatrixLegacyCryptoMigrationState,
|
||||
readMatrixRecoveryKeyState,
|
||||
writeMatrixLegacyCryptoMigrationState,
|
||||
writeMatrixRecoveryKeyState,
|
||||
type MatrixLegacyCryptoMigrationState,
|
||||
} from "./matrix/crypto-state-store.js";
|
||||
import { formatMatrixErrorMessage } from "./matrix/errors.js";
|
||||
import type { MatrixStoredRecoveryKey } from "./matrix/sdk/types.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
} from "./migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
|
||||
|
||||
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
|
||||
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoSummary = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: MatrixLegacyCryptoCounts | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPlan = {
|
||||
accountId: string;
|
||||
rootDir: string;
|
||||
recoveryKeyPath: string;
|
||||
statePath: string;
|
||||
legacyCryptoPath: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoDetection = {
|
||||
inspectorAvailable: boolean;
|
||||
plans: MatrixLegacyCryptoPlan[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPreparationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoPrepareDeps = {
|
||||
inspectLegacyStore: MatrixLegacyCryptoInspector;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspectorParams = {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspectorResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
type MatrixLegacyCryptoInspector = (
|
||||
params: MatrixLegacyCryptoInspectorParams,
|
||||
) => Promise<MatrixLegacyCryptoInspectorResult>;
|
||||
|
||||
type MatrixLegacyBotSdkMetadata = {
|
||||
deviceId: string | null;
|
||||
};
|
||||
|
||||
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
|
||||
const module = await import("./matrix/legacy-crypto-inspector.js");
|
||||
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
|
||||
}
|
||||
|
||||
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
|
||||
detected: boolean;
|
||||
warning?: string;
|
||||
} {
|
||||
try {
|
||||
const stat = fs.statSync(cryptoRootDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
detected:
|
||||
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
|
||||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
|
||||
fs
|
||||
.readdirSync(cryptoRootDir, { withFileTypes: true })
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
detected: false,
|
||||
warning:
|
||||
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
|
||||
"OpenClaw skipped automatic crypto migration for that path.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return resolveConfiguredMatrixAccountIds(cfg);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixFlatStorePlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoPlan | { warning: string } | null {
|
||||
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
|
||||
if (!fs.existsSync(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
|
||||
if (legacyStore.warning) {
|
||||
return { warning: legacyStore.warning };
|
||||
}
|
||||
if (!legacyStore.detected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
detectedPath: legacy.cryptoPath,
|
||||
detectedKind: "encrypted state",
|
||||
});
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
|
||||
return {
|
||||
accountId: target.accountId,
|
||||
rootDir: target.rootDir,
|
||||
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
|
||||
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
|
||||
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
|
||||
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
|
||||
const parsed = loadJsonFile<{ deviceId?: unknown }>(metadataPath);
|
||||
return {
|
||||
deviceId:
|
||||
typeof parsed?.deviceId === "string" && parsed.deviceId.trim()
|
||||
? parsed.deviceId
|
||||
: fallback.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixLegacyCryptoPlans(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Omit<MatrixLegacyCryptoDetection, "inspectorAvailable"> {
|
||||
const warnings: string[] = [];
|
||||
const plans: MatrixLegacyCryptoPlan[] = [];
|
||||
|
||||
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
|
||||
if (flatPlan) {
|
||||
if ("warning" in flatPlan) {
|
||||
warnings.push(flatPlan.warning);
|
||||
} else {
|
||||
plans.push(flatPlan);
|
||||
}
|
||||
}
|
||||
|
||||
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
|
||||
const target = resolveMatrixMigrationAccountTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const legacyCryptoPath = path.join(target.rootDir, "crypto");
|
||||
if (!fs.existsSync(legacyCryptoPath)) {
|
||||
continue;
|
||||
}
|
||||
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
|
||||
if (detectedStore.warning) {
|
||||
warnings.push(detectedStore.warning);
|
||||
continue;
|
||||
}
|
||||
if (!detectedStore.detected) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
plans.some(
|
||||
(plan) =>
|
||||
plan.accountId === accountId &&
|
||||
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
|
||||
plans.push({
|
||||
accountId: target.accountId,
|
||||
rootDir: target.rootDir,
|
||||
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
|
||||
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
|
||||
legacyCryptoPath,
|
||||
homeserver: target.homeserver,
|
||||
userId: target.userId,
|
||||
accessToken: target.accessToken,
|
||||
deviceId: metadata.deviceId ?? target.storedDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
return { plans, warnings };
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyCryptoDetection {
|
||||
const detection = resolveMatrixLegacyCryptoPlans({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
const inspectorAvailable =
|
||||
detection.plans.length === 0 || isMatrixLegacyCryptoInspectorAvailable();
|
||||
if (!inspectorAvailable && detection.plans.length > 0) {
|
||||
return {
|
||||
inspectorAvailable,
|
||||
plans: detection.plans,
|
||||
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
|
||||
};
|
||||
}
|
||||
return {
|
||||
inspectorAvailable,
|
||||
plans: detection.plans,
|
||||
warnings: detection.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function autoPrepareLegacyMatrixCrypto(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
|
||||
}): Promise<MatrixLegacyCryptoPreparationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = params.deps?.inspectLegacyStore
|
||||
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
|
||||
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
|
||||
const inspectorAvailable =
|
||||
"inspectorAvailable" in detection ? detection.inspectorAvailable : true;
|
||||
const warnings = [...detection.warnings];
|
||||
const changes: string[] = [];
|
||||
if (detection.plans.length === 0) {
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
if (!params.deps?.inspectLegacyStore && !inspectorAvailable) {
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let inspectLegacyStore = params.deps?.inspectLegacyStore;
|
||||
if (!inspectLegacyStore) {
|
||||
try {
|
||||
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
|
||||
} catch (err) {
|
||||
const message = formatMatrixErrorMessage(err);
|
||||
if (!warnings.includes(message)) {
|
||||
warnings.push(message);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!inspectLegacyStore) {
|
||||
return {
|
||||
migrated: false,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
for (const plan of detection.plans) {
|
||||
try {
|
||||
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(plan.rootDir);
|
||||
migrateLegacyMatrixRecoveryKeyFileToStore(plan.rootDir);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating Matrix crypto sidecar state for account "${plan.accountId}" (${plan.rootDir}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
const existingState = readMatrixLegacyCryptoMigrationState(plan.rootDir);
|
||||
if (existingState?.version === 1) {
|
||||
continue;
|
||||
}
|
||||
if (!plan.deviceId) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
|
||||
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary: MatrixLegacyCryptoSummary;
|
||||
try {
|
||||
summary = await inspectLegacyStore({
|
||||
cryptoRootDir: plan.legacyCryptoPath,
|
||||
userId: plan.userId,
|
||||
deviceId: plan.deviceId,
|
||||
log: params.log?.info,
|
||||
});
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let decryptionKeyImported = false;
|
||||
if (summary.decryptionKeyBase64) {
|
||||
const existingRecoveryKey = readMatrixRecoveryKeyState(plan.rootDir);
|
||||
if (
|
||||
existingRecoveryKey?.privateKeyBase64 &&
|
||||
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
|
||||
) {
|
||||
warnings.push(
|
||||
`Legacy Matrix backup key was found for account "${plan.accountId}", but Matrix SQLite state already contains a different recovery key. Leaving the existing state unchanged.`,
|
||||
);
|
||||
} else if (!existingRecoveryKey?.privateKeyBase64) {
|
||||
const payload: MatrixStoredRecoveryKey = {
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: null,
|
||||
privateKeyBase64: summary.decryptionKeyBase64,
|
||||
};
|
||||
try {
|
||||
writeMatrixRecoveryKeyState({
|
||||
storageRootDir: plan.rootDir,
|
||||
payload,
|
||||
});
|
||||
changes.push(
|
||||
`Imported Matrix legacy backup key for account "${plan.accountId}" into SQLite`,
|
||||
);
|
||||
decryptionKeyImported = true;
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed writing Matrix recovery key for account "${plan.accountId}" to SQLite: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
decryptionKeyImported = true;
|
||||
}
|
||||
}
|
||||
|
||||
const localOnlyKeys =
|
||||
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
|
||||
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
|
||||
: 0;
|
||||
if (localOnlyKeys > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
|
||||
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
|
||||
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
|
||||
);
|
||||
}
|
||||
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
|
||||
warnings.push(
|
||||
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
|
||||
);
|
||||
}
|
||||
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
|
||||
if (
|
||||
summary.decryptionKeyBase64 &&
|
||||
!decryptionKeyImported &&
|
||||
!readMatrixRecoveryKeyState(plan.rootDir)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state: MatrixLegacyCryptoMigrationState = {
|
||||
version: 1,
|
||||
source: "matrix-bot-sdk-rust",
|
||||
accountId: plan.accountId,
|
||||
deviceId: summary.deviceId,
|
||||
roomKeyCounts: summary.roomKeyCounts,
|
||||
backupVersion: summary.backupVersion,
|
||||
decryptionKeyImported,
|
||||
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
|
||||
detectedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
};
|
||||
try {
|
||||
writeMatrixLegacyCryptoMigrationState({
|
||||
storageRootDir: plan.rootDir,
|
||||
state,
|
||||
});
|
||||
changes.push(
|
||||
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}" in SQLite`,
|
||||
);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" to SQLite: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0) {
|
||||
params.log?.info?.(
|
||||
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
87
extensions/matrix/src/legacy-state.test.ts
Normal file
87
extensions/matrix/src/legacy-state.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Matrix tests cover legacy state plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { withTempHome } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf-8");
|
||||
}
|
||||
|
||||
describe("matrix legacy state migration", () => {
|
||||
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected a migratable Matrix legacy state plan");
|
||||
}
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-from-cache",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const detection = detectLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(detection && "warning" in detection).toBe(false);
|
||||
if (!detection || "warning" in detection) {
|
||||
throw new Error("expected cached credentials to make Matrix migration resolvable");
|
||||
}
|
||||
|
||||
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
|
||||
|
||||
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
extensions/matrix/src/legacy-state.ts
Normal file
157
extensions/matrix/src/legacy-state.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Matrix plugin module implements legacy state behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
|
||||
|
||||
type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
type MatrixLegacyStatePlan = {
|
||||
accountId: string;
|
||||
legacyStoragePath: string;
|
||||
legacyCryptoPath: string;
|
||||
targetRootDir: string;
|
||||
targetStoragePath: string;
|
||||
targetCryptoPath: string;
|
||||
selectionNote?: string;
|
||||
};
|
||||
|
||||
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
return resolveMatrixLegacyFlatStoragePaths(stateDir);
|
||||
}
|
||||
|
||||
function resolveMatrixMigrationPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
const legacy = resolveLegacyMatrixPaths(params.env);
|
||||
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = resolveLegacyMatrixFlatStoreTarget({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
detectedPath: legacy.rootDir,
|
||||
detectedKind: "state",
|
||||
});
|
||||
if ("warning" in target) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: target.accountId,
|
||||
legacyStoragePath: legacy.storagePath,
|
||||
legacyCryptoPath: legacy.cryptoPath,
|
||||
targetRootDir: target.rootDir,
|
||||
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
|
||||
targetCryptoPath: path.join(target.rootDir, "crypto"),
|
||||
selectionNote: target.selectionNote,
|
||||
};
|
||||
}
|
||||
|
||||
export function detectLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixLegacyStatePlan | { warning: string } | null {
|
||||
return resolveMatrixMigrationPlan({
|
||||
cfg: params.cfg,
|
||||
env: params.env ?? process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function moveLegacyPath(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
label: string;
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
}): void {
|
||||
if (!fs.existsSync(params.sourcePath)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(params.targetPath)) {
|
||||
params.warnings.push(
|
||||
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
|
||||
fs.renameSync(params.sourcePath, params.targetPath);
|
||||
params.changes.push(
|
||||
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.warnings.push(
|
||||
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function autoMigrateLegacyMatrixState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log?: { info?: (message: string) => void; warn?: (message: string) => void };
|
||||
}): Promise<MatrixLegacyStateMigrationResult> {
|
||||
const env = params.env ?? process.env;
|
||||
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
|
||||
if (!detection) {
|
||||
return { migrated: false, changes: [], warnings: [] };
|
||||
}
|
||||
if ("warning" in detection) {
|
||||
params.log?.warn?.(`matrix: ${detection.warning}`);
|
||||
return { migrated: false, changes: [], warnings: [detection.warning] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyStoragePath,
|
||||
targetPath: detection.targetStoragePath,
|
||||
label: "sync store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
moveLegacyPath({
|
||||
sourcePath: detection.legacyCryptoPath,
|
||||
targetPath: detection.targetCryptoPath,
|
||||
label: "crypto store",
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
|
||||
if (changes.length > 0) {
|
||||
const details = [
|
||||
...changes.map((entry) => `- ${entry}`),
|
||||
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
|
||||
"- No user action required.",
|
||||
];
|
||||
params.log?.info?.(
|
||||
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
params.log?.warn?.(
|
||||
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: changes.length > 0,
|
||||
changes,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
10
extensions/matrix/src/matrix-migration.runtime.ts
Normal file
10
extensions/matrix/src/matrix-migration.runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Matrix plugin module implements matrix migration behavior.
|
||||
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
|
||||
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
|
||||
export {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
resolveMatrixMigrationStatus,
|
||||
type MatrixMigrationStatus,
|
||||
} from "./migration-snapshot.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "./migration-snapshot-backup.js";
|
||||
176
extensions/matrix/src/matrix/account-config.ts
Normal file
176
extensions/matrix/src/matrix/account-config.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Matrix helper module supports account config behavior.
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
listConfiguredAccountIds,
|
||||
resolveMergedAccountConfig,
|
||||
resolveNormalizedAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
|
||||
|
||||
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
|
||||
return cfg.channels?.matrix ?? {};
|
||||
}
|
||||
|
||||
function resolveMatrixAccountsMap(cfg: CoreConfig): Readonly<Record<string, MatrixAccountConfig>> {
|
||||
const accounts = resolveMatrixBaseConfig(cfg).accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return {};
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
function selectInheritedMatrixRoomEntries(params: {
|
||||
entries: MatrixRoomEntries | undefined;
|
||||
accountId: string;
|
||||
}): MatrixRoomEntries | undefined {
|
||||
const entries = params.entries;
|
||||
if (!entries) {
|
||||
return undefined;
|
||||
}
|
||||
const selected = Object.fromEntries(
|
||||
Object.entries(entries).filter(([, value]) => {
|
||||
const scopedAccount =
|
||||
typeof value?.account === "string" ? normalizeAccountId(value.account) : undefined;
|
||||
return scopedAccount === undefined || scopedAccount === params.accountId;
|
||||
}),
|
||||
) as MatrixRoomEntries;
|
||||
return Object.keys(selected).length > 0 ? selected : undefined;
|
||||
}
|
||||
|
||||
function mergeMatrixRoomEntries(
|
||||
inherited: MatrixRoomEntries | undefined,
|
||||
accountEntries: MatrixRoomEntries | undefined,
|
||||
hasAccountOverride: boolean,
|
||||
): MatrixRoomEntries | undefined {
|
||||
if (!inherited && !accountEntries) {
|
||||
return undefined;
|
||||
}
|
||||
if (hasAccountOverride && Object.keys(accountEntries ?? {}).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const merged: MatrixRoomEntries = {
|
||||
...inherited,
|
||||
};
|
||||
for (const [key, value] of Object.entries(accountEntries ?? {})) {
|
||||
const inheritedValue = merged[key];
|
||||
merged[key] =
|
||||
inheritedValue && value
|
||||
? {
|
||||
...inheritedValue,
|
||||
...value,
|
||||
}
|
||||
: (value ?? inheritedValue);
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
return listConfiguredAccountIds({
|
||||
accounts: resolveMatrixAccountsMap(cfg),
|
||||
normalizeAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function findMatrixAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): MatrixAccountConfig | undefined {
|
||||
return resolveNormalizedAccountEntry(
|
||||
resolveMatrixAccountsMap(cfg),
|
||||
accountId,
|
||||
normalizeAccountId,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (findMatrixAccountConfig(cfg, normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (normalized !== DEFAULT_ACCOUNT_ID) {
|
||||
return false;
|
||||
}
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
return (
|
||||
typeof matrix.enabled === "boolean" ||
|
||||
typeof matrix.name === "string" ||
|
||||
typeof matrix.homeserver === "string" ||
|
||||
typeof matrix.userId === "string" ||
|
||||
hasConfiguredSecretInput(matrix.accessToken) ||
|
||||
hasConfiguredSecretInput(matrix.password) ||
|
||||
typeof matrix.deviceId === "string" ||
|
||||
typeof matrix.deviceName === "string" ||
|
||||
typeof matrix.avatarUrl === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): MatrixConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const base = resolveMatrixBaseConfig(params.cfg);
|
||||
const merged = resolveMergedAccountConfig<MatrixConfig>({
|
||||
channelConfig: base,
|
||||
accounts: params.cfg.channels?.matrix?.accounts as
|
||||
| Record<string, Partial<MatrixConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["dm", "actions", "execApprovals", "botLoopProtection"],
|
||||
});
|
||||
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
|
||||
const groups = mergeMatrixRoomEntries(
|
||||
selectInheritedMatrixRoomEntries({
|
||||
entries: base.groups,
|
||||
accountId,
|
||||
}),
|
||||
accountConfig?.groups,
|
||||
Boolean(accountConfig && Object.hasOwn(accountConfig, "groups")),
|
||||
);
|
||||
const rooms = mergeMatrixRoomEntries(
|
||||
selectInheritedMatrixRoomEntries({
|
||||
entries: base.rooms,
|
||||
accountId,
|
||||
}),
|
||||
accountConfig?.rooms,
|
||||
Boolean(accountConfig && Object.hasOwn(accountConfig, "rooms")),
|
||||
);
|
||||
// Room maps need custom scoping, so keep the generic merge for all other fields.
|
||||
const { groups: _ignoredGroups, rooms: _ignoredRooms, ...rest } = merged;
|
||||
return {
|
||||
...rest,
|
||||
...(groups ? { groups } : {}),
|
||||
...(rooms ? { rooms } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountAllowlistConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
dmAllowFrom?: NonNullable<MatrixConfig["dm"]>["allowFrom"];
|
||||
groupAllowFrom?: MatrixConfig["groupAllowFrom"];
|
||||
} {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const base = resolveMatrixBaseConfig(params.cfg);
|
||||
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
|
||||
const accountDm = accountConfig?.dm;
|
||||
|
||||
let dmAllowFrom = base.dm?.allowFrom;
|
||||
if (accountDm && Object.hasOwn(accountDm, "allowFrom")) {
|
||||
dmAllowFrom = accountDm.allowFrom;
|
||||
}
|
||||
|
||||
let groupAllowFrom = base.groupAllowFrom;
|
||||
if (accountConfig && Object.hasOwn(accountConfig, "groupAllowFrom")) {
|
||||
groupAllowFrom = accountConfig.groupAllowFrom;
|
||||
}
|
||||
|
||||
return { dmAllowFrom, groupAllowFrom };
|
||||
}
|
||||
28
extensions/matrix/src/matrix/accounts.readiness.test.ts
Normal file
28
extensions/matrix/src/matrix/accounts.readiness.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Matrix tests cover accounts.readiness plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveMatrixAccount readiness", () => {
|
||||
it("does not treat inherited base auth as configured for named accounts", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
installMatrixTestRuntime({ cfg });
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "default" }).configured).toBe(true);
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).configured).toBe(false);
|
||||
});
|
||||
});
|
||||
790
extensions/matrix/src/matrix/accounts.test.ts
Normal file
790
extensions/matrix/src/matrix/accounts.test.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
// Matrix tests cover accounts plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveConfiguredMatrixBotUserIds,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
} from "./accounts.js";
|
||||
import type { MatrixStoredCredentials } from "./credentials-read.js";
|
||||
|
||||
const loadMatrixCredentialsMock = vi.hoisted(() =>
|
||||
vi.fn<(env?: NodeJS.ProcessEnv, accountId?: string | null) => MatrixStoredCredentials | null>(
|
||||
() => null,
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: (env?: NodeJS.ProcessEnv, accountId?: string | null) =>
|
||||
loadMatrixCredentialsMock(env, accountId),
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
const envKeys = [
|
||||
"MATRIX_HOMESERVER",
|
||||
"MATRIX_USER_ID",
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_DEVICE_NAME",
|
||||
"MATRIX_DEFAULT_HOMESERVER",
|
||||
"MATRIX_DEFAULT_ACCESS_TOKEN",
|
||||
getMatrixScopedEnvVarNames("team-ops").homeserver,
|
||||
getMatrixScopedEnvVarNames("team-ops").accessToken,
|
||||
];
|
||||
|
||||
type MatrixRoomScopeKey = "groups" | "rooms";
|
||||
|
||||
function createMatrixAccountConfig(accessToken: string) {
|
||||
return {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
function createMatrixScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
[scopeKey]: {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!axis-room:example.org": {
|
||||
enabled: true,
|
||||
account: "axis",
|
||||
},
|
||||
"!unassigned-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
default: createMatrixAccountConfig("default-token"),
|
||||
axis: createMatrixAccountConfig("axis-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
}
|
||||
|
||||
function createMatrixTopLevelDefaultScopedEntriesConfig(scopeKey: MatrixRoomScopeKey): CoreConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
...createMatrixAccountConfig("default-token"),
|
||||
[scopeKey]: {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!ops-room:example.org": {
|
||||
enabled: true,
|
||||
account: "ops",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
ops: createMatrixAccountConfig("ops-token"),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
}
|
||||
|
||||
function expectMatrixScopedEntries(
|
||||
cfg: CoreConfig,
|
||||
scopeKey: MatrixRoomScopeKey,
|
||||
accountId: string,
|
||||
expected: Record<string, { enabled: true; account?: string }>,
|
||||
): void {
|
||||
expect(resolveMatrixAccount({ cfg, accountId }).config[scopeKey]).toEqual(expected);
|
||||
}
|
||||
|
||||
function expectMultiAccountMatrixScopedEntries(
|
||||
cfg: CoreConfig,
|
||||
scopeKey: MatrixRoomScopeKey,
|
||||
): void {
|
||||
expectMatrixScopedEntries(cfg, scopeKey, "default", {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!unassigned-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expectMatrixScopedEntries(cfg, scopeKey, "axis", {
|
||||
"!axis-room:example.org": {
|
||||
enabled: true,
|
||||
account: "axis",
|
||||
},
|
||||
"!unassigned-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expectTopLevelDefaultMatrixScopedEntries(
|
||||
cfg: CoreConfig,
|
||||
scopeKey: MatrixRoomScopeKey,
|
||||
): void {
|
||||
expectMatrixScopedEntries(cfg, scopeKey, "default", {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expectMatrixScopedEntries(cfg, scopeKey, "ops", {
|
||||
"!ops-room:example.org": {
|
||||
enabled: true,
|
||||
account: "ops",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveMatrixAccount", () => {
|
||||
let prevEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
loadMatrixCredentialsMock.mockReset().mockReturnValue(null);
|
||||
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("treats SecretRef access-token config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("treats accounts.default SecretRef access-token config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("treats accounts.default SecretRef password config as configured", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-password",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("merges account bot loop protection over top-level defaults field-by-field", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "top-token",
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 8,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
accessToken: "ops-token",
|
||||
botLoopProtection: {
|
||||
maxEventsPerWindow: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg, accountId: "ops" });
|
||||
expect(account.config.botLoopProtection).toEqual({
|
||||
maxEventsPerWindow: 3,
|
||||
windowSeconds: 120,
|
||||
cooldownSeconds: 240,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes and de-duplicates configured account ids", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Main Bot",
|
||||
accounts: {
|
||||
"Main Bot": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "main-token",
|
||||
},
|
||||
"main-bot": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "duplicate-token",
|
||||
},
|
||||
OPS: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("returns the only named account when no explicit default is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.accountId).toBe("ops");
|
||||
expect(account.homeserver).toBe("https://ops.example.org");
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("includes env-backed named accounts in plugin account enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
process.env[keys.homeserver] = "https://matrix.example.org";
|
||||
process.env[keys.accessToken] = "ops-token";
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("includes default accounts backed only by global env vars in plugin account enumeration", () => {
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "default-token";
|
||||
|
||||
const cfg: CoreConfig = {};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["default"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "default-token";
|
||||
process.env[keys.homeserver] = "https://matrix.example.org";
|
||||
process.env[keys.accessToken] = "ops-token";
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["default", "team-ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("includes a top-level configured default account alongside named accounts", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["default", "ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
enabled: true,
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
alpha: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "alpha-token",
|
||||
},
|
||||
beta: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "beta-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("collects other configured Matrix account user ids for bot detection", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@main:example.org",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "main-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
alerts: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@alerts:example.org",
|
||||
accessToken: "alerts-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops" })).toSorted(),
|
||||
).toEqual(["@alerts:example.org", "@main:example.org"]);
|
||||
});
|
||||
|
||||
it("honors injected env when detecting configured bot accounts", () => {
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@main:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "main-token",
|
||||
MATRIX_ALERTS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ALERTS_USER_ID: "@alerts:example.org",
|
||||
MATRIX_ALERTS_ACCESS_TOKEN: "alerts-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "ops", env })).toSorted(),
|
||||
).toEqual(["@alerts:example.org", "@main:example.org"]);
|
||||
});
|
||||
|
||||
it("falls back to stored credentials when an access-token-only account omits userId", () => {
|
||||
loadMatrixCredentialsMock.mockImplementation(
|
||||
(env?: NodeJS.ProcessEnv, accountId?: string | null) =>
|
||||
accountId === "ops"
|
||||
? {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
createdAt: "2026-03-19T00:00:00.000Z",
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@main:example.org",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "main-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Array.from(resolveConfiguredMatrixBotUserIds({ cfg, accountId: "default" }))).toEqual([
|
||||
"@ops:example.org",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves shared nested dm and actions config when an account overrides one field", () => {
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "main-token",
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing",
|
||||
},
|
||||
actions: {
|
||||
reactions: true,
|
||||
messages: true,
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
accessToken: "ops-token",
|
||||
dm: {
|
||||
allowFrom: ["@ops:example.org"],
|
||||
},
|
||||
actions: {
|
||||
messages: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(account.config.dm).toEqual({
|
||||
enabled: true,
|
||||
policy: "pairing",
|
||||
allowFrom: ["@ops:example.org"],
|
||||
});
|
||||
expect(account.config.actions).toEqual({
|
||||
reactions: true,
|
||||
messages: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters channel-level groups by room account in multi-account setups", () => {
|
||||
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("groups"), "groups");
|
||||
});
|
||||
|
||||
it("filters channel-level groups when the default account is configured at the top level", () => {
|
||||
expectTopLevelDefaultMatrixScopedEntries(
|
||||
createMatrixTopLevelDefaultScopedEntriesConfig("groups"),
|
||||
"groups",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters legacy channel-level rooms by room account in multi-account setups", () => {
|
||||
expectMultiAccountMatrixScopedEntries(createMatrixScopedEntriesConfig("rooms"), "rooms");
|
||||
});
|
||||
|
||||
it("filters legacy channel-level rooms when the default account is configured at the top level", () => {
|
||||
expectTopLevelDefaultMatrixScopedEntries(
|
||||
createMatrixTopLevelDefaultScopedEntriesConfig("rooms"),
|
||||
"rooms",
|
||||
);
|
||||
});
|
||||
|
||||
it("honors injected env when scoping room entries in multi-account setups", () => {
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!ops-room:example.org": {
|
||||
enabled: true,
|
||||
account: "ops",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops", env }).config.groups).toEqual({
|
||||
"!ops-room:example.org": {
|
||||
enabled: true,
|
||||
account: "ops",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps scoped groups bound to their account even when only one account is active", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps scoped legacy rooms bound to their account even when only one account is active", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
rooms: {
|
||||
"!default-room:example.org": {
|
||||
enabled: true,
|
||||
account: "default",
|
||||
},
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets an account clear inherited groups with an explicit empty map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
groups: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets an account clear inherited legacy rooms with an explicit empty map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
rooms: {
|
||||
"!shared-room:example.org": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
rooms: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toBeUndefined();
|
||||
});
|
||||
});
|
||||
195
extensions/matrix/src/matrix/accounts.ts
Normal file
195
extensions/matrix/src/matrix/accounts.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// Matrix plugin module implements accounts behavior.
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
resolveMatrixAccountConfig,
|
||||
resolveMatrixBaseConfig,
|
||||
} from "./account-config.js";
|
||||
import { resolveGlobalMatrixEnvConfig, resolveScopedMatrixEnvConfig } from "./client/env-auth.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
|
||||
|
||||
export type ResolvedMatrixAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
config: MatrixConfig;
|
||||
};
|
||||
|
||||
function clean(value: unknown): string {
|
||||
return normalizeOptionalString(value) ?? "";
|
||||
}
|
||||
|
||||
function resolveMatrixAccountAuthView(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
} {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const matrix = resolveMatrixBaseConfig(params.cfg);
|
||||
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
account: {
|
||||
homeserver: clean(account.homeserver),
|
||||
userId: clean(account.userId),
|
||||
accessToken: typeof account.accessToken === "string" ? clean(account.accessToken) : "",
|
||||
password: typeof account.password === "string" ? clean(account.password) : "",
|
||||
deviceId: clean(account.deviceId),
|
||||
deviceName: clean(account.deviceName),
|
||||
},
|
||||
scopedEnv: resolveScopedMatrixEnvConfig(normalizedAccountId, params.env),
|
||||
channel: {
|
||||
homeserver: clean(matrix.homeserver),
|
||||
userId: clean(matrix.userId),
|
||||
accessToken: typeof matrix.accessToken === "string" ? clean(matrix.accessToken) : "",
|
||||
password: typeof matrix.password === "string" ? clean(matrix.password) : "",
|
||||
deviceId: clean(matrix.deviceId),
|
||||
deviceName: clean(matrix.deviceName),
|
||||
},
|
||||
globalEnv: resolveGlobalMatrixEnvConfig(params.env),
|
||||
});
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
accessToken: resolvedStrings.accessToken || undefined,
|
||||
password: resolvedStrings.password || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixAccountUserId(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string | null {
|
||||
const env = params.env ?? process.env;
|
||||
const authView = resolveMatrixAccountAuthView({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
env,
|
||||
});
|
||||
const configuredUserId = authView.userId.trim();
|
||||
if (configuredUserId) {
|
||||
return configuredUserId;
|
||||
}
|
||||
|
||||
const stored = loadMatrixCredentials(env, params.accountId);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
if (authView.homeserver && stored.homeserver !== authView.homeserver) {
|
||||
return null;
|
||||
}
|
||||
if (authView.accessToken && stored.accessToken !== authView.accessToken) {
|
||||
return null;
|
||||
}
|
||||
return stored.userId.trim() || null;
|
||||
}
|
||||
|
||||
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = resolveConfiguredMatrixAccountIds(cfg, process.env);
|
||||
return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveConfiguredMatrixBotUserIds(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Set<string> {
|
||||
const env = params.env ?? process.env;
|
||||
const currentAccountId = normalizeAccountId(params.accountId);
|
||||
const accountIds = new Set(resolveConfiguredMatrixAccountIds(params.cfg, env));
|
||||
if (resolveMatrixAccount({ cfg: params.cfg, accountId: DEFAULT_ACCOUNT_ID, env }).configured) {
|
||||
accountIds.add(DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
if (normalizeAccountId(accountId) === currentAccountId) {
|
||||
continue;
|
||||
}
|
||||
if (!resolveMatrixAccount({ cfg: params.cfg, accountId, env }).configured) {
|
||||
continue;
|
||||
}
|
||||
const userId = resolveMatrixAccountUserId({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
env,
|
||||
});
|
||||
if (userId) {
|
||||
ids.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ResolvedMatrixAccount {
|
||||
const env = params.env ?? process.env;
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultMatrixAccountId(params.cfg),
|
||||
);
|
||||
const matrixBase = resolveMatrixBaseConfig(params.cfg);
|
||||
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId, env });
|
||||
const explicitAuthConfig =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? base
|
||||
: (findMatrixAccountConfig(params.cfg, accountId) ?? {});
|
||||
const enabled = base.enabled !== false && matrixBase.enabled !== false;
|
||||
|
||||
const authView = resolveMatrixAccountAuthView({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
env,
|
||||
});
|
||||
const hasHomeserver = Boolean(authView.homeserver);
|
||||
const hasUserId = Boolean(authView.userId);
|
||||
const hasAccessToken =
|
||||
Boolean(authView.accessToken) || hasConfiguredSecretInput(explicitAuthConfig.accessToken);
|
||||
const hasPassword = Boolean(authView.password);
|
||||
const hasPasswordAuth =
|
||||
hasUserId && (hasPassword || hasConfiguredSecretInput(explicitAuthConfig.password));
|
||||
const stored = loadMatrixCredentials(env, accountId);
|
||||
const hasStored =
|
||||
stored && authView.homeserver
|
||||
? credentialsMatchConfig(stored, {
|
||||
homeserver: authView.homeserver,
|
||||
userId: authView.userId || "",
|
||||
})
|
||||
: false;
|
||||
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || hasStored);
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(base.name),
|
||||
configured,
|
||||
homeserver: authView.homeserver || undefined,
|
||||
userId: authView.userId || undefined,
|
||||
config: base,
|
||||
};
|
||||
}
|
||||
|
||||
export { resolveMatrixAccountConfig } from "./account-config.js";
|
||||
38
extensions/matrix/src/matrix/actions.ts
Normal file
38
extensions/matrix/src/matrix/actions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Matrix plugin module implements actions behavior.
|
||||
export type {
|
||||
MatrixActionClientOpts,
|
||||
MatrixMessageSummary,
|
||||
MatrixReactionSummary,
|
||||
} from "./actions/types.js";
|
||||
export {
|
||||
sendMatrixMessage,
|
||||
editMatrixMessage,
|
||||
deleteMatrixMessage,
|
||||
readMatrixMessages,
|
||||
} from "./actions/messages.js";
|
||||
export { voteMatrixPoll } from "./actions/polls.js";
|
||||
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||
export { updateMatrixOwnProfile } from "./actions/profile.js";
|
||||
export {
|
||||
bootstrapMatrixVerification,
|
||||
acceptMatrixVerification,
|
||||
cancelMatrixVerification,
|
||||
confirmMatrixVerificationReciprocateQr,
|
||||
confirmMatrixVerificationSas,
|
||||
generateMatrixVerificationQr,
|
||||
getMatrixEncryptionStatus,
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
getMatrixVerificationSas,
|
||||
listMatrixVerifications,
|
||||
mismatchMatrixVerificationSas,
|
||||
requestMatrixVerification,
|
||||
resetMatrixRoomKeyBackup,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./actions/verification.js";
|
||||
export { reactMatrixMessage } from "./send.js";
|
||||
247
extensions/matrix/src/matrix/actions/client.test.ts
Normal file
247
extensions/matrix/src/matrix/actions/client.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Matrix tests cover client plugin behavior.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockMatrixClient,
|
||||
expectExplicitMatrixClientConfig,
|
||||
expectOneOffSharedMatrixClient,
|
||||
matrixClientResolverMocks,
|
||||
primeMatrixClientResolverMocks,
|
||||
} from "../client-resolver.test-helpers.js";
|
||||
|
||||
const resolveMatrixRoomIdMock = vi.fn();
|
||||
|
||||
const {
|
||||
loadConfigMock,
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
getActiveMatrixClient: getActiveMatrixClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
acquireSharedMatrixClient: acquireSharedMatrixClientMock,
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client/shared.js", () => ({
|
||||
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
|
||||
}));
|
||||
|
||||
let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient;
|
||||
let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction;
|
||||
let withStartedActionClient: typeof import("./client.js").withStartedActionClient;
|
||||
|
||||
describe("action client helpers", () => {
|
||||
beforeAll(async () => {
|
||||
({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
|
||||
await import("./client.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
primeMatrixClientResolverMocks();
|
||||
resolveMatrixRoomIdMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (clientForTest, roomId: string) => roomId);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("stops one-off shared clients when no active monitor client is registered", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
await expectOneOffSharedMatrixClient();
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips one-off room preparation when readiness is disabled", async () => {
|
||||
await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default", readiness: "none" },
|
||||
async () => {},
|
||||
);
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(sharedClient.start).not.toHaveBeenCalled();
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("starts one-off clients when started readiness is required", async () => {
|
||||
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {});
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "persist");
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient["stop"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts active clients when started readiness is required", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
});
|
||||
|
||||
expect(activeClient["start"]).toHaveBeenCalledTimes(1);
|
||||
expect(activeClient["prepareForOneOff"]).not.toHaveBeenCalled();
|
||||
expect(activeClient["stop"]).not.toHaveBeenCalled();
|
||||
expect(activeClient["stopAndPersist"]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the implicit resolved account id for active client lookup and storage", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveMatrixAuthContextMock.mockReturnValue({
|
||||
cfg: loadConfigMock(),
|
||||
env: process.env,
|
||||
accountId: "ops",
|
||||
resolved: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
},
|
||||
});
|
||||
await withResolvedActionClient({ cfg: loadConfigMock() as never }, async () => {});
|
||||
|
||||
await expectOneOffSharedMatrixClient({
|
||||
cfg: loadConfigMock(),
|
||||
accountId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit cfg instead of loading runtime config", async () => {
|
||||
const explicitCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expectExplicitMatrixClientConfig({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("stops shared action clients after wrapped calls succeed", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("can discard read-only shared action clients without persisting crypto state", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
},
|
||||
"discard",
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "discard");
|
||||
});
|
||||
|
||||
it("stops shared action clients when the wrapped call throws", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("resolves room ids before running wrapped room actions", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
resolveMatrixRoomIdMock.mockResolvedValue("!room:example.org");
|
||||
|
||||
const result = await withResolvedRoomAction(
|
||||
"room:#ops:example.org",
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client, resolvedRoom) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return resolvedRoom;
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(sharedClient, "room:#ops:example.org");
|
||||
expect(result).toBe("!room:example.org");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
});
|
||||
32
extensions/matrix/src/matrix/actions/client.ts
Normal file
32
extensions/matrix/src/matrix/actions/client.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Matrix plugin module implements client behavior.
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
type MatrixActionClientStopMode = "stop" | "persist" | "discard";
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
return await withResolvedRuntimeMatrixClient(opts, run, mode);
|
||||
}
|
||||
|
||||
export async function withStartedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist");
|
||||
}
|
||||
|
||||
export async function withResolvedRoomAction<T>(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await run(client, resolvedRoom);
|
||||
});
|
||||
}
|
||||
221
extensions/matrix/src/matrix/actions/devices.test.ts
Normal file
221
extensions/matrix/src/matrix/actions/devices.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// Matrix tests cover devices plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const withResolvedActionClientMock = vi.fn();
|
||||
const withStartedActionClientMock = vi.fn();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
|
||||
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
|
||||
}));
|
||||
|
||||
const { getMatrixDeviceHealth, listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } =
|
||||
await import("./devices.js");
|
||||
|
||||
function expectResolvedActionClientCall(): void {
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
||||
const call = withResolvedActionClientMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected resolved action client call");
|
||||
}
|
||||
expect(call[0]).toEqual({ accountId: "poe" });
|
||||
expect(call[1]).toBeTypeOf("function");
|
||||
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("matrix device actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists own devices without starting a sync client", async () => {
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
listOwnDevices: vi.fn(async () => [
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await listMatrixOwnDevices({ accountId: "poe" });
|
||||
|
||||
expectResolvedActionClientCall();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes device health without starting a sync client", async () => {
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
listOwnDevices: vi.fn(async () => [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "old123",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await getMatrixDeviceHealth({ accountId: "poe" });
|
||||
|
||||
expect(result).toEqual({
|
||||
currentDeviceId: "du314Zpw3A",
|
||||
staleOpenClawDevices: [
|
||||
{
|
||||
deviceId: "old123",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
],
|
||||
currentOpenClawDevices: [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expectResolvedActionClientCall();
|
||||
});
|
||||
|
||||
it("prunes stale OpenClaw-managed devices but preserves the current device", async () => {
|
||||
const deleteOwnDevices = vi.fn(async () => ({
|
||||
currentDeviceId: "du314Zpw3A",
|
||||
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
|
||||
remainingDevices: [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
listOwnDevices: vi.fn(async () => [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "G6NJU9cTgs",
|
||||
displayName: "OpenClaw Debug",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "My3T0hkTE0",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "phone123",
|
||||
displayName: "Element iPhone",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
]),
|
||||
deleteOwnDevices,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await pruneMatrixStaleGatewayDevices({ accountId: "poe" });
|
||||
|
||||
expect(deleteOwnDevices).toHaveBeenCalledWith(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
|
||||
expect(result).toEqual({
|
||||
before: [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "G6NJU9cTgs",
|
||||
displayName: "OpenClaw Debug",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "My3T0hkTE0",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "phone123",
|
||||
displayName: "Element iPhone",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
],
|
||||
staleGatewayDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
|
||||
currentDeviceId: "du314Zpw3A",
|
||||
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
|
||||
remainingDevices: [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expectResolvedActionClientCall();
|
||||
});
|
||||
});
|
||||
35
extensions/matrix/src/matrix/actions/devices.ts
Normal file
35
extensions/matrix/src/matrix/actions/devices.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Matrix plugin module implements devices behavior.
|
||||
import { summarizeMatrixDeviceHealth } from "../device-health.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function listMatrixOwnDevices(opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(opts, async (client) => await client.listOwnDevices());
|
||||
}
|
||||
|
||||
export async function pruneMatrixStaleGatewayDevices(opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const devices = await client.listOwnDevices();
|
||||
const health = summarizeMatrixDeviceHealth(devices);
|
||||
const staleGatewayDeviceIds = health.staleOpenClawDevices.map((device) => device.deviceId);
|
||||
const deleted =
|
||||
staleGatewayDeviceIds.length > 0
|
||||
? await client.deleteOwnDevices(staleGatewayDeviceIds)
|
||||
: {
|
||||
currentDeviceId: devices.find((device) => device.current)?.deviceId ?? null,
|
||||
deletedDeviceIds: [] as string[],
|
||||
remainingDevices: devices,
|
||||
};
|
||||
return {
|
||||
before: devices,
|
||||
staleGatewayDeviceIds,
|
||||
...deleted,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixDeviceHealth(opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(opts, async (client) =>
|
||||
summarizeMatrixDeviceHealth(await client.listOwnDevices()),
|
||||
);
|
||||
}
|
||||
16
extensions/matrix/src/matrix/actions/limits.test.ts
Normal file
16
extensions/matrix/src/matrix/actions/limits.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Matrix tests cover limits plugin behavior.
|
||||
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
extensions/matrix/src/matrix/actions/limits.ts
Normal file
6
extensions/matrix/src/matrix/actions/limits.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Matrix plugin module implements limits behavior.
|
||||
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
|
||||
return resolveIntegerOption(raw, fallback, { min: 1 });
|
||||
}
|
||||
642
extensions/matrix/src/matrix/actions/messages.test.ts
Normal file
642
extensions/matrix/src/matrix/actions/messages.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
// Matrix tests cover messages plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import * as sendModule from "../send.js";
|
||||
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
|
||||
|
||||
const MATRIX_ACTION_TEST_CFG = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
function installMatrixActionTestRuntime(): void {
|
||||
setMatrixRuntime({
|
||||
config: {
|
||||
current: () => ({}),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
},
|
||||
},
|
||||
} as unknown as import("../../runtime-api.js").PluginRuntime);
|
||||
}
|
||||
|
||||
function createPollResponseEvent(): Record<string, unknown> {
|
||||
return {
|
||||
event_id: "$vote",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.poll.response",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
"m.poll.response": { answers: ["a1"] },
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPollStartEvent(params?: {
|
||||
answers?: Array<Record<string, unknown>>;
|
||||
includeDisclosedKind?: boolean;
|
||||
maxSelections?: number;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
...(params?.includeDisclosedKind ? { kind: "m.poll.disclosed" } : {}),
|
||||
...(params?.maxSelections !== undefined ? { max_selections: params.maxSelections } : {}),
|
||||
answers: params?.answers ?? [{ id: "a1", "m.text": "Apple" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMessagesClient(params: {
|
||||
chunk: Array<Record<string, unknown>>;
|
||||
hydratedChunk?: Array<Record<string, unknown>>;
|
||||
pollRoot?: Record<string, unknown>;
|
||||
pollRelations?: Array<Record<string, unknown>>;
|
||||
threadRelations?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const doRequest = vi.fn(async () => ({
|
||||
chunk: params.chunk,
|
||||
start: "start-token",
|
||||
end: "end-token",
|
||||
}));
|
||||
const hydrateEvents = vi.fn(
|
||||
async (_roomId: string, _events: Array<Record<string, unknown>>) =>
|
||||
(params.hydratedChunk ?? _events) as unknown,
|
||||
);
|
||||
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
|
||||
if (params.pollRoot?.event_id === eventId) {
|
||||
return params.pollRoot;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const getRelations = vi.fn(async (_roomId: string, _eventId: string, relType: string) => ({
|
||||
events:
|
||||
relType === "m.thread"
|
||||
? (params.threadRelations ?? params.pollRelations ?? [])
|
||||
: (params.pollRelations ?? []),
|
||||
nextBatch: null,
|
||||
prevBatch: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
client: {
|
||||
doRequest,
|
||||
hydrateEvents,
|
||||
getEvent,
|
||||
getRelations,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
doRequest,
|
||||
hydrateEvents,
|
||||
getEvent,
|
||||
getRelations,
|
||||
};
|
||||
}
|
||||
|
||||
function createEditClient(originalContent: Record<string, unknown>) {
|
||||
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
|
||||
const client = {
|
||||
getEvent: vi.fn().mockResolvedValue({ content: originalContent }),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||
sendMessage,
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
stopAndPersist: vi.fn(async () => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
return { client, sendMessage };
|
||||
}
|
||||
|
||||
function expectRecordFields(value: unknown, expected: Record<string, unknown>) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Expected record");
|
||||
}
|
||||
const actual = value as Record<string, unknown>;
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
expect(actual[key]).toEqual(expectedValue);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
function mockCallArg(
|
||||
mockFn: { mock: { calls: unknown[][] } },
|
||||
callIndex: number,
|
||||
argIndex: number,
|
||||
) {
|
||||
const call = mockFn.mock.calls.at(callIndex);
|
||||
if (!call) {
|
||||
throw new Error(`Expected mock call ${callIndex} to exist`);
|
||||
}
|
||||
if (!(argIndex in call)) {
|
||||
throw new Error(`Expected mock call ${callIndex} argument ${argIndex} to exist`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
describe("matrix message actions", () => {
|
||||
it("forwards timeoutMs to the shared Matrix edit helper", async () => {
|
||||
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
|
||||
|
||||
try {
|
||||
const cfg = {} as never;
|
||||
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
|
||||
cfg,
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
client: undefined,
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
} finally {
|
||||
editSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes edits through the shared Matrix edit helper so mentions are preserved", async () => {
|
||||
installMatrixActionTestRuntime();
|
||||
const { client, sendMessage } = createEditClient({
|
||||
body: "hello @alice:example.org",
|
||||
"m.mentions": { user_ids: ["@alice:example.org"] },
|
||||
});
|
||||
|
||||
const result = await editMatrixMessage(
|
||||
"!room:example.org",
|
||||
"$original",
|
||||
"hello @alice:example.org and @bob:example.org",
|
||||
{ cfg: MATRIX_ACTION_TEST_CFG, client },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
|
||||
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
|
||||
"m.mentions": { user_ids: ["@bob:example.org"] },
|
||||
});
|
||||
expectRecordFields(content["m.new_content"], {
|
||||
"m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-notify legacy mentions when action edits target pre-m.mentions messages", async () => {
|
||||
installMatrixActionTestRuntime();
|
||||
const { client, sendMessage } = createEditClient({
|
||||
body: "hello @alice:example.org",
|
||||
});
|
||||
|
||||
const result = await editMatrixMessage(
|
||||
"!room:example.org",
|
||||
"$original",
|
||||
"hello again @alice:example.org",
|
||||
{ cfg: MATRIX_ACTION_TEST_CFG, client },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ eventId: "evt-edit" });
|
||||
expect(mockCallArg(sendMessage, 0, 0)).toBe("!room:example.org");
|
||||
const content = expectRecordFields(mockCallArg(sendMessage, 0, 1), {
|
||||
"m.mentions": {},
|
||||
});
|
||||
expectRecordFields(content["m.new_content"], {
|
||||
body: "hello again @alice:example.org",
|
||||
"m.mentions": { user_ids: ["@alice:example.org"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes poll snapshots when reading message history", async () => {
|
||||
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [
|
||||
createPollResponseEvent(),
|
||||
{
|
||||
event_id: "$msg",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: createPollStartEvent({
|
||||
includeDisclosedKind: true,
|
||||
maxSelections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Apple" },
|
||||
{ id: "a2", "m.text": "Strawberry" },
|
||||
],
|
||||
}),
|
||||
pollRelations: [createPollResponseEvent()],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 });
|
||||
|
||||
expect(mockCallArg(doRequest, 0, 0)).toBe("GET");
|
||||
expect(String(mockCallArg(doRequest, 0, 1))).toContain("/rooms/!room%3Aexample.org/messages");
|
||||
expectRecordFields(mockCallArg(doRequest, 0, 2), { limit: 2 });
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$poll",
|
||||
"m.reference",
|
||||
undefined,
|
||||
{
|
||||
from: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expectRecordFields(result.messages[0], {
|
||||
eventId: "$poll",
|
||||
msgtype: "m.text",
|
||||
});
|
||||
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
|
||||
expectRecordFields(result.messages[1], {
|
||||
eventId: "$msg",
|
||||
body: "hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes multiple poll events for the same poll within one read page", async () => {
|
||||
const { client, getEvent } = createMessagesClient({
|
||||
chunk: [createPollResponseEvent(), createPollStartEvent()],
|
||||
pollRoot: createPollStartEvent(),
|
||||
pollRelations: [],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expectRecordFields(result.messages[0], { eventId: "$poll" });
|
||||
expect(result.messages[0]?.body).toContain("[Poll]");
|
||||
expect(getEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses hydrated history events so encrypted poll entries can be read", async () => {
|
||||
const { client, hydrateEvents } = createMessagesClient({
|
||||
chunk: [
|
||||
{
|
||||
event_id: "$enc",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.room.encrypted",
|
||||
origin_server_ts: 20,
|
||||
content: {},
|
||||
},
|
||||
],
|
||||
hydratedChunk: [createPollResponseEvent()],
|
||||
pollRoot: createPollStartEvent(),
|
||||
pollRelations: [],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(mockCallArg(hydrateEvents, 0, 0)).toBe("!room:example.org");
|
||||
expect(
|
||||
(mockCallArg(hydrateEvents, 0, 1) as Array<Record<string, unknown>>).some(
|
||||
(event) => event.event_id === "$enc",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]?.eventId).toBe("$poll");
|
||||
});
|
||||
|
||||
it("filters Matrix thread events out of main-room reads", async () => {
|
||||
const { client } = createMessagesClient({
|
||||
chunk: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "$main",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "main room",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual(["$main"]);
|
||||
});
|
||||
|
||||
it("filters threaded poll roots out of main-room reads", async () => {
|
||||
const threadedPollRoot = createPollStartEvent();
|
||||
const threadedPollContent = threadedPollRoot.content as Record<string, unknown>;
|
||||
threadedPollRoot.content = {
|
||||
...threadedPollContent,
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
|
||||
};
|
||||
const { client, getEvent } = createMessagesClient({
|
||||
chunk: [createPollResponseEvent()],
|
||||
pollRoot: threadedPollRoot,
|
||||
pollRelations: [createPollResponseEvent()],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the thread relations endpoint and includes the thread root once", async () => {
|
||||
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRelations: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$thread-root",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread root",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$thread-root",
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(doRequest).not.toHaveBeenCalled();
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$thread-root",
|
||||
"m.thread",
|
||||
undefined,
|
||||
{ dir: "b", from: undefined, limit: 4 },
|
||||
);
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual([
|
||||
"$thread-root",
|
||||
"$thread-reply",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes poll snapshots from threaded reads", async () => {
|
||||
const { client, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRoot: createPollStartEvent({
|
||||
includeDisclosedKind: true,
|
||||
maxSelections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Apple" },
|
||||
{ id: "a2", "m.text": "Strawberry" },
|
||||
],
|
||||
}),
|
||||
pollRelations: [createPollResponseEvent()],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$thread-root",
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$thread-root",
|
||||
"m.thread",
|
||||
undefined,
|
||||
{ dir: "b", from: undefined, limit: 5 },
|
||||
);
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
|
||||
});
|
||||
|
||||
it("includes poll roots when reading the thread they start", async () => {
|
||||
const { client, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRoot: createPollStartEvent({
|
||||
includeDisclosedKind: true,
|
||||
maxSelections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Apple" },
|
||||
{ id: "a2", "m.text": "Strawberry" },
|
||||
],
|
||||
}),
|
||||
pollRelations: [createPollResponseEvent()],
|
||||
threadRelations: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$poll" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$poll",
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$poll",
|
||||
"m.reference",
|
||||
undefined,
|
||||
{
|
||||
from: undefined,
|
||||
},
|
||||
);
|
||||
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$poll", "m.thread", undefined, {
|
||||
dir: "b",
|
||||
from: undefined,
|
||||
limit: 4,
|
||||
});
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual(["$poll", "$thread-reply"]);
|
||||
expect(result.messages[0]?.body).toContain("1. Apple (1 vote)");
|
||||
});
|
||||
|
||||
it("does not summarize non-start poll events as thread roots", async () => {
|
||||
const { client, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRoot: createPollResponseEvent(),
|
||||
threadRelations: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$vote" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$vote",
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(getRelations).toHaveBeenCalledWith("!room:example.org", "$vote", "m.thread", undefined, {
|
||||
dir: "b",
|
||||
from: undefined,
|
||||
limit: 5,
|
||||
});
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
|
||||
});
|
||||
|
||||
it("counts the thread root toward the requested first-page limit", async () => {
|
||||
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRelations: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$thread-root",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread root",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$thread-root",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$thread-root",
|
||||
"m.thread",
|
||||
undefined,
|
||||
{ dir: "b", from: undefined, limit: 1 },
|
||||
);
|
||||
expect(doRequest).not.toHaveBeenCalled();
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-root"]);
|
||||
expect(result.nextBatch).toEqual(
|
||||
expect.stringContaining("openclaw.matrix.thread-relations-start:"),
|
||||
);
|
||||
|
||||
const next = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$thread-root",
|
||||
limit: 1,
|
||||
before: result.nextBatch ?? undefined,
|
||||
});
|
||||
|
||||
expect(getRelations).toHaveBeenLastCalledWith(
|
||||
"!room:example.org",
|
||||
"$thread-root",
|
||||
"m.thread",
|
||||
undefined,
|
||||
{ dir: "b", from: undefined, limit: 1 },
|
||||
);
|
||||
expect(next.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
|
||||
});
|
||||
|
||||
it("does not reserve first-page thread capacity for a redacted root", async () => {
|
||||
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [],
|
||||
pollRelations: [
|
||||
{
|
||||
event_id: "$thread-reply",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "thread reply",
|
||||
"m.relates_to": { rel_type: "m.thread", event_id: "$thread-root" },
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$thread-root",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
unsigned: { redacted_because: {} },
|
||||
content: {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", {
|
||||
client,
|
||||
threadId: "$thread-root",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$thread-root",
|
||||
"m.thread",
|
||||
undefined,
|
||||
{ dir: "b", from: undefined, limit: 1 },
|
||||
);
|
||||
expect(doRequest).not.toHaveBeenCalled();
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$thread-root");
|
||||
expect(result.messages.map((message) => message.eventId)).toEqual(["$thread-reply"]);
|
||||
expect(result.nextBatch).toBeNull();
|
||||
});
|
||||
});
|
||||
261
extensions/matrix/src/matrix/actions/messages.ts
Normal file
261
extensions/matrix/src/matrix/actions/messages.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
|
||||
import { isPollEventType, isPollStartType } from "../poll-types.js";
|
||||
import { editMessageMatrix, sendMessageMatrix } from "../send.js";
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
} from "./types.js";
|
||||
|
||||
const MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX = "openclaw.matrix.thread-relations-start:";
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string | undefined,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
threadId?: string;
|
||||
audioAsVoice?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!opts.cfg) {
|
||||
throw new Error("Matrix message actions require a resolved runtime config.");
|
||||
}
|
||||
return await sendMessageMatrix(to, content, {
|
||||
cfg: opts.cfg,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
audioAsVoice: opts.audioAsVoice,
|
||||
accountId: opts.accountId ?? undefined,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
if (!opts.cfg) {
|
||||
throw new Error("Matrix message actions require a resolved runtime config.");
|
||||
}
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix edit requires content");
|
||||
}
|
||||
const eventId = await editMessageMatrix(roomId, messageId, trimmed, {
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId ?? undefined,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { eventId: eventId || null };
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
threadId?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
messages: MatrixMessageSummary[];
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 20);
|
||||
const rawBefore = normalizeOptionalString(opts.before);
|
||||
const rawAfter = normalizeOptionalString(opts.after);
|
||||
const dir = opts.after ? "f" : "b";
|
||||
const threadId = normalizeOptionalString(opts.threadId);
|
||||
const isThreadRelationsStartCursor = threadId
|
||||
? isMatrixThreadRelationsStartCursor(rawBefore, threadId)
|
||||
: false;
|
||||
const token = isThreadRelationsStartCursor ? undefined : (rawBefore ?? rawAfter);
|
||||
const includeThreadRoot = threadId !== undefined && !token && !isThreadRelationsStartCursor;
|
||||
const threadRootSummary =
|
||||
includeThreadRoot && threadId
|
||||
? await fetchDisplayableThreadRootSummary(client, resolvedRoom, threadId)
|
||||
: undefined;
|
||||
const rootCountsTowardLimit = threadRootSummary !== undefined;
|
||||
const rootFillsThreadPage = rootCountsTowardLimit && limit === 1;
|
||||
const relationLimit = rootCountsTowardLimit ? Math.max(limit - 1, 1) : limit;
|
||||
const seenPollRoots = new Set<string>();
|
||||
const threadRootEventId = normalizeOptionalString(threadRootSummary?.eventId);
|
||||
if (threadRootEventId) {
|
||||
seenPollRoots.add(threadRootEventId);
|
||||
}
|
||||
const relationPage =
|
||||
threadId && relationLimit > 0
|
||||
? await client.getRelations(resolvedRoom, threadId, "m.thread", undefined, {
|
||||
dir: dir as Direction,
|
||||
from: token,
|
||||
limit: relationLimit,
|
||||
})
|
||||
: null;
|
||||
// Flat room history uses the low-level endpoint for compatibility; threaded reads use
|
||||
// the SDK relations helper so encrypted rooms get the SDK's event-type translation.
|
||||
const flatPage = threadId
|
||||
? null
|
||||
: ((await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
{
|
||||
dir,
|
||||
limit,
|
||||
from: token,
|
||||
},
|
||||
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string });
|
||||
const hydratedChunk = await client.hydrateEvents(
|
||||
resolvedRoom,
|
||||
relationPage ? (rootFillsThreadPage ? [] : relationPage.events) : (flatPage?.chunk ?? []),
|
||||
);
|
||||
const messages: MatrixMessageSummary[] = [];
|
||||
if (threadRootSummary) {
|
||||
messages.push(threadRootSummary);
|
||||
}
|
||||
for (const event of hydratedChunk) {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
continue;
|
||||
}
|
||||
if (!threadId && isMatrixThreadEvent(event)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === EventType.RoomMessage) {
|
||||
if (threadId && event.event_id === threadId) {
|
||||
continue;
|
||||
}
|
||||
messages.push(summarizeMatrixRawEvent(event));
|
||||
continue;
|
||||
}
|
||||
if (!isPollEventType(event.type)) {
|
||||
continue;
|
||||
}
|
||||
const pollRootId = resolveMatrixPollRootEventId(event);
|
||||
if (!pollRootId || seenPollRoots.has(pollRootId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!threadId &&
|
||||
(await isMatrixPollRootThreaded({
|
||||
client,
|
||||
event,
|
||||
pollRootId,
|
||||
resolvedRoom,
|
||||
}))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
seenPollRoots.add(pollRootId);
|
||||
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
|
||||
if (pollSummary) {
|
||||
messages.push(pollSummary);
|
||||
}
|
||||
}
|
||||
const nextBatch =
|
||||
rootFillsThreadPage && threadId && relationPage?.events.length
|
||||
? encodeMatrixThreadRelationsStartCursor(threadId)
|
||||
: (relationPage?.nextBatch ?? flatPage?.end ?? null);
|
||||
return {
|
||||
messages,
|
||||
nextBatch,
|
||||
prevBatch: relationPage?.prevBatch ?? flatPage?.start ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function encodeMatrixThreadRelationsStartCursor(threadId: string): string {
|
||||
const payload = Buffer.from(JSON.stringify({ v: 1, threadId }), "utf8").toString("base64url");
|
||||
return `${MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX}${payload}`;
|
||||
}
|
||||
|
||||
function isMatrixThreadRelationsStartCursor(raw: string | undefined, threadId: string): boolean {
|
||||
if (!raw?.startsWith(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const encoded = raw.slice(MATRIX_THREAD_RELATIONS_START_CURSOR_PREFIX.length);
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as {
|
||||
v?: unknown;
|
||||
threadId?: unknown;
|
||||
};
|
||||
return decoded.v === 1 && decoded.threadId === threadId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDisplayableThreadRootSummary(
|
||||
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>,
|
||||
resolvedRoom: string,
|
||||
threadId: string,
|
||||
): Promise<MatrixMessageSummary | undefined> {
|
||||
const rawRootEvent = (await client
|
||||
.getEvent(resolvedRoom, threadId)
|
||||
.catch(() => null)) as MatrixRawEvent | null;
|
||||
if (!rawRootEvent) {
|
||||
return undefined;
|
||||
}
|
||||
const rootEvent = (await client.hydrateEvents(resolvedRoom, [rawRootEvent]))[0];
|
||||
if (!rootEvent || rootEvent.unsigned?.redacted_because) {
|
||||
return undefined;
|
||||
}
|
||||
if (rootEvent.type === EventType.RoomMessage) {
|
||||
return summarizeMatrixRawEvent(rootEvent);
|
||||
}
|
||||
if (isPollStartType(rootEvent.type)) {
|
||||
return (await fetchMatrixPollMessageSummary(client, resolvedRoom, rootEvent)) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isMatrixThreadEvent(event: MatrixRawEvent): boolean {
|
||||
const relates = event.content?.["m.relates_to"];
|
||||
if (!relates || typeof relates !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (relates as { rel_type?: unknown }).rel_type === "m.thread";
|
||||
}
|
||||
|
||||
async function isMatrixPollRootThreaded(params: {
|
||||
client: MatrixActionClientOpts["client"] & NonNullable<MatrixActionClientOpts["client"]>;
|
||||
event: MatrixRawEvent;
|
||||
pollRootId: string;
|
||||
resolvedRoom: string;
|
||||
}): Promise<boolean> {
|
||||
if (isMatrixThreadEvent(params.event)) {
|
||||
return true;
|
||||
}
|
||||
const rootEvent = (await params.client
|
||||
.getEvent(params.resolvedRoom, params.pollRootId)
|
||||
.catch(() => null)) as MatrixRawEvent | null;
|
||||
if (!rootEvent) {
|
||||
return false;
|
||||
}
|
||||
const hydratedRoot = (await params.client.hydrateEvents(params.resolvedRoom, [rootEvent]))[0];
|
||||
return hydratedRoot ? isMatrixThreadEvent(hydratedRoot) : false;
|
||||
}
|
||||
80
extensions/matrix/src/matrix/actions/pins.test.ts
Normal file
80
extensions/matrix/src/matrix/actions/pins.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Matrix tests cover pins plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
|
||||
|
||||
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
|
||||
let pinned = [...seedPinned];
|
||||
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
|
||||
const sendStateEvent = vi.fn(
|
||||
async (_roomId: string, _type: string, _key: string, payload: unknown) => {
|
||||
pinned = [...((payload as { pinned: string[] }).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([
|
||||
{
|
||||
attachment: undefined,
|
||||
body: "hello",
|
||||
eventId: "$a",
|
||||
msgtype: "m.text",
|
||||
relatesTo: undefined,
|
||||
sender: "@alice:example.org",
|
||||
timestamp: 123,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
64
extensions/matrix/src/matrix/actions/pins.ts
Normal file
64
extensions/matrix/src/matrix/actions/pins.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Matrix plugin module implements pins behavior.
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixMessageSummary,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
async function updateMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
update: (current: string[]) => string[],
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await withResolvedRoomAction(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, 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, opts, (current) =>
|
||||
current.filter((id) => id !== messageId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
return await withResolvedRoomAction(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 };
|
||||
});
|
||||
}
|
||||
73
extensions/matrix/src/matrix/actions/polls.test.ts
Normal file
73
extensions/matrix/src/matrix/actions/polls.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Matrix tests cover polls plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { voteMatrixPoll } from "./polls.js";
|
||||
|
||||
function createPollClient(pollContent?: Record<string, unknown>) {
|
||||
const getEvent = vi.fn(async () => ({
|
||||
type: "m.poll.start",
|
||||
content: pollContent ?? {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "apple", "m.text": "Apple" },
|
||||
{ id: "berry", "m.text": "Berry" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const sendEvent = vi.fn(async () => "$vote1");
|
||||
|
||||
return {
|
||||
client: {
|
||||
getEvent,
|
||||
sendEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getEvent,
|
||||
sendEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix poll actions", () => {
|
||||
it("votes by option index against the resolved room id", async () => {
|
||||
const { client, getEvent, sendEvent } = createPollClient();
|
||||
|
||||
const result = await voteMatrixPoll("room:!room:example.org", "$poll", {
|
||||
client,
|
||||
optionIndex: 2,
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(sendEvent).toHaveBeenCalledWith("!room:example.org", "m.poll.response", {
|
||||
"m.poll.response": { answers: ["berry"] },
|
||||
"m.relates_to": {
|
||||
event_id: "$poll",
|
||||
rel_type: "m.reference",
|
||||
},
|
||||
"org.matrix.msc3381.poll.response": { answers: ["berry"] },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
eventId: "$vote1",
|
||||
roomId: "!room:example.org",
|
||||
pollId: "$poll",
|
||||
answerIds: ["berry"],
|
||||
labels: ["Berry"],
|
||||
maxSelections: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects option indexes that are outside the poll range", async () => {
|
||||
const { client, sendEvent } = createPollClient();
|
||||
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example.org", "$poll", {
|
||||
client,
|
||||
optionIndex: 3,
|
||||
}),
|
||||
).rejects.toThrow("out of range");
|
||||
|
||||
expect(sendEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
111
extensions/matrix/src/matrix/actions/polls.ts
Normal file
111
extensions/matrix/src/matrix/actions/polls.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Matrix plugin module implements polls behavior.
|
||||
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
buildPollResponseContent,
|
||||
isPollStartType,
|
||||
parsePollStart,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
function normalizeOptionIndexes(indexes: number[]): number[] {
|
||||
const normalized = indexes
|
||||
.map((index) => Math.trunc(index))
|
||||
.filter((index) => Number.isFinite(index) && index > 0);
|
||||
return uniqueValues(normalized);
|
||||
}
|
||||
|
||||
function normalizeOptionIds(optionIds: string[]): string[] {
|
||||
return uniqueStrings(
|
||||
optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedAnswerIds(params: {
|
||||
optionIds?: string[];
|
||||
optionIndexes?: number[];
|
||||
pollContent: PollStartContent;
|
||||
}): { answerIds: string[]; labels: string[]; maxSelections: number } {
|
||||
const parsed = parsePollStart(params.pollContent);
|
||||
if (!parsed) {
|
||||
throw new Error("Matrix poll vote requires a valid poll start event.");
|
||||
}
|
||||
|
||||
const selectedById = normalizeOptionIds(params.optionIds ?? []);
|
||||
const selectedByIndex = normalizeOptionIndexes(params.optionIndexes ?? []).map((index) => {
|
||||
const answer = parsed.answers[index - 1];
|
||||
if (!answer) {
|
||||
throw new Error(
|
||||
`Matrix poll option index ${index} is out of range for a poll with ${parsed.answers.length} options.`,
|
||||
);
|
||||
}
|
||||
return answer.id;
|
||||
});
|
||||
|
||||
const answerIds = normalizeOptionIds([...selectedById, ...selectedByIndex]);
|
||||
if (answerIds.length === 0) {
|
||||
throw new Error("Matrix poll vote requires at least one poll option id or index.");
|
||||
}
|
||||
if (answerIds.length > parsed.maxSelections) {
|
||||
throw new Error(
|
||||
`Matrix poll allows at most ${parsed.maxSelections} selection${parsed.maxSelections === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const answerMap = new Map(parsed.answers.map((answer) => [answer.id, answer.text] as const));
|
||||
const labels = answerIds.map((answerId) => {
|
||||
const label = answerMap.get(answerId);
|
||||
if (!label) {
|
||||
throw new Error(
|
||||
`Matrix poll option id "${answerId}" is not valid for poll ${parsed.question}.`,
|
||||
);
|
||||
}
|
||||
return label;
|
||||
});
|
||||
|
||||
return {
|
||||
answerIds,
|
||||
labels,
|
||||
maxSelections: parsed.maxSelections,
|
||||
};
|
||||
}
|
||||
|
||||
export async function voteMatrixPoll(
|
||||
roomId: string,
|
||||
pollId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
optionId?: string;
|
||||
optionIds?: string[];
|
||||
optionIndex?: number;
|
||||
optionIndexes?: number[];
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const pollEvent = await client.getEvent(resolvedRoom, pollId);
|
||||
const eventType = typeof pollEvent.type === "string" ? pollEvent.type : "";
|
||||
if (!isPollStartType(eventType)) {
|
||||
throw new Error(`Event ${pollId} is not a Matrix poll start event.`);
|
||||
}
|
||||
|
||||
const { answerIds, labels, maxSelections } = resolveSelectedAnswerIds({
|
||||
optionIds: [...(opts.optionIds ?? []), ...(opts.optionId ? [opts.optionId] : [])],
|
||||
optionIndexes: [
|
||||
...(opts.optionIndexes ?? []),
|
||||
...(opts.optionIndex !== undefined ? [opts.optionIndex] : []),
|
||||
],
|
||||
pollContent: pollEvent.content as PollStartContent,
|
||||
});
|
||||
|
||||
const content = buildPollResponseContent(pollId, answerIds);
|
||||
const eventId = await client.sendEvent(resolvedRoom, "m.poll.response", content);
|
||||
return {
|
||||
eventId: eventId ?? null,
|
||||
roomId: resolvedRoom,
|
||||
pollId,
|
||||
answerIds,
|
||||
labels,
|
||||
maxSelections,
|
||||
};
|
||||
});
|
||||
}
|
||||
149
extensions/matrix/src/matrix/actions/profile.test.ts
Normal file
149
extensions/matrix/src/matrix/actions/profile.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Matrix tests cover profile plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadWebMediaMock = vi.fn();
|
||||
const syncMatrixOwnProfileMock = vi.fn();
|
||||
const withResolvedActionClientMock = vi.fn();
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
media: {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../profile.js", () => ({
|
||||
syncMatrixOwnProfile: (...args: unknown[]) => syncMatrixOwnProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
|
||||
}));
|
||||
|
||||
const { updateMatrixOwnProfile } = await import("./profile.js");
|
||||
|
||||
function mockCallAt(
|
||||
mock: { mock: { calls: Array<readonly unknown[]> } },
|
||||
index: number,
|
||||
label: string,
|
||||
): readonly unknown[] {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function firstMockArg(mock: { mock: { calls: Array<readonly unknown[]> } }, label: string) {
|
||||
return mockCallAt(mock, 0, label)[0];
|
||||
}
|
||||
|
||||
describe("matrix profile actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("avatar"),
|
||||
contentType: "image/png",
|
||||
fileName: "avatar.png",
|
||||
});
|
||||
syncMatrixOwnProfileMock.mockResolvedValue({
|
||||
skipped: false,
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: true,
|
||||
resolvedAvatarUrl: "mxc://example/avatar",
|
||||
convertedAvatarFromHttp: true,
|
||||
uploadedAvatarSource: "http",
|
||||
});
|
||||
});
|
||||
|
||||
it("trims profile fields and persists through the action client wrapper", async () => {
|
||||
const actionClient = {
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
};
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run(actionClient);
|
||||
});
|
||||
|
||||
await updateMatrixOwnProfile({
|
||||
accountId: "ops",
|
||||
displayName: " Ops Bot ",
|
||||
avatarUrl: " mxc://example/avatar ",
|
||||
avatarPath: " /tmp/avatar.png ",
|
||||
});
|
||||
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
||||
const [wrapperOpts, run, mode] = mockCallAt(
|
||||
withResolvedActionClientMock,
|
||||
0,
|
||||
"Matrix action client wrapper",
|
||||
);
|
||||
expect(wrapperOpts).toEqual({
|
||||
accountId: "ops",
|
||||
displayName: " Ops Bot ",
|
||||
avatarUrl: " mxc://example/avatar ",
|
||||
avatarPath: " /tmp/avatar.png ",
|
||||
});
|
||||
expect(typeof run).toBe("function");
|
||||
expect(mode).toBe("persist");
|
||||
|
||||
expect(syncMatrixOwnProfileMock).toHaveBeenCalledTimes(1);
|
||||
const syncCall = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") as
|
||||
| {
|
||||
client: unknown;
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
avatarPath: string;
|
||||
loadAvatarFromUrl: unknown;
|
||||
loadAvatarFromPath: unknown;
|
||||
}
|
||||
| undefined;
|
||||
if (!syncCall) {
|
||||
throw new Error("syncMatrixOwnProfile was not called");
|
||||
}
|
||||
const { client, loadAvatarFromUrl, loadAvatarFromPath, ...profileFields } = syncCall;
|
||||
expect(client).toBe(actionClient);
|
||||
expect(typeof loadAvatarFromUrl).toBe("function");
|
||||
expect(typeof loadAvatarFromPath).toBe("function");
|
||||
expect(profileFields).toEqual({
|
||||
userId: "@bot:example.org",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
avatarPath: "/tmp/avatar.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("bridges avatar loaders through Matrix runtime media helpers", async () => {
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
});
|
||||
});
|
||||
|
||||
await updateMatrixOwnProfile({
|
||||
avatarUrl: "https://cdn.example.org/avatar.png",
|
||||
avatarPath: "/tmp/avatar.png",
|
||||
});
|
||||
|
||||
const call = firstMockArg(syncMatrixOwnProfileMock, "Matrix profile sync") as
|
||||
| {
|
||||
loadAvatarFromUrl: (url: string, maxBytes: number) => Promise<unknown>;
|
||||
loadAvatarFromPath: (path: string, maxBytes: number) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!call) {
|
||||
throw new Error("syncMatrixOwnProfile was not called");
|
||||
}
|
||||
|
||||
await call.loadAvatarFromUrl("https://cdn.example.org/avatar.png", 123);
|
||||
await call.loadAvatarFromPath("/tmp/avatar.png", 456);
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenNthCalledWith(1, "https://cdn.example.org/avatar.png", 123);
|
||||
expect(loadWebMediaMock).toHaveBeenNthCalledWith(2, "/tmp/avatar.png", {
|
||||
maxBytes: 456,
|
||||
localRoots: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
38
extensions/matrix/src/matrix/actions/profile.ts
Normal file
38
extensions/matrix/src/matrix/actions/profile.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Matrix plugin module implements profile behavior.
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function updateMatrixOwnProfile(
|
||||
opts: MatrixActionClientOpts & {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
avatarPath?: string;
|
||||
} = {},
|
||||
): Promise<MatrixProfileSyncResult> {
|
||||
const displayName = opts.displayName?.trim();
|
||||
const avatarUrl = opts.avatarUrl?.trim();
|
||||
const avatarPath = opts.avatarPath?.trim();
|
||||
const runtime = getMatrixRuntime();
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const userId = await client.getUserId();
|
||||
return await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId,
|
||||
displayName: displayName || undefined,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
avatarPath: avatarPath || undefined,
|
||||
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
|
||||
loadAvatarFromPath: async (path, maxBytes) =>
|
||||
await runtime.media.loadWebMedia(path, {
|
||||
maxBytes,
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
}),
|
||||
});
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
134
extensions/matrix/src/matrix/actions/reactions.test.ts
Normal file
134
extensions/matrix/src/matrix/actions/reactions.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// Matrix tests cover reactions plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
|
||||
|
||||
function createReactionsClient(params: {
|
||||
chunk: Array<{
|
||||
event_id?: string;
|
||||
sender?: string;
|
||||
key?: string;
|
||||
}>;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const doRequest = vi.fn(async (_method: string, _path: string, _query: unknown) => ({
|
||||
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",
|
||||
"/_matrix/client/v1/rooms/!room%3Aexample.org/relations/%24msg/m.annotation/m.reaction",
|
||||
{ dir: "b", limit: 2 },
|
||||
);
|
||||
expect(result).toStrictEqual([
|
||||
{
|
||||
key: "👍",
|
||||
count: 2,
|
||||
users: ["@alice:example.org", "@bob:example.org"],
|
||||
},
|
||||
{
|
||||
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();
|
||||
});
|
||||
|
||||
it("returns an empty list when the relations response is malformed", async () => {
|
||||
const doRequest = vi.fn(async () => ({ chunk: null }));
|
||||
const client = {
|
||||
doRequest,
|
||||
getUserId: vi.fn(async () => "@me:example.org"),
|
||||
redactEvent: vi.fn(async () => undefined),
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const result = await listMatrixReactions("!room:example.org", "$msg", { client });
|
||||
|
||||
expect(result).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("rejects blank message ids before querying Matrix relations", async () => {
|
||||
const { client, doRequest } = createReactionsClient({
|
||||
chunk: [],
|
||||
userId: "@me:example.org",
|
||||
});
|
||||
|
||||
await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow(
|
||||
"messageId",
|
||||
);
|
||||
expect(doRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
56
extensions/matrix/src/matrix/actions/reactions.ts
Normal file
56
extensions/matrix/src/matrix/actions/reactions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Matrix plugin module implements reactions behavior.
|
||||
import {
|
||||
buildMatrixReactionRelationsPath,
|
||||
selectOwnMatrixReactionEventIds,
|
||||
summarizeMatrixReactionEvents,
|
||||
} from "../reaction-common.js";
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import type { MatrixActionClientOpts, MatrixRawEvent, MatrixReactionSummary } from "./types.js";
|
||||
|
||||
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
|
||||
|
||||
async function listMatrixReactionEvents(
|
||||
client: ActionClient,
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
limit: number,
|
||||
): Promise<MatrixRawEvent[]> {
|
||||
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
|
||||
dir: "b",
|
||||
limit,
|
||||
})) as { chunk?: MatrixRawEvent[] };
|
||||
return Array.isArray(res.chunk) ? res.chunk : [];
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 100);
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
return summarizeMatrixReactionEvents(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji);
|
||||
if (toRemove.length === 0) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||
return { removed: toRemove.length };
|
||||
});
|
||||
}
|
||||
80
extensions/matrix/src/matrix/actions/room.test.ts
Normal file
80
extensions/matrix/src/matrix/actions/room.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Matrix tests cover room plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixMemberInfo, getMatrixRoomInfo } from "./room.js";
|
||||
|
||||
function createRoomClient() {
|
||||
const getRoomStateEvent = vi.fn(async (_roomId: string, eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "m.room.name":
|
||||
return { name: "Ops Room" };
|
||||
case "m.room.topic":
|
||||
return { topic: "Incidents" };
|
||||
case "m.room.canonical_alias":
|
||||
return { alias: "#ops:example.org" };
|
||||
default:
|
||||
throw new Error(`unexpected state event ${eventType}`);
|
||||
}
|
||||
});
|
||||
const getJoinedRoomMembers = vi.fn(async () => [
|
||||
{ user_id: "@alice:example.org" },
|
||||
{ user_id: "@bot:example.org" },
|
||||
]);
|
||||
const getUserProfile = vi.fn(async () => ({
|
||||
displayname: "Alice",
|
||||
avatar_url: "mxc://example.org/alice",
|
||||
}));
|
||||
|
||||
return {
|
||||
client: {
|
||||
getRoomStateEvent,
|
||||
getJoinedRoomMembers,
|
||||
getUserProfile,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getRoomStateEvent,
|
||||
getJoinedRoomMembers,
|
||||
getUserProfile,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix room actions", () => {
|
||||
it("returns room details from the resolved Matrix room id", async () => {
|
||||
const { client, getJoinedRoomMembers, getRoomStateEvent } = createRoomClient();
|
||||
|
||||
const result = await getMatrixRoomInfo("room:!ops:example.org", { client });
|
||||
|
||||
expect(getRoomStateEvent).toHaveBeenCalledWith("!ops:example.org", "m.room.name", "");
|
||||
expect(getJoinedRoomMembers).toHaveBeenCalledWith("!ops:example.org");
|
||||
expect(result).toEqual({
|
||||
roomId: "!ops:example.org",
|
||||
name: "Ops Room",
|
||||
topic: "Incidents",
|
||||
canonicalAlias: "#ops:example.org",
|
||||
altAliases: [],
|
||||
memberCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves optional room ids when looking up member info", async () => {
|
||||
const { client, getUserProfile } = createRoomClient();
|
||||
|
||||
const result = await getMatrixMemberInfo("@alice:example.org", {
|
||||
client,
|
||||
roomId: "room:!ops:example.org",
|
||||
});
|
||||
|
||||
expect(getUserProfile).toHaveBeenCalledWith("@alice:example.org");
|
||||
expect(result).toEqual({
|
||||
userId: "@alice:example.org",
|
||||
profile: {
|
||||
displayName: "Alice",
|
||||
avatarUrl: "mxc://example.org/alice",
|
||||
},
|
||||
membership: null,
|
||||
powerLevel: null,
|
||||
displayName: "Alice",
|
||||
roomId: "!ops:example.org",
|
||||
});
|
||||
});
|
||||
});
|
||||
72
extensions/matrix/src/matrix/actions/room.ts
Normal file
72
extensions/matrix/src/matrix/actions/room.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Matrix plugin module implements room behavior.
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
|
||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Membership and power levels are not included in profile calls; fetch state separately if needed.
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
displayName: profile?.displayname ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
membership: null, // Would need separate room state query
|
||||
powerLevel: null, // Would need separate power levels state query
|
||||
displayName: profile?.displayname ?? null,
|
||||
roomId: roomId ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
let memberCount: number | null = null;
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = typeof nameState?.name === "string" ? nameState.name : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await client.getJoinedRoomMembers(resolvedRoom);
|
||||
memberCount = members.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: resolvedRoom,
|
||||
name,
|
||||
topic,
|
||||
canonicalAlias,
|
||||
altAliases: [], // Would need separate query
|
||||
memberCount,
|
||||
};
|
||||
});
|
||||
}
|
||||
101
extensions/matrix/src/matrix/actions/summary.test.ts
Normal file
101
extensions/matrix/src/matrix/actions/summary.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Matrix tests cover summary plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
|
||||
describe("summarizeMatrixRawEvent", () => {
|
||||
it("replaces bare media filenames with a media marker", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "photo.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
eventId: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
body: undefined,
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
timestamp: 123,
|
||||
relatesTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves captions while marking media summaries", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "can you see this?",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
eventId: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
body: "can you see this?",
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "can you see this?",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
timestamp: 123,
|
||||
relatesTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a sentence ending in a file extension as a bare filename", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "see image.png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toEqual({
|
||||
eventId: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
body: "see image.png",
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "see image.png",
|
||||
},
|
||||
timestamp: 123,
|
||||
relatesTo: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves text messages unchanged", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$text",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.body).toBe("hello");
|
||||
expect(summary.attachment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
89
extensions/matrix/src/matrix/actions/summary.ts
Normal file
89
extensions/matrix/src/matrix/actions/summary.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Matrix plugin module implements summary behavior.
|
||||
import { isMatrixNotFoundError } from "../errors.js";
|
||||
import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js";
|
||||
import { fetchMatrixPollMessageSummary } from "../poll-summary.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
type MatrixRawEvent,
|
||||
type RoomMessageEventContent,
|
||||
type RoomPinnedEventsEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
|
||||
const content = event.content as RoomMessageEventContent;
|
||||
const relates = content["m.relates_to"];
|
||||
let relType: string | undefined;
|
||||
let eventId: string | undefined;
|
||||
if (relates) {
|
||||
if ("rel_type" in relates) {
|
||||
relType = relates.rel_type;
|
||||
eventId = relates.event_id;
|
||||
} else if ("m.in_reply_to" in relates) {
|
||||
eventId = relates["m.in_reply_to"]?.event_id;
|
||||
}
|
||||
}
|
||||
const relatesTo =
|
||||
relType || eventId
|
||||
? {
|
||||
relType,
|
||||
eventId,
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
eventId: event.event_id,
|
||||
sender: event.sender,
|
||||
body: resolveMatrixMessageBody({
|
||||
body: content.body,
|
||||
filename: content.filename,
|
||||
msgtype: content.msgtype,
|
||||
}),
|
||||
msgtype: content.msgtype,
|
||||
attachment: resolveMatrixMessageAttachment({
|
||||
body: content.body,
|
||||
filename: content.filename,
|
||||
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) {
|
||||
if (isMatrixNotFoundError(err)) {
|
||||
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;
|
||||
}
|
||||
const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw);
|
||||
if (pollSummary) {
|
||||
return pollSummary;
|
||||
}
|
||||
return summarizeMatrixRawEvent(raw);
|
||||
} catch {
|
||||
// Event not found, redacted, or inaccessible - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
64
extensions/matrix/src/matrix/actions/types.ts
Normal file
64
extensions/matrix/src/matrix/actions/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// Matrix type declarations define plugin contracts.
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
|
||||
import type { MatrixClient, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
export type { MatrixReactionSummary } from "../reaction-common.js";
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomPinnedEvents: "m.room.pinned_events",
|
||||
RoomTopic: "m.room.topic",
|
||||
Reaction: MATRIX_REACTION_EVENT_TYPE,
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
"m.relates_to"?: {
|
||||
rel_type?: string;
|
||||
event_id?: string;
|
||||
"m.in_reply_to"?: { event_id?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type RoomPinnedEventsEventContent = {
|
||||
pinned: string[];
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: "none" | "prepared" | "started";
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
eventId?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixMessageAttachmentKind = "audio" | "file" | "image" | "sticker" | "video";
|
||||
|
||||
export type MatrixMessageAttachmentSummary = {
|
||||
kind: MatrixMessageAttachmentKind;
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
1096
extensions/matrix/src/matrix/actions/verification.test.ts
Normal file
1096
extensions/matrix/src/matrix/actions/verification.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
590
extensions/matrix/src/matrix/actions/verification.ts
Normal file
590
extensions/matrix/src/matrix/actions/verification.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
// Matrix plugin module implements verification behavior.
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
|
||||
import type { MatrixDeviceVerificationStatus, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
|
||||
import type { MatrixVerificationSummary } from "../sdk/verification-manager.js";
|
||||
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
const DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
type MatrixCryptoActionFacade = NonNullable<import("../sdk.js").MatrixClient["crypto"]>;
|
||||
type MatrixActionClient = import("../sdk.js").MatrixClient;
|
||||
type MatrixVerificationDmLookupOpts = {
|
||||
verificationDmRoomId?: string;
|
||||
verificationDmUserId?: string;
|
||||
};
|
||||
|
||||
export type MatrixSelfVerificationResult = MatrixVerificationSummary & {
|
||||
deviceOwnerVerified: boolean;
|
||||
ownerVerification: MatrixOwnDeviceVerificationStatus;
|
||||
};
|
||||
|
||||
function requireCrypto(
|
||||
client: import("../sdk.js").MatrixClient,
|
||||
opts: MatrixActionClientOpts,
|
||||
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
|
||||
if (!client.crypto) {
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Matrix verification actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix verification actions") as CoreConfig;
|
||||
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
|
||||
}
|
||||
return client.crypto;
|
||||
}
|
||||
|
||||
function resolveVerificationId(input: string): string {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("Matrix verification request id is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function ensureMatrixVerificationDmTracked(
|
||||
crypto: MatrixCryptoActionFacade,
|
||||
opts: MatrixVerificationDmLookupOpts,
|
||||
): Promise<void> {
|
||||
const roomId = normalizeOptionalString(opts.verificationDmRoomId);
|
||||
const userId = normalizeOptionalString(opts.verificationDmUserId);
|
||||
if (Boolean(roomId) !== Boolean(userId)) {
|
||||
throw new Error("--user-id and --room-id must be provided together for Matrix DM verification");
|
||||
}
|
||||
if (!roomId || !userId) {
|
||||
return;
|
||||
}
|
||||
const tracked = await crypto.ensureVerificationDmTracked({ roomId, userId });
|
||||
if (!tracked) {
|
||||
throw new Error(
|
||||
`Matrix DM verification request not found for room ${roomId} and user ${userId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isSameMatrixVerification(
|
||||
left: MatrixVerificationSummary,
|
||||
right: MatrixVerificationSummary,
|
||||
): boolean {
|
||||
return (
|
||||
left.id === right.id ||
|
||||
Boolean(left.transactionId && left.transactionId === right.transactionId)
|
||||
);
|
||||
}
|
||||
|
||||
function isMatrixVerificationReadyForSas(summary: MatrixVerificationSummary): boolean {
|
||||
return (
|
||||
summary.completed ||
|
||||
summary.hasSas ||
|
||||
summary.phaseName === "ready" ||
|
||||
summary.phaseName === "started"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldStartMatrixSasVerification(summary: MatrixVerificationSummary): boolean {
|
||||
return !summary.hasSas && summary.phaseName !== "started" && !summary.completed;
|
||||
}
|
||||
|
||||
function isMatrixVerificationCancelled(summary: MatrixVerificationSummary): boolean {
|
||||
return summary.phaseName === "cancelled";
|
||||
}
|
||||
|
||||
function isMatrixSasMethod(method: string | null | undefined): boolean {
|
||||
return method === "m.sas.v1" || method === "sas";
|
||||
}
|
||||
|
||||
function getMatrixVerificationSasWaitFailure(
|
||||
summary: MatrixVerificationSummary,
|
||||
label: string,
|
||||
): string | null {
|
||||
if (summary.hasSas || summary.phaseName === "cancelled") {
|
||||
return null;
|
||||
}
|
||||
const method = summary.chosenMethod ? ` (method: ${summary.chosenMethod})` : "";
|
||||
if (summary.completed) {
|
||||
return `Matrix self-verification completed without SAS while waiting to ${label}${method}`;
|
||||
}
|
||||
if (
|
||||
summary.phaseName === "started" &&
|
||||
summary.chosenMethod &&
|
||||
!isMatrixSasMethod(summary.chosenMethod)
|
||||
) {
|
||||
return `Matrix self-verification started without SAS while waiting to ${label}${method}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForMatrixVerificationSummary(params: {
|
||||
crypto: MatrixCryptoActionFacade;
|
||||
label: string;
|
||||
request: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
predicate: (summary: MatrixVerificationSummary) => boolean;
|
||||
reject?: (summary: MatrixVerificationSummary) => string | null;
|
||||
}): Promise<MatrixVerificationSummary> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixVerificationSummary | undefined;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const summaries = await params.crypto.listVerifications();
|
||||
const found = summaries.find((summary) => isSameMatrixVerification(summary, params.request));
|
||||
if (found) {
|
||||
last = found;
|
||||
if (params.predicate(found)) {
|
||||
return found;
|
||||
}
|
||||
if (isMatrixVerificationCancelled(found)) {
|
||||
throw new Error(
|
||||
`Matrix self-verification was cancelled${
|
||||
found.error ? `: ${found.error}` : ` while waiting to ${params.label}`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const rejection = params.reject?.(found);
|
||||
if (rejection) {
|
||||
throw new Error(rejection);
|
||||
}
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Matrix self-verification to ${params.label}${
|
||||
last ? ` (last phase: ${last.phaseName})` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatMatrixOwnerVerificationDiagnostics(
|
||||
status: MatrixDeviceVerificationStatus | MatrixOwnDeviceVerificationStatus | undefined,
|
||||
): string {
|
||||
if (!status) {
|
||||
return "Matrix identity trust status was unavailable";
|
||||
}
|
||||
return `cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}, signed by owner: ${
|
||||
status.signedByOwner ? "yes" : "no"
|
||||
}, locally trusted: ${status.localVerified ? "yes" : "no"}`;
|
||||
}
|
||||
|
||||
async function waitForMatrixSelfVerificationTrustStatus(params: {
|
||||
client: MatrixActionClient;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixOwnDeviceVerificationStatus> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixOwnDeviceVerificationStatus | undefined;
|
||||
let crossSigningPublished = false;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const [status, crossSigning] = await Promise.all([
|
||||
params.client.getOwnDeviceVerificationStatus(),
|
||||
params.client.getOwnCrossSigningPublicationStatus(),
|
||||
]);
|
||||
last = status;
|
||||
crossSigningPublished = crossSigning.published;
|
||||
if (status.verified && crossSigningPublished) {
|
||||
return status;
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for Matrix self-verification to establish full Matrix identity trust for this device (${formatMatrixOwnerVerificationDiagnostics(
|
||||
last,
|
||||
)}, cross-signing keys published: ${crossSigningPublished ? "yes" : "no"}). Complete self-verification from another Matrix client, then check Matrix verification status for details.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cancelMatrixSelfVerificationOnFailure(params: {
|
||||
crypto: MatrixCryptoActionFacade;
|
||||
request: MatrixVerificationSummary | undefined;
|
||||
}): Promise<void> {
|
||||
if (!params.request || typeof params.crypto.cancelVerification !== "function") {
|
||||
return;
|
||||
}
|
||||
await params.crypto
|
||||
.cancelVerification(params.request.id, {
|
||||
reason: "OpenClaw self-verification did not complete",
|
||||
code: "m.user",
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function completeMatrixSelfVerification(params: {
|
||||
client: MatrixActionClient;
|
||||
completed: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixSelfVerificationResult> {
|
||||
const initial = await Promise.all([
|
||||
params.client.getOwnDeviceVerificationStatus(),
|
||||
params.client.getOwnCrossSigningPublicationStatus(),
|
||||
]);
|
||||
let ownerVerification = initial[0];
|
||||
if (!ownerVerification.verified || !initial[1].published) {
|
||||
if (!ownerVerification.verified) {
|
||||
await params.client.trustOwnIdentityAfterSelfVerification?.();
|
||||
}
|
||||
ownerVerification = await waitForMatrixSelfVerificationTrustStatus({
|
||||
client: params.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...params.completed,
|
||||
deviceOwnerVerified: ownerVerification.verified,
|
||||
ownerVerification,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.listVerifications();
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestMatrixVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(params, async (client) => {
|
||||
const crypto = requireCrypto(client, params);
|
||||
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
|
||||
return await crypto.requestVerification({
|
||||
ownUser,
|
||||
userId: normalizeOptionalString(params.userId),
|
||||
deviceId: normalizeOptionalString(params.deviceId),
|
||||
roomId: normalizeOptionalString(params.roomId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMatrixSelfVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
confirmSas: (
|
||||
sas: NonNullable<MatrixVerificationSummary["sas"]>,
|
||||
summary: MatrixVerificationSummary,
|
||||
) => Promise<boolean>;
|
||||
onReady?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
onRequested?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
onSas?: (summary: MatrixVerificationSummary) => void | Promise<void>;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<MatrixSelfVerificationResult> {
|
||||
return await withStartedActionClient(params, async (client) => {
|
||||
const crypto = requireCrypto(client, params);
|
||||
const timeoutMs = params.timeoutMs ?? DEFAULT_MATRIX_SELF_VERIFICATION_TIMEOUT_MS;
|
||||
let requested: MatrixVerificationSummary | undefined;
|
||||
let requestCompleted = false;
|
||||
let handledByMismatch = false;
|
||||
try {
|
||||
requested = await crypto.requestVerification({ ownUser: true });
|
||||
await params.onRequested?.(requested);
|
||||
|
||||
const ready = isMatrixVerificationReadyForSas(requested)
|
||||
? requested
|
||||
: await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "be accepted in another Matrix client",
|
||||
request: requested,
|
||||
timeoutMs,
|
||||
predicate: isMatrixVerificationReadyForSas,
|
||||
});
|
||||
await params.onReady?.(ready);
|
||||
|
||||
if (ready.completed) {
|
||||
requestCompleted = true;
|
||||
return await completeMatrixSelfVerification({ client, completed: ready, timeoutMs });
|
||||
}
|
||||
|
||||
const started = shouldStartMatrixSasVerification(ready)
|
||||
? await crypto.startVerification(ready.id, "sas")
|
||||
: ready;
|
||||
let sasSummary = started;
|
||||
if (!sasSummary.hasSas) {
|
||||
const sasFailure = getMatrixVerificationSasWaitFailure(
|
||||
sasSummary,
|
||||
"show SAS emoji or decimals",
|
||||
);
|
||||
if (sasFailure) {
|
||||
throw new Error(sasFailure);
|
||||
}
|
||||
sasSummary = await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "show SAS emoji or decimals",
|
||||
request: started,
|
||||
timeoutMs,
|
||||
predicate: (summary) => summary.hasSas,
|
||||
reject: (summary) =>
|
||||
getMatrixVerificationSasWaitFailure(summary, "show SAS emoji or decimals"),
|
||||
});
|
||||
}
|
||||
if (!sasSummary.sas) {
|
||||
throw new Error("Matrix SAS data is not available for this verification request");
|
||||
}
|
||||
await params.onSas?.(sasSummary);
|
||||
|
||||
const matched = await params.confirmSas(sasSummary.sas, sasSummary);
|
||||
if (!matched) {
|
||||
await crypto.mismatchVerificationSas(sasSummary.id);
|
||||
handledByMismatch = true;
|
||||
throw new Error("Matrix SAS verification was not confirmed.");
|
||||
}
|
||||
|
||||
const confirmed = await crypto.confirmVerificationSas(sasSummary.id);
|
||||
const completed = confirmed.completed
|
||||
? confirmed
|
||||
: await waitForMatrixVerificationSummary({
|
||||
crypto,
|
||||
label: "complete",
|
||||
request: confirmed,
|
||||
timeoutMs,
|
||||
predicate: (summary) => summary.completed,
|
||||
});
|
||||
requestCompleted = true;
|
||||
return await completeMatrixSelfVerification({ client, completed, timeoutMs });
|
||||
} catch (error) {
|
||||
if (!requestCompleted && !handledByMismatch) {
|
||||
await cancelMatrixSelfVerificationOnFailure({ crypto, request: requested });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.acceptVerification(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts &
|
||||
MatrixVerificationDmLookupOpts & { reason?: string; code?: string } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.cancelVerification(resolveVerificationId(requestId), {
|
||||
reason: normalizeOptionalString(opts.reason),
|
||||
code: normalizeOptionalString(opts.code),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts & { method?: "sas" } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMatrixVerificationQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanMatrixVerificationQr(
|
||||
requestId: string,
|
||||
qrDataBase64: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
const payload = qrDataBase64.trim();
|
||||
if (!payload) {
|
||||
throw new Error("Matrix QR data is required");
|
||||
}
|
||||
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.getVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
const summary = await crypto.confirmVerificationSas(resolveVerificationId(requestId));
|
||||
// For self-verifications, mirror the trust-own-identity step that the
|
||||
// higher-level runMatrixSelfVerification path already performs at
|
||||
// completeMatrixSelfVerification: cross-sign the operator's master key
|
||||
// from the bot side so Element X clears the "Verify" prompt without
|
||||
// waiting for a passive sync tick. Non-self verifications are a no-op.
|
||||
if (summary.isSelfVerification && summary.completed && !summary.error) {
|
||||
await client.trustOwnIdentityAfterSelfVerification?.();
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
export async function mismatchMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationReciprocateQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & MatrixVerificationDmLookupOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
await ensureMatrixVerificationDmTracked(crypto, opts);
|
||||
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixEncryptionStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
const recoveryKey = await crypto.getRecoveryKey();
|
||||
return {
|
||||
encryptionEnabled: true,
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
|
||||
pendingVerifications: (await crypto.listVerifications()).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
const readiness = opts.readiness ?? "prepared";
|
||||
return await withResolvedActionClient(
|
||||
{ ...opts, readiness: "none" },
|
||||
async (client) => {
|
||||
const preflight = await readMatrixVerificationStatus(client, opts);
|
||||
if (readiness === "none" || preflight.serverDeviceKnown === false) {
|
||||
return preflight;
|
||||
}
|
||||
if (readiness === "started") {
|
||||
await client.start();
|
||||
} else {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
return await readMatrixVerificationStatus(client, opts);
|
||||
},
|
||||
"discard",
|
||||
);
|
||||
}
|
||||
|
||||
async function readMatrixVerificationStatus(
|
||||
client: MatrixActionClient,
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean },
|
||||
) {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
const payload = {
|
||||
...status,
|
||||
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
|
||||
};
|
||||
if (!opts.includeRecoveryKey) {
|
||||
return payload;
|
||||
}
|
||||
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
|
||||
return {
|
||||
...payload,
|
||||
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => await client.getRoomKeyBackupStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyMatrixRecoveryKey(
|
||||
recoveryKey: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.restoreRoomKeyBackup({
|
||||
recoveryKey: normalizeOptionalString(opts.recoveryKey),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & { rotateRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.resetRoomKeyBackup({
|
||||
rotateRecoveryKey: opts.rotateRecoveryKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.bootstrapOwnDeviceVerification({
|
||||
recoveryKey: normalizeOptionalString(opts.recoveryKey),
|
||||
forceResetCrossSigning: opts.forceResetCrossSigning === true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
27
extensions/matrix/src/matrix/active-client.ts
Normal file
27
extensions/matrix/src/matrix/active-client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Matrix plugin module implements active client behavior.
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
const activeClients = new Map<string, MatrixClient>();
|
||||
|
||||
function resolveAccountKey(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return normalized || DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function setActiveMatrixClient(
|
||||
client: MatrixClient | null,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const key = resolveAccountKey(accountId);
|
||||
if (!client) {
|
||||
activeClients.delete(key);
|
||||
return;
|
||||
}
|
||||
activeClients.set(key, client);
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||
const key = resolveAccountKey(accountId);
|
||||
return activeClients.get(key) ?? null;
|
||||
}
|
||||
19
extensions/matrix/src/matrix/async-lock.ts
Normal file
19
extensions/matrix/src/matrix/async-lock.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Matrix plugin module implements async lock behavior.
|
||||
export type AsyncLock = <T>(fn: () => Promise<T>) => Promise<T>;
|
||||
|
||||
export function createAsyncLock(): AsyncLock {
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const previous = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
125
extensions/matrix/src/matrix/backup-health.ts
Normal file
125
extensions/matrix/src/matrix/backup-health.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// Matrix plugin module implements backup health behavior.
|
||||
type MatrixRoomKeyBackupStatusLike = {
|
||||
serverVersion: string | null;
|
||||
activeVersion: string | null;
|
||||
trusted: boolean | null;
|
||||
matchesDecryptionKey: boolean | null;
|
||||
decryptionKeyCached: boolean | null;
|
||||
keyLoadAttempted: boolean;
|
||||
keyLoadError: string | null;
|
||||
};
|
||||
|
||||
type MatrixRoomKeyBackupIssueCode =
|
||||
| "missing-server-backup"
|
||||
| "key-load-failed"
|
||||
| "key-not-loaded"
|
||||
| "key-mismatch"
|
||||
| "untrusted-signature"
|
||||
| "inactive"
|
||||
| "indeterminate"
|
||||
| "ok";
|
||||
|
||||
type MatrixRoomKeyBackupIssue = {
|
||||
code: MatrixRoomKeyBackupIssueCode;
|
||||
summary: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export function resolveMatrixRoomKeyBackupIssue(
|
||||
backup: MatrixRoomKeyBackupStatusLike,
|
||||
): MatrixRoomKeyBackupIssue {
|
||||
if (!backup.serverVersion) {
|
||||
return {
|
||||
code: "missing-server-backup",
|
||||
summary: "missing on server",
|
||||
message: "no room-key backup exists on the homeserver",
|
||||
};
|
||||
}
|
||||
if (backup.decryptionKeyCached === false) {
|
||||
if (backup.keyLoadError) {
|
||||
return {
|
||||
code: "key-load-failed",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
|
||||
};
|
||||
}
|
||||
if (backup.keyLoadAttempted) {
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message:
|
||||
"backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: "backup decryption key is not loaded on this device",
|
||||
};
|
||||
}
|
||||
if (backup.matchesDecryptionKey === false) {
|
||||
return {
|
||||
code: "key-mismatch",
|
||||
summary: "present but backup key mismatch on this device",
|
||||
message: "backup key mismatch (this device does not have the matching backup decryption key)",
|
||||
};
|
||||
}
|
||||
if (backup.trusted === false) {
|
||||
return {
|
||||
code: "untrusted-signature",
|
||||
summary: "present but not trusted on this device",
|
||||
message: "backup signature chain is not trusted by this device",
|
||||
};
|
||||
}
|
||||
if (!backup.activeVersion) {
|
||||
return {
|
||||
code: "inactive",
|
||||
summary: "present on server but inactive on this device",
|
||||
message: "backup exists but is not active on this device",
|
||||
};
|
||||
}
|
||||
if (
|
||||
backup.trusted === null ||
|
||||
backup.matchesDecryptionKey === null ||
|
||||
backup.decryptionKeyCached === null
|
||||
) {
|
||||
return {
|
||||
code: "indeterminate",
|
||||
summary: "present but trust state unknown",
|
||||
message: "backup trust state could not be fully determined",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "ok",
|
||||
summary: "active and trusted on this device",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixRoomKeyBackupReadinessError(
|
||||
backup: MatrixRoomKeyBackupStatusLike,
|
||||
opts: {
|
||||
allowUntrustedMatchingKey?: boolean;
|
||||
requireServerBackup: boolean;
|
||||
},
|
||||
): string | null {
|
||||
const issue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
if (issue.code === "missing-server-backup") {
|
||||
return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null;
|
||||
}
|
||||
if (issue.code === "ok") {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
issue.code === "untrusted-signature" &&
|
||||
opts.allowUntrustedMatchingKey === true &&
|
||||
backup.matchesDecryptionKey === true &&
|
||||
backup.decryptionKeyCached === true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (issue.message) {
|
||||
return `Matrix room key backup is not usable: ${issue.message}.`;
|
||||
}
|
||||
return "Matrix room key backup is not usable on this device.";
|
||||
}
|
||||
89
extensions/matrix/src/matrix/client-bootstrap.test.ts
Normal file
89
extensions/matrix/src/matrix/client-bootstrap.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Matrix tests cover client bootstrap plugin behavior.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockMatrixClient,
|
||||
matrixClientResolverMocks,
|
||||
primeMatrixClientResolverMocks,
|
||||
} from "./client-resolver.test-helpers.js";
|
||||
|
||||
const {
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const TEST_CFG = {};
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("./active-client.js", () => ({
|
||||
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
acquireSharedMatrixClient: (...args: unknown[]) => acquireSharedMatrixClientMock(...args),
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client/shared.js", () => ({
|
||||
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
|
||||
}));
|
||||
|
||||
let resolveRuntimeMatrixClientWithReadiness: typeof import("./client-bootstrap.js").resolveRuntimeMatrixClientWithReadiness;
|
||||
let withResolvedRuntimeMatrixClient: typeof import("./client-bootstrap.js").withResolvedRuntimeMatrixClient;
|
||||
|
||||
describe("client bootstrap", () => {
|
||||
beforeAll(async () => {
|
||||
({ resolveRuntimeMatrixClientWithReadiness, withResolvedRuntimeMatrixClient } =
|
||||
await import("./client-bootstrap.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
primeMatrixClientResolverMocks({ resolved: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("releases leased shared clients when readiness setup fails", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
vi.mocked(sharedClient["prepareForOneOff"]).mockRejectedValue(new Error("prepare failed"));
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
resolveRuntimeMatrixClientWithReadiness({
|
||||
cfg: TEST_CFG,
|
||||
accountId: "default",
|
||||
readiness: "prepared",
|
||||
}),
|
||||
).rejects.toThrow("prepare failed");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("releases leased shared clients when the wrapped action throws during readiness", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
vi.mocked(sharedClient["start"]).mockRejectedValue(new Error("start failed"));
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedRuntimeMatrixClient(
|
||||
{
|
||||
cfg: TEST_CFG,
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
},
|
||||
async () => "ok",
|
||||
),
|
||||
).rejects.toThrow("start failed");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
});
|
||||
165
extensions/matrix/src/matrix/client-bootstrap.ts
Normal file
165
extensions/matrix/src/matrix/client-bootstrap.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Matrix plugin module implements client bootstrap behavior.
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import { isBunRuntime } from "./client/runtime.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
type ResolvedRuntimeMatrixClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
cleanup?: (mode: ResolvedRuntimeMatrixClientStopMode) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist" | "discard";
|
||||
|
||||
type MatrixResolvedClientHook = (
|
||||
client: MatrixClient,
|
||||
context: { preparedByDefault: boolean },
|
||||
) => Promise<void> | void;
|
||||
|
||||
const loadMatrixSharedClientRuntimeDeps = createLazyRuntimeModule(() =>
|
||||
Promise.all([import("./client.js"), import("./client/shared.js")]).then(
|
||||
([clientModule, sharedModule]) => ({
|
||||
acquireSharedMatrixClient: clientModule.acquireSharedMatrixClient,
|
||||
resolveMatrixAuthContext: clientModule.resolveMatrixAuthContext,
|
||||
releaseSharedClientInstance: sharedModule.releaseSharedClientInstance,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
async function ensureResolvedClientReadiness(params: {
|
||||
client: MatrixClient;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
preparedByDefault: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.readiness === "started") {
|
||||
await params.client.start();
|
||||
return;
|
||||
}
|
||||
if (params.readiness === "prepared" || (!params.readiness && params.preparedByDefault)) {
|
||||
await params.client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMatrixNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
onResolved?: MatrixResolvedClientHook;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
ensureMatrixNodeRuntime();
|
||||
if (opts.client) {
|
||||
await opts.onResolved?.(opts.client, { preparedByDefault: false });
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
|
||||
if (!opts.cfg) {
|
||||
throw new Error(
|
||||
"Matrix runtime client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Matrix runtime client") as CoreConfig;
|
||||
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
|
||||
await loadMatrixSharedClientRuntimeDeps();
|
||||
const authContext = resolveMatrixAuthContext({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const active = getActiveMatrixClient(authContext.accountId);
|
||||
if (active) {
|
||||
await opts.onResolved?.(active, { preparedByDefault: false });
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
const client = await acquireSharedMatrixClient({
|
||||
cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: authContext.accountId,
|
||||
startClient: false,
|
||||
});
|
||||
try {
|
||||
await opts.onResolved?.(client, { preparedByDefault: true });
|
||||
} catch (err) {
|
||||
await releaseSharedClientInstance(client, "stop");
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
client,
|
||||
stopOnDone: true,
|
||||
cleanup: async (mode) => {
|
||||
await releaseSharedClientInstance(client, mode);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClientWithReadiness(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureResolvedClientReadiness({
|
||||
client,
|
||||
readiness: opts.readiness,
|
||||
preparedByDefault: context.preparedByDefault,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopResolvedRuntimeMatrixClient(
|
||||
resolved: ResolvedRuntimeMatrixClient,
|
||||
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (resolved.cleanup) {
|
||||
await resolved.cleanup(mode);
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
if (mode === "discard") {
|
||||
resolved.client.stopWithoutPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedRuntimeMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
|
||||
}
|
||||
}
|
||||
164
extensions/matrix/src/matrix/client-resolver.test-helpers.ts
Normal file
164
extensions/matrix/src/matrix/client-resolver.test-helpers.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// Matrix helper module supports client resolver helpers behavior.
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
type MatrixClientResolverMocks = {
|
||||
loadConfigMock: Mock<() => unknown>;
|
||||
getMatrixRuntimeMock: Mock<() => unknown>;
|
||||
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
|
||||
acquireSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
|
||||
releaseSharedClientInstanceMock: Mock<(...args: unknown[]) => Promise<boolean>>;
|
||||
isBunRuntimeMock: Mock<() => boolean>;
|
||||
resolveMatrixAuthContextMock: Mock<
|
||||
(params: { cfg: unknown; accountId?: string | null }) => unknown
|
||||
>;
|
||||
};
|
||||
|
||||
export const matrixClientResolverMocks: MatrixClientResolverMocks = {
|
||||
loadConfigMock: vi.fn(() => ({})),
|
||||
getMatrixRuntimeMock: vi.fn(),
|
||||
getActiveMatrixClientMock: vi.fn(),
|
||||
acquireSharedMatrixClientMock: vi.fn(),
|
||||
releaseSharedClientInstanceMock: vi.fn(),
|
||||
isBunRuntimeMock: vi.fn(() => false),
|
||||
resolveMatrixAuthContextMock: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-config-runtime")>(
|
||||
"openclaw/plugin-sdk/plugin-config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
requireRuntimeConfig: vi.fn((cfg: unknown) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
return matrixClientResolverMocks.loadConfigMock();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export function createMockMatrixClient(): MatrixClient {
|
||||
return {
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
stopAndPersist: vi.fn(async () => undefined),
|
||||
stopWithoutPersist: vi.fn(() => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
export function primeMatrixClientResolverMocks(params?: {
|
||||
cfg?: unknown;
|
||||
accountId?: string;
|
||||
resolved?: Record<string, unknown>;
|
||||
auth?: Record<string, unknown>;
|
||||
client?: MatrixClient;
|
||||
}): MatrixClient {
|
||||
const {
|
||||
loadConfigMock,
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const cfg = params?.cfg ?? {};
|
||||
const accountId = params?.accountId ?? "default";
|
||||
const defaultResolved = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
password: undefined,
|
||||
deviceId: "DEVICE123",
|
||||
encryption: false,
|
||||
};
|
||||
const client = params?.client ?? createMockMatrixClient();
|
||||
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
getMatrixRuntimeMock.mockReturnValue({
|
||||
config: {
|
||||
current: loadConfigMock,
|
||||
},
|
||||
});
|
||||
getActiveMatrixClientMock.mockReturnValue(null);
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
releaseSharedClientInstanceMock.mockReset().mockResolvedValue(true);
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({
|
||||
cfg: explicitCfg,
|
||||
accountId: explicitAccountId,
|
||||
}: {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
}) => ({
|
||||
cfg: explicitCfg,
|
||||
env: process.env,
|
||||
accountId: explicitAccountId ?? accountId,
|
||||
resolved: {
|
||||
...defaultResolved,
|
||||
...params?.resolved,
|
||||
},
|
||||
}),
|
||||
);
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function expectOneOffSharedMatrixClient(params?: {
|
||||
cfg?: unknown;
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
prepareForOneOffCalls?: number;
|
||||
startCalls?: number;
|
||||
releaseMode?: "persist" | "stop" | "discard";
|
||||
}) {
|
||||
const {
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
} = matrixClientResolverMocks;
|
||||
const accountId = params?.accountId ?? "default";
|
||||
const prepareForOneOffCalls = params?.prepareForOneOffCalls ?? 1;
|
||||
const startCalls = params?.startCalls ?? 0;
|
||||
const releaseMode = params?.releaseMode ?? "stop";
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith(accountId);
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
|
||||
cfg: params?.cfg ?? {},
|
||||
timeoutMs: params?.timeoutMs,
|
||||
accountId,
|
||||
startClient: false,
|
||||
});
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(prepareForOneOffCalls);
|
||||
expect(sharedClient.start).toHaveBeenCalledTimes(startCalls);
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, releaseMode);
|
||||
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
export function expectExplicitMatrixClientConfig(params: { cfg: unknown; accountId?: string }) {
|
||||
const { getMatrixRuntimeMock, resolveMatrixAuthContextMock, acquireSharedMatrixClientMock } =
|
||||
matrixClientResolverMocks;
|
||||
const accountId = params.accountId ?? "default";
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
});
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
|
||||
cfg: params.cfg,
|
||||
timeoutMs: undefined,
|
||||
accountId,
|
||||
startClient: false,
|
||||
});
|
||||
}
|
||||
863
extensions/matrix/src/matrix/client.test.ts
Normal file
863
extensions/matrix/src/matrix/client.test.ts
Normal file
@@ -0,0 +1,863 @@
|
||||
// Matrix tests cover client plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { installMatrixTestRuntime } from "../test-runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
resolveMatrixAuth,
|
||||
setMatrixAuthClientDepsForTest,
|
||||
} from "./client/config.js";
|
||||
import * as credentialsReadModule from "./credentials-read.js";
|
||||
|
||||
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const saveBackfilledMatrixDeviceIdMock = vi.hoisted(() => vi.fn(async () => "saved"));
|
||||
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
const repairCurrentTokenStorageMetaDeviceIdMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: vi.fn(() => null),
|
||||
credentialsMatchConfig: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("./credentials-write.runtime.js", () => ({
|
||||
saveBackfilledMatrixDeviceId: saveBackfilledMatrixDeviceIdMock,
|
||||
saveMatrixCredentials: saveMatrixCredentialsMock,
|
||||
touchMatrixCredentials: touchMatrixCredentialsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client/storage.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client/storage.js")>("./client/storage.js");
|
||||
return {
|
||||
...actual,
|
||||
repairCurrentTokenStorageMetaDeviceId: repairCurrentTokenStorageMetaDeviceIdMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client/config-secret-input.runtime.js", () => ({
|
||||
resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock,
|
||||
}));
|
||||
|
||||
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
|
||||
const matrixDoRequestMock = vi.fn();
|
||||
|
||||
class MockMatrixClient {
|
||||
async doRequest(...args: unknown[]) {
|
||||
return await matrixDoRequestMock(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new Error(`${label} was not an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(record[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectAuthFields(auth: unknown, fields: Record<string, unknown>) {
|
||||
expectRecordFields(requireRecord(auth, "Matrix auth"), fields);
|
||||
}
|
||||
|
||||
function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
|
||||
const call = mock.mock.calls.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`missing mock call ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function expectSavedCredentials(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
fields: Record<string, unknown>,
|
||||
accountId: string,
|
||||
) {
|
||||
const call = mockCall(mock);
|
||||
expectRecordFields(requireRecord(call[0], "Matrix credentials"), fields);
|
||||
requireRecord(call[1], "Matrix credential save options");
|
||||
expect(call[2]).toBe(accountId);
|
||||
}
|
||||
|
||||
function expectMatrixLoginCall(fields: Record<string, unknown>) {
|
||||
const call = mockCall(matrixDoRequestMock);
|
||||
expect(call[0]).toBe("POST");
|
||||
expect(call[1]).toBe("/_matrix/client/v3/login");
|
||||
expect(call[2]).toBeUndefined();
|
||||
expectRecordFields(requireRecord(call[3], "Matrix login body"), fields);
|
||||
}
|
||||
|
||||
describe("resolveMatrixAuth", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReset();
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReset();
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
saveMatrixCredentialsMock.mockReset();
|
||||
saveBackfilledMatrixDeviceIdMock.mockReset().mockResolvedValue("saved");
|
||||
touchMatrixCredentialsMock.mockReset();
|
||||
repairCurrentTokenStorageMetaDeviceIdMock.mockReset().mockReturnValue(true);
|
||||
resolveConfiguredSecretInputStringMock.mockReset().mockResolvedValue({});
|
||||
ensureMatrixSdkLoggingConfiguredMock.mockReset();
|
||||
matrixDoRequestMock.mockReset();
|
||||
setMatrixAuthClientDepsForTest({
|
||||
MatrixClient: MockMatrixClient as unknown as typeof import("./sdk.js").MatrixClient,
|
||||
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
|
||||
retryMinDelayMs: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setMatrixAuthClientDepsForTest(undefined);
|
||||
});
|
||||
|
||||
it("uses the hardened client request path for password login and persists deviceId", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expectMatrixLoginCall({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@bot:example.org" },
|
||||
password: "secret",
|
||||
});
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
expectSavedCredentials(
|
||||
saveMatrixCredentialsMock,
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces password login errors when account credentials are invalid", async () => {
|
||||
matrixDoRequestMock.mockRejectedValueOnce(new Error("Invalid username or password"));
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(
|
||||
resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).rejects.toThrow("Invalid username or password");
|
||||
|
||||
expectMatrixLoginCall({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@bot:example.org" },
|
||||
password: "secret",
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses cached matching credentials when access token is not configured", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expectAuthFields(auth, {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects embedded credentials in Matrix homeserver URLs", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://user:pass@matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix homeserver URL must not include embedded credentials",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to config deviceId when cached credentials are missing it", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth.deviceId).toBe("DEVICE123");
|
||||
expect(auth.accountId).toBe("default");
|
||||
expectSavedCredentials(
|
||||
saveMatrixCredentialsMock,
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the private-network opt-in through Matrix auth resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
allowPrivateNetwork: true,
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expectAuthFields(auth, {
|
||||
homeserver: "http://127.0.0.1:8008",
|
||||
allowPrivateNetwork: true,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves token-only non-default account userId from whoami instead of inheriting the base user", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@ops:example.org",
|
||||
device_id: "OPSDEVICE",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@base:example.org",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth.userId).toBe("@ops:example.org");
|
||||
expect(auth.deviceId).toBe("OPSDEVICE");
|
||||
});
|
||||
|
||||
it("uses named-account password auth instead of inheriting the base access token", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
access_token: "ops-token",
|
||||
user_id: "@ops:example.org",
|
||||
device_id: "OPSDEVICE",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expectMatrixLoginCall({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@ops:example.org" },
|
||||
password: "ops-pass",
|
||||
});
|
||||
expectAuthFields(auth, {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves missing whoami identity fields for token auth", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries token whoami when startup auth hits a transient network error", async () => {
|
||||
matrixDoRequestMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new TypeError("fetch failed"), {
|
||||
cause: Object.assign(new Error("read ECONNRESET"), {
|
||||
code: "ECONNRESET",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledTimes(2);
|
||||
expectAuthFields(auth, {
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call whoami when token auth already has a userId and only deviceId is missing", async () => {
|
||||
matrixDoRequestMock.mockRejectedValue(new Error("whoami should not be called"));
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).not.toHaveBeenCalled();
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: undefined,
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("retries password login when startup auth hits a transient network error", async () => {
|
||||
matrixDoRequestMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new TypeError("fetch failed"), {
|
||||
cause: Object.assign(new Error("socket hang up"), {
|
||||
code: "ECONNRESET",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
access_token: "tok-123",
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledTimes(2);
|
||||
expectAuthFields(auth, {
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("best-effort backfills a missing deviceId after startup", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
|
||||
auth: {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expectSavedCredentials(
|
||||
saveBackfilledMatrixDeviceIdMock,
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
"default",
|
||||
);
|
||||
const repairMeta = requireRecord(
|
||||
mockCall(repairCurrentTokenStorageMetaDeviceIdMock).at(0),
|
||||
"repair metadata",
|
||||
);
|
||||
expectRecordFields(repairMeta, {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
accountId: "default",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
requireRecord(repairMeta.env, "repair env");
|
||||
expect(repairCurrentTokenStorageMetaDeviceIdMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
saveBackfilledMatrixDeviceIdMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(deviceId).toBe("DEVICE123");
|
||||
});
|
||||
|
||||
it("skips deviceId backfill when auth already includes it", async () => {
|
||||
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
|
||||
auth: {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).not.toHaveBeenCalled();
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
|
||||
expect(deviceId).toBe("DEVICE123");
|
||||
});
|
||||
|
||||
it("fails before saving repaired credentials when storage metadata repair fails", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
repairCurrentTokenStorageMetaDeviceIdMock.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
backfillMatrixAuthDeviceIdAfterStartup({
|
||||
auth: {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).rejects.toThrow("Matrix deviceId backfill failed to repair current-token storage metadata");
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips stale deviceId backfill writes after newer credentials take over", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-new",
|
||||
deviceId: "DEVICE999",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const deviceId = await backfillMatrixAuthDeviceIdAfterStartup({
|
||||
auth: {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-old",
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(deviceId).toBeUndefined();
|
||||
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips persistence when startup backfill is aborted before whoami resolves", async () => {
|
||||
let resolveWhoami: ((value: { user_id: string; device_id: string }) => void) | undefined;
|
||||
matrixDoRequestMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveWhoami = resolve;
|
||||
}),
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
const backfillPromise = backfillMatrixAuthDeviceIdAfterStartup({
|
||||
auth: {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveWhoami).toBeTypeOf("function");
|
||||
});
|
||||
abortController.abort();
|
||||
resolveWhoami?.({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
|
||||
await expect(backfillPromise).resolves.toBeUndefined();
|
||||
expect(repairCurrentTokenStorageMetaDeviceIdMock).not.toHaveBeenCalled();
|
||||
expect(saveBackfilledMatrixDeviceIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves configured accessToken SecretRefs during Matrix auth", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@bot:example.org",
|
||||
device_id: "DEVICE123",
|
||||
});
|
||||
resolveConfiguredSecretInputStringMock.mockResolvedValue({ value: "resolved-token" });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token.txt",
|
||||
mode: "singleValue",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expectRecordFields(
|
||||
requireRecord(mockCall(resolveConfiguredSecretInputStringMock).at(0), "secret request"),
|
||||
{
|
||||
config: cfg,
|
||||
value: { source: "file", provider: "matrix-file", id: "value" },
|
||||
path: "channels.matrix.accessToken",
|
||||
},
|
||||
);
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "resolved-token",
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
|
||||
matrixDoRequestMock.mockResolvedValue({
|
||||
user_id: "@ops:example.org",
|
||||
device_id: "OPSDEVICE",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
installMatrixTestRuntime({ cfg });
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expectAuthFields(auth, {
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
|
||||
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expectAuthFields(auth, {
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the sole configured account when no global homeserver is set", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expectAuthFields(auth, {
|
||||
accountId: "ops",
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
});
|
||||
expectSavedCredentials(
|
||||
saveMatrixCredentialsMock,
|
||||
{
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
},
|
||||
"ops",
|
||||
);
|
||||
});
|
||||
});
|
||||
24
extensions/matrix/src/matrix/client.ts
Normal file
24
extensions/matrix/src/matrix/client.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Matrix plugin module implements client behavior.
|
||||
export type { MatrixAuth } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
export {
|
||||
backfillMatrixAuthDeviceIdAfterStartup,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
acquireSharedMatrixClient,
|
||||
removeSharedClientInstance,
|
||||
releaseSharedClientInstance,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientForAccount,
|
||||
stopSharedClientInstance,
|
||||
} from "./client/shared.js";
|
||||
10
extensions/matrix/src/matrix/client/config-runtime-api.ts
Normal file
10
extensions/matrix/src/matrix/client/config-runtime-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Matrix API module exposes the plugin public contract.
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
export {
|
||||
isPrivateNetworkOptInEnabled,
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Matrix helper module supports config secret input behavior.
|
||||
export { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
741
extensions/matrix/src/matrix/client/config.test.ts
Normal file
741
extensions/matrix/src/matrix/client/config.test.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
// Matrix tests cover config plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../runtime-api.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuthContext,
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./config.js";
|
||||
|
||||
function createLookupFn(addresses: Array<{ address: string; family: number }>): LookupFn {
|
||||
return vi.fn(async (_hostname: string, options?: unknown) => {
|
||||
if (typeof options === "number" || !options || !(options as { all?: boolean }).all) {
|
||||
return addresses[0];
|
||||
}
|
||||
return addresses;
|
||||
}) as unknown as LookupFn;
|
||||
}
|
||||
|
||||
function resolveDefaultMatrixAuthContext(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = {} as NodeJS.ProcessEnv,
|
||||
) {
|
||||
return resolveMatrixAuthContext({ cfg, env });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
describe("Matrix auth/config live surfaces", () => {
|
||||
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 = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved).toEqual({
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses env when config is missing", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_ID: "ENVDEVICE",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.homeserver).toBe("https://env.example.org");
|
||||
expect(resolved.userId).toBe("@env:example.org");
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
expect(resolved.deviceId).toBe("ENVDEVICE");
|
||||
expect(resolved.deviceName).toBe("EnvDevice");
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
expect(resolved.encryption).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores non-finite initial sync limits", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
initialSyncLimit: Number.NaN,
|
||||
accounts: {
|
||||
ops: {
|
||||
initialSyncLimit: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.initialSyncLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.accessToken).toBe("env-token");
|
||||
});
|
||||
|
||||
it("resolves password SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
userId: "@cfg:example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_PASSWORD" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, env).resolved;
|
||||
expect(resolved.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("resolves account accessToken SecretRef against the provided env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_OPS_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
});
|
||||
|
||||
it("does not resolve account password SecretRefs when scoped token auth is configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
password: { source: "env", provider: "default", id: "MATRIX_OPS_PASSWORD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps unresolved accessToken SecretRef errors when env fallback is missing", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "default", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
defaults: {
|
||||
env: "default",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv)).toThrow(
|
||||
/channels\.matrix\.accessToken: unresolved SecretRef "env:default:MATRIX_ACCESS_TOKEN"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bypass env provider allowlists during startup fallback", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "env", provider: "matrix-env", id: "MATRIX_ACCESS_TOKEN" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-env": {
|
||||
source: "env",
|
||||
allowlist: ["OTHER_MATRIX_ACCESS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveDefaultMatrixAuthContext(cfg, {
|
||||
MATRIX_ACCESS_TOKEN: "env-token",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toThrow(/not allowlisted in secrets\.providers\.matrix-env\.allowlist/i);
|
||||
});
|
||||
|
||||
it("leaves non-env SecretRef access tokens unresolved", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://cfg.example.org",
|
||||
accessToken: { source: "file", provider: "matrix-file", id: "value" },
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"matrix-file": {
|
||||
source: "file",
|
||||
path: "/tmp/matrix-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(
|
||||
resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved.accessToken,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses account-scoped env vars for non-default accounts before global env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://global.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
MATRIX_OPS_DEVICE_NAME: "Ops Device",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.homeserver).toBe("https://ops.example.org");
|
||||
expect(resolved.accessToken).toBe("ops-token");
|
||||
expect(resolved.deviceName).toBe("Ops Device");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toEqual({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
accessToken: undefined,
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
initialSyncLimit: undefined,
|
||||
encryption: true,
|
||||
allowPrivateNetwork: undefined,
|
||||
ssrfPolicy: undefined,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a named "default" account implicitly when multiple Matrix accounts exist', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize a default account from shared top-level defaults alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
name: "Shared Defaults",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from partial top-level auth defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accessToken: "shared-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the injected env-backed "default" Matrix account when implicit selection is available', () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_ACCESS_TOKEN: "default-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("default");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from partial global auth fields", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "shared-token",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@default:example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not materialize a default env account from global homeserver plus userId alone", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_USER_ID: "@default:example.org",
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_USER_ID: "@ops:example.org",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
expect(validateMatrixHomeserverUrl("http://[::ffff:127.0.0.1]:8008")).toBe(
|
||||
"http://[::ffff:127.0.0.1]:8008",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http homeservers only when private-network access is enabled", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix-synapse:8008")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(
|
||||
validateMatrixHomeserverUrl("http://matrix-synapse:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
).toBe("http://matrix-synapse:8008");
|
||||
});
|
||||
|
||||
it("resolves an explicit proxy dispatcher from top-level Matrix config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveDefaultMatrixAuthContext(cfg, {} as NodeJS.ProcessEnv).resolved;
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7890",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers account proxy overrides over top-level Matrix proxy config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "base-token",
|
||||
proxy: "http://127.0.0.1:7890",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
proxy: "http://127.0.0.1:7891",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
|
||||
expect(resolved.dispatcherPolicy).toEqual({
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://127.0.0.1:7891",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects public http homeservers even when private-network access is enabled", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://matrix.example.org:8008", {
|
||||
allowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "93.184.216.34", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts internal http hostnames when the private-network opt-in is explicit", async () => {
|
||||
await expect(
|
||||
resolveValidatedMatrixHomeserverUrl("http://localhost.localdomain:8008", {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
lookupFn: createLookupFn([{ address: "127.0.0.1", family: 4 }]),
|
||||
}),
|
||||
).resolves.toBe("http://localhost.localdomain:8008");
|
||||
});
|
||||
});
|
||||
836
extensions/matrix/src/matrix/client/config.ts
Normal file
836
extensions/matrix/src/matrix/client/config.ts
Normal file
@@ -0,0 +1,836 @@
|
||||
// Matrix helper module supports config behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveOptionalIntegerOption } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../account-selection.js";
|
||||
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
resolveMatrixBaseConfig,
|
||||
listNormalizedMatrixAccountIds,
|
||||
} from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-paths.js";
|
||||
import type { MatrixStoredCredentials } from "../credentials-read.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
isPrivateNetworkOptInEnabled,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
} from "./config-runtime-api.js";
|
||||
import { resolveGlobalMatrixEnvConfig, resolveScopedMatrixEnvConfig } from "./env-auth.js";
|
||||
import { repairCurrentTokenStorageMetaDeviceId } from "./storage.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
import { resolveValidatedMatrixHomeserverUrl } from "./url-validation.js";
|
||||
|
||||
type MatrixAuthClientDeps = {
|
||||
MatrixClient: typeof import("../sdk.js").MatrixClient;
|
||||
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
|
||||
retryMinDelayMs?: number;
|
||||
};
|
||||
|
||||
const loadDefaultMatrixAuthClientDeps = createLazyRuntimeModule(() =>
|
||||
Promise.all([import("../sdk.js"), import("./logging.js")]).then(([sdkModule, loggingModule]) => ({
|
||||
MatrixClient: sdkModule.MatrixClient,
|
||||
ensureMatrixSdkLoggingConfigured: loggingModule.ensureMatrixSdkLoggingConfigured,
|
||||
})),
|
||||
);
|
||||
let matrixAuthClientDepsForTest: MatrixAuthClientDeps | undefined;
|
||||
|
||||
const MATRIX_AUTH_REQUEST_RETRY_RE =
|
||||
/\b(fetch failed|econnreset|econnrefused|enotfound|etimedout|ehostunreach|enetunreach|eai_again|und_err_|socket hang up|network|headers timeout|body timeout|connect timeout)\b/i;
|
||||
|
||||
export function setMatrixAuthClientDepsForTest(deps?: {
|
||||
MatrixClient: typeof import("../sdk.js").MatrixClient;
|
||||
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
|
||||
retryMinDelayMs?: number;
|
||||
}): void {
|
||||
matrixAuthClientDepsForTest = deps;
|
||||
}
|
||||
|
||||
async function loadMatrixAuthClientDeps(): Promise<MatrixAuthClientDeps> {
|
||||
if (matrixAuthClientDepsForTest) {
|
||||
return matrixAuthClientDepsForTest;
|
||||
}
|
||||
return await loadDefaultMatrixAuthClientDeps();
|
||||
}
|
||||
|
||||
const loadMatrixCredentialsReadDeps = createLazyRuntimeModule(() =>
|
||||
import("../credentials-read.js").then((credentialsReadModule) => ({
|
||||
loadMatrixCredentials: credentialsReadModule.loadMatrixCredentials,
|
||||
credentialsMatchConfig: credentialsReadModule.credentialsMatchConfig,
|
||||
})),
|
||||
);
|
||||
|
||||
const loadMatrixCredentialsWriteRuntime = createLazyRuntimeModule(
|
||||
() => import("../credentials-write.runtime.js"),
|
||||
);
|
||||
|
||||
const loadMatrixSecretInputDeps = createLazyRuntimeModule(() =>
|
||||
import("./config-secret-input.runtime.js").then((runtime) => ({
|
||||
resolveConfiguredSecretInputString: runtime.resolveConfiguredSecretInputString,
|
||||
})),
|
||||
);
|
||||
|
||||
function shouldRetryMatrixAuthRequest(err: unknown): boolean {
|
||||
return MATRIX_AUTH_REQUEST_RETRY_RE.test(formatErrorMessage(err));
|
||||
}
|
||||
|
||||
function isAbortSignalTriggered(signal?: AbortSignal): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function credentialsMatchBackfillAuthLineage(params: {
|
||||
stored: MatrixStoredCredentials | null;
|
||||
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken">;
|
||||
}): boolean {
|
||||
if (!params.stored) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
params.stored.homeserver === params.auth.homeserver &&
|
||||
params.stored.userId === params.auth.userId &&
|
||||
params.stored.accessToken === params.auth.accessToken
|
||||
);
|
||||
}
|
||||
|
||||
async function retryMatrixAuthRequest<T>(label: string, run: () => Promise<T>): Promise<T> {
|
||||
return await retryAsync(run, {
|
||||
attempts: 3,
|
||||
minDelayMs: matrixAuthClientDepsForTest?.retryMinDelayMs ?? 250,
|
||||
maxDelayMs: 1_500,
|
||||
jitter: 0.1,
|
||||
label,
|
||||
shouldRetry: (err) => shouldRetryMatrixAuthRequest(err),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchMatrixWhoamiIdentity(params: {
|
||||
homeserver: string;
|
||||
accessToken: string;
|
||||
userId?: string;
|
||||
ssrfPolicy?: MatrixResolvedConfig["ssrfPolicy"];
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
}): Promise<{
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
}> {
|
||||
const { MatrixClient, ensureMatrixSdkLoggingConfigured } = await loadMatrixAuthClientDeps();
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const tempClient = new MatrixClient(params.homeserver, params.accessToken, {
|
||||
userId: params.userId,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
});
|
||||
return (await retryMatrixAuthRequest("matrix auth whoami", async () => {
|
||||
return (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
})) as {
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function readEnvSecretRefFallback(params: {
|
||||
value: unknown;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
}): string | undefined {
|
||||
const ref = coerceSecretRef(params.value, params.config?.secrets?.defaults);
|
||||
if (!ref || ref.source !== "env" || !params.env) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerConfig = params.config?.secrets?.providers?.[ref.provider];
|
||||
if (providerConfig) {
|
||||
if (providerConfig.source !== "env") {
|
||||
throw new Error(
|
||||
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`,
|
||||
);
|
||||
}
|
||||
if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) {
|
||||
throw new Error(
|
||||
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`,
|
||||
);
|
||||
}
|
||||
} else if (ref.provider !== (params.config?.secrets?.defaults?.env?.trim() || "default")) {
|
||||
throw new Error(
|
||||
`Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = params.env[ref.id];
|
||||
if (typeof resolved !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = resolved.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function clean(
|
||||
value: unknown,
|
||||
path: string,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
allowEnvSecretRefFallback?: boolean;
|
||||
suppressSecretRef?: boolean;
|
||||
},
|
||||
): string {
|
||||
const ref = coerceSecretRef(value, opts?.config?.secrets?.defaults);
|
||||
if (opts?.suppressSecretRef && ref) {
|
||||
return "";
|
||||
}
|
||||
const normalizedValue = opts?.allowEnvSecretRefFallback
|
||||
? ref?.source === "env"
|
||||
? (readEnvSecretRefFallback({
|
||||
value,
|
||||
env: opts.env,
|
||||
config: opts.config,
|
||||
}) ?? value)
|
||||
: ref
|
||||
? ""
|
||||
: value
|
||||
: value;
|
||||
return (
|
||||
normalizeResolvedSecretInputString({
|
||||
value: normalizedValue,
|
||||
path,
|
||||
defaults: opts?.config?.secrets?.defaults,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
type MatrixConfigStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
function resolveMatrixBaseConfigFieldPath(field: MatrixConfigStringField): string {
|
||||
return `channels.matrix.${field}`;
|
||||
}
|
||||
|
||||
function shouldAllowEnvSecretRefFallback(field: MatrixConfigStringField): boolean {
|
||||
return field === "accessToken" || field === "password";
|
||||
}
|
||||
|
||||
type MatrixAuthSecretField = "accessToken" | "password";
|
||||
|
||||
type MatrixConfiguredAuthInput = {
|
||||
value: unknown;
|
||||
path: string;
|
||||
};
|
||||
|
||||
function hasConfiguredSecretInputValue(value: unknown, cfg: Pick<CoreConfig, "secrets">): boolean {
|
||||
return (
|
||||
(typeof value === "string" && value.trim().length > 0) ||
|
||||
Boolean(coerceSecretRef(value, cfg.secrets?.defaults))
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredMatrixAccessTokenSource(params: {
|
||||
cfg: CoreConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
}): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
|
||||
const scopedAccessTokenVar = getMatrixScopedEnvVarNames(normalizedAccountId).accessToken;
|
||||
if (
|
||||
hasConfiguredSecretInputValue(account.accessToken, params.cfg) ||
|
||||
clean(params.env[scopedAccessTokenVar], scopedAccessTokenVar).length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return false;
|
||||
}
|
||||
const matrix = resolveMatrixBaseConfig(params.cfg);
|
||||
return (
|
||||
hasConfiguredSecretInputValue(matrix.accessToken, params.cfg) ||
|
||||
clean(params.env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN").length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfiguredMatrixAuthInput(params: {
|
||||
cfg: CoreConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
field: MatrixAuthSecretField;
|
||||
}): MatrixConfiguredAuthInput | undefined {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
|
||||
const accountValue = account[params.field];
|
||||
if (accountValue !== undefined) {
|
||||
return {
|
||||
value: accountValue,
|
||||
path: resolveMatrixConfigFieldPath(params.cfg, normalizedAccountId, params.field),
|
||||
};
|
||||
}
|
||||
|
||||
const scopedKeys = getMatrixScopedEnvVarNames(normalizedAccountId);
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, params.env);
|
||||
const scopedValue = scopedEnv[params.field];
|
||||
if (scopedValue !== undefined) {
|
||||
return {
|
||||
value: scopedValue,
|
||||
path: params.field === "accessToken" ? scopedKeys.accessToken : scopedKeys.password,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const matrix = resolveMatrixBaseConfig(params.cfg);
|
||||
const baseValue = matrix[params.field];
|
||||
if (baseValue !== undefined) {
|
||||
return {
|
||||
value: baseValue,
|
||||
path: resolveMatrixBaseConfigFieldPath(params.field),
|
||||
};
|
||||
}
|
||||
|
||||
const globalValue =
|
||||
params.field === "accessToken" ? params.env.MATRIX_ACCESS_TOKEN : params.env.MATRIX_PASSWORD;
|
||||
if (globalValue !== undefined) {
|
||||
return {
|
||||
value: globalValue,
|
||||
path: params.field === "accessToken" ? "MATRIX_ACCESS_TOKEN" : "MATRIX_PASSWORD",
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function resolveConfiguredMatrixAuthSecretInput(params: {
|
||||
cfg: CoreConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
field: MatrixAuthSecretField;
|
||||
}): Promise<string | undefined> {
|
||||
const configured = resolveConfiguredMatrixAuthInput(params);
|
||||
if (!configured) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ref = coerceSecretRef(configured.value, params.cfg.secrets?.defaults);
|
||||
if (!ref) {
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: configured.value,
|
||||
path: configured.path,
|
||||
defaults: params.cfg.secrets?.defaults,
|
||||
});
|
||||
}
|
||||
|
||||
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
|
||||
const resolved = await resolveConfiguredSecretInputString({
|
||||
config: params.cfg,
|
||||
env: params.env,
|
||||
value: configured.value,
|
||||
path: configured.path,
|
||||
unresolvedReasonStyle: "detailed",
|
||||
});
|
||||
if (resolved.value !== undefined) {
|
||||
return resolved.value;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
resolved.unresolvedRefReason ?? `${configured.path} SecretRef could not be resolved.`,
|
||||
);
|
||||
}
|
||||
|
||||
function readMatrixBaseConfigField(
|
||||
matrix: ReturnType<typeof resolveMatrixBaseConfig>,
|
||||
field: MatrixConfigStringField,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
suppressSecretRef?: boolean;
|
||||
},
|
||||
): string {
|
||||
return clean(matrix[field], resolveMatrixBaseConfigFieldPath(field), {
|
||||
env: opts?.env,
|
||||
config: opts?.config,
|
||||
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
|
||||
suppressSecretRef: opts?.suppressSecretRef,
|
||||
});
|
||||
}
|
||||
|
||||
function readMatrixAccountConfigField(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
account: Partial<Record<MatrixConfigStringField, unknown>>,
|
||||
field: MatrixConfigStringField,
|
||||
opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config?: Pick<CoreConfig, "secrets">;
|
||||
suppressSecretRef?: boolean;
|
||||
},
|
||||
): string {
|
||||
return clean(account[field], resolveMatrixConfigFieldPath(cfg, accountId, field), {
|
||||
env: opts?.env,
|
||||
config: opts?.config,
|
||||
allowEnvSecretRefFallback: shouldAllowEnvSecretRefFallback(field),
|
||||
suppressSecretRef: opts?.suppressSecretRef,
|
||||
});
|
||||
}
|
||||
|
||||
function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
|
||||
return resolveOptionalIntegerOption(value, { min: 0 });
|
||||
}
|
||||
|
||||
function buildMatrixNetworkFields(params: {
|
||||
allowPrivateNetwork: boolean | undefined;
|
||||
proxy?: string;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
}): Pick<MatrixResolvedConfig, "allowPrivateNetwork" | "ssrfPolicy" | "dispatcherPolicy"> {
|
||||
const dispatcherPolicy: PinnedDispatcherPolicy | undefined =
|
||||
params.dispatcherPolicy ??
|
||||
(params.proxy ? { mode: "explicit-proxy", proxyUrl: params.proxy } : undefined);
|
||||
if (!params.allowPrivateNetwork && !dispatcherPolicy) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
...(params.allowPrivateNetwork
|
||||
? {
|
||||
allowPrivateNetwork: true,
|
||||
ssrfPolicy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),
|
||||
}
|
||||
: {}),
|
||||
...(dispatcherPolicy ? { dispatcherPolicy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
export {
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
} from "./env-auth.js";
|
||||
export {
|
||||
resolveValidatedMatrixHomeserverUrl,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./url-validation.js";
|
||||
|
||||
function hasScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): boolean {
|
||||
const scoped = resolveScopedMatrixEnvConfig(accountId, env);
|
||||
return Boolean(
|
||||
scoped.homeserver ||
|
||||
scoped.userId ||
|
||||
scoped.accessToken ||
|
||||
scoped.password ||
|
||||
scoped.deviceId ||
|
||||
scoped.deviceName,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const suppressInactivePasswordSecretRef = hasConfiguredMatrixAccessTokenSource({
|
||||
cfg,
|
||||
env,
|
||||
accountId: normalizedAccountId,
|
||||
});
|
||||
const fieldReadOptions = {
|
||||
env,
|
||||
config: cfg,
|
||||
};
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
const accountField = (field: MatrixConfigStringField) =>
|
||||
readMatrixAccountConfigField(cfg, normalizedAccountId, account, field, {
|
||||
...fieldReadOptions,
|
||||
suppressSecretRef: field === "password" ? suppressInactivePasswordSecretRef : undefined,
|
||||
});
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
account: {
|
||||
homeserver: accountField("homeserver"),
|
||||
userId: accountField("userId"),
|
||||
accessToken: accountField("accessToken"),
|
||||
password: accountField("password"),
|
||||
deviceId: accountField("deviceId"),
|
||||
deviceName: accountField("deviceName"),
|
||||
},
|
||||
scopedEnv,
|
||||
channel: {
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver", fieldReadOptions),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId", fieldReadOptions),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken", fieldReadOptions),
|
||||
password: readMatrixBaseConfigField(matrix, "password", {
|
||||
...fieldReadOptions,
|
||||
suppressSecretRef: suppressInactivePasswordSecretRef,
|
||||
}),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId", fieldReadOptions),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName", fieldReadOptions),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
|
||||
const accountInitialSyncLimit = clampMatrixInitialSyncLimit(account.initialSyncLimit);
|
||||
const initialSyncLimit =
|
||||
accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
const allowPrivateNetwork =
|
||||
isPrivateNetworkOptInEnabled(account) || isPrivateNetworkOptInEnabled(matrix)
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
accessToken: resolvedStrings.accessToken || undefined,
|
||||
password: resolvedStrings.password || undefined,
|
||||
deviceId: resolvedStrings.deviceId || undefined,
|
||||
deviceName: resolvedStrings.deviceName || undefined,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
...buildMatrixNetworkFields({
|
||||
allowPrivateNetwork,
|
||||
proxy: account.proxy ?? matrix.proxy,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImplicitMatrixAccountId(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg, env)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env));
|
||||
}
|
||||
|
||||
export function resolveMatrixAuthContext(params: {
|
||||
cfg: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
cfg: CoreConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
resolved: MatrixResolvedConfig;
|
||||
} {
|
||||
const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig;
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
|
||||
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
|
||||
if (!effectiveAccountId) {
|
||||
throw new Error(
|
||||
'Multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended account or pass --account <id>.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
explicitAccountId &&
|
||||
explicitAccountId !== DEFAULT_ACCOUNT_ID &&
|
||||
!listNormalizedMatrixAccountIds(cfg).includes(explicitAccountId) &&
|
||||
!hasScopedMatrixEnvConfig(explicitAccountId, env)
|
||||
) {
|
||||
throw new Error(
|
||||
`Matrix account "${explicitAccountId}" is not configured. Add channels.matrix.accounts.${explicitAccountId} or define scoped ${getMatrixScopedEnvVarNames(explicitAccountId).accessToken.replace(/_ACCESS_TOKEN$/, "")}_* variables.`,
|
||||
);
|
||||
}
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
|
||||
|
||||
return {
|
||||
cfg,
|
||||
env,
|
||||
accountId: effectiveAccountId,
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
if (!params?.cfg) {
|
||||
throw new Error(
|
||||
"Matrix auth requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
|
||||
);
|
||||
}
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const accessToken =
|
||||
(await resolveConfiguredMatrixAuthSecretInput({
|
||||
cfg,
|
||||
env,
|
||||
accountId,
|
||||
field: "accessToken",
|
||||
})) ?? resolved.accessToken;
|
||||
const tokenAuthPassword = resolved.password;
|
||||
const homeserver = await resolveValidatedMatrixHomeserverUrl(resolved.homeserver, {
|
||||
dangerouslyAllowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
});
|
||||
const { loadMatrixCredentials, credentialsMatchConfig } = await loadMatrixCredentialsReadDeps();
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver,
|
||||
userId: resolved.userId || "",
|
||||
accessToken,
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (accessToken) {
|
||||
let userId = resolved.userId;
|
||||
const hasMatchingCachedToken = cachedCredentials?.accessToken === accessToken;
|
||||
let knownDeviceId = hasMatchingCachedToken
|
||||
? cachedCredentials?.deviceId || resolved.deviceId
|
||||
: resolved.deviceId;
|
||||
|
||||
if (!userId) {
|
||||
// Only block startup on whoami when token auth still needs the user ID.
|
||||
// A missing device ID alone is optional and should not force a network round-trip.
|
||||
const whoami = await fetchMatrixWhoamiIdentity({
|
||||
homeserver,
|
||||
accessToken,
|
||||
userId,
|
||||
ssrfPolicy: resolved.ssrfPolicy,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
});
|
||||
const fetchedUserId = whoami.user_id?.trim();
|
||||
if (!fetchedUserId) {
|
||||
throw new Error("Matrix whoami did not return user_id");
|
||||
}
|
||||
userId = fetchedUserId;
|
||||
knownDeviceId = knownDeviceId || whoami.device_id?.trim() || resolved.deviceId;
|
||||
}
|
||||
|
||||
const shouldRefreshCachedCredentials =
|
||||
!cachedCredentials ||
|
||||
!hasMatchingCachedToken ||
|
||||
cachedCredentials.userId !== userId ||
|
||||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
|
||||
if (shouldRefreshCachedCredentials) {
|
||||
const { saveMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
deviceId: knownDeviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
} else if (hasMatchingCachedToken) {
|
||||
const { touchMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
accountId,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password: tokenAuthPassword,
|
||||
deviceId: knownDeviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields({
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
const { touchMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
return {
|
||||
accountId,
|
||||
homeserver: cachedCredentials.homeserver,
|
||||
userId: cachedCredentials.userId,
|
||||
accessToken: cachedCredentials.accessToken,
|
||||
password: tokenAuthPassword,
|
||||
deviceId: cachedCredentials.deviceId || resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields({
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.userId) {
|
||||
throw new Error("Matrix userId is required when no access token is configured (matrix.userId)");
|
||||
}
|
||||
|
||||
const password =
|
||||
(await resolveConfiguredMatrixAuthSecretInput({
|
||||
cfg,
|
||||
env,
|
||||
accountId,
|
||||
field: "password",
|
||||
})) ?? resolved.password;
|
||||
if (!password) {
|
||||
throw new Error(
|
||||
"Matrix password is required when no access token is configured (matrix.password)",
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
const { MatrixClient, ensureMatrixSdkLoggingConfigured } = await loadMatrixAuthClientDeps();
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(homeserver, "", {
|
||||
ssrfPolicy: resolved.ssrfPolicy,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
});
|
||||
const login = (await retryMatrixAuthRequest("matrix auth login", async () => {
|
||||
return (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password,
|
||||
device_id: resolved.deviceId,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
})) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
|
||||
const loginAccessToken = login.access_token?.trim();
|
||||
if (!loginAccessToken) {
|
||||
throw new Error("Matrix login did not return an access token");
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
accountId,
|
||||
homeserver,
|
||||
userId: login.user_id ?? resolved.userId,
|
||||
accessToken: loginAccessToken,
|
||||
password,
|
||||
deviceId: login.device_id ?? resolved.deviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
...buildMatrixNetworkFields({
|
||||
allowPrivateNetwork: resolved.allowPrivateNetwork,
|
||||
dispatcherPolicy: resolved.dispatcherPolicy,
|
||||
}),
|
||||
};
|
||||
|
||||
const { saveMatrixCredentials } = await loadMatrixCredentialsWriteRuntime();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: auth.deviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function backfillMatrixAuthDeviceIdAfterStartup(params: {
|
||||
auth: MatrixAuth;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<string | undefined> {
|
||||
const knownDeviceId = params.auth.deviceId?.trim();
|
||||
if (knownDeviceId) {
|
||||
return knownDeviceId;
|
||||
}
|
||||
if (isAbortSignalTriggered(params.abortSignal)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const whoami = await fetchMatrixWhoamiIdentity({
|
||||
homeserver: params.auth.homeserver,
|
||||
accessToken: params.auth.accessToken,
|
||||
userId: params.auth.userId,
|
||||
ssrfPolicy: params.auth.ssrfPolicy,
|
||||
dispatcherPolicy: params.auth.dispatcherPolicy,
|
||||
});
|
||||
const deviceId = whoami.device_id?.trim();
|
||||
if (!deviceId) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAbortSignalTriggered(params.abortSignal)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const { loadMatrixCredentials } = await loadMatrixCredentialsReadDeps();
|
||||
if (
|
||||
!credentialsMatchBackfillAuthLineage({
|
||||
stored: loadMatrixCredentials(env, params.auth.accountId),
|
||||
auth: params.auth,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const repairedStorageMeta = repairCurrentTokenStorageMetaDeviceId({
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
accountId: params.auth.accountId,
|
||||
deviceId,
|
||||
env: params.env,
|
||||
});
|
||||
if (!repairedStorageMeta) {
|
||||
throw new Error("Matrix deviceId backfill failed to repair current-token storage metadata");
|
||||
}
|
||||
if (isAbortSignalTriggered(params.abortSignal)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const credentialsWriter = await loadMatrixCredentialsWriteRuntime();
|
||||
const saved = await credentialsWriter.saveBackfilledMatrixDeviceId(
|
||||
{
|
||||
homeserver: params.auth.homeserver,
|
||||
userId: params.auth.userId,
|
||||
accessToken: params.auth.accessToken,
|
||||
deviceId,
|
||||
},
|
||||
env,
|
||||
params.auth.accountId,
|
||||
);
|
||||
return saved === "saved" ? deviceId : undefined;
|
||||
}
|
||||
194
extensions/matrix/src/matrix/client/create-client.test.ts
Normal file
194
extensions/matrix/src/matrix/client/create-client.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Matrix tests cover create client plugin behavior.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ensureMatrixSdkLoggingConfiguredMock = vi.hoisted(() => vi.fn());
|
||||
const resolveValidatedMatrixHomeserverUrlMock = vi.hoisted(() => vi.fn());
|
||||
const maybeMigrateLegacyStorageMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const resolveMatrixStoragePathsMock = vi.hoisted(() => vi.fn());
|
||||
const writeStorageMetaMock = vi.hoisted(() => vi.fn());
|
||||
const MatrixClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./logging.js", () => ({
|
||||
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
|
||||
}));
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveValidatedMatrixHomeserverUrl: resolveValidatedMatrixHomeserverUrlMock,
|
||||
}));
|
||||
|
||||
vi.mock("./storage.js", () => ({
|
||||
maybeMigrateLegacyStorage: maybeMigrateLegacyStorageMock,
|
||||
resolveMatrixStoragePaths: resolveMatrixStoragePathsMock,
|
||||
writeStorageMeta: writeStorageMetaMock,
|
||||
}));
|
||||
|
||||
vi.mock("../sdk.js", () => ({
|
||||
MatrixClient: MatrixClientMock,
|
||||
}));
|
||||
|
||||
let createMatrixClient: typeof import("./create-client.js").createMatrixClient;
|
||||
|
||||
describe("createMatrixClient", () => {
|
||||
const storagePaths = {
|
||||
rootDir: "/tmp/openclaw-matrix-create-client-test",
|
||||
storagePath: "/tmp/openclaw-matrix-create-client-test/storage.json",
|
||||
recoveryKeyPath: "/tmp/openclaw-matrix-create-client-test/recovery.key",
|
||||
idbSnapshotPath: "/tmp/openclaw-matrix-create-client-test/idb.snapshot",
|
||||
accountKey: "default",
|
||||
tokenHash: "token-hash",
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createMatrixClient } = await import("./create-client.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ensureMatrixSdkLoggingConfiguredMock.mockReturnValue(undefined);
|
||||
resolveValidatedMatrixHomeserverUrlMock.mockResolvedValue("https://matrix.example.org");
|
||||
resolveMatrixStoragePathsMock.mockReturnValue(storagePaths);
|
||||
MatrixClientMock.mockImplementation(function MockMatrixClient() {
|
||||
return {
|
||||
stop: vi.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("persists storage metadata by default", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
});
|
||||
|
||||
expect(writeStorageMetaMock).toHaveBeenCalledWith({
|
||||
storagePaths,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accountId: undefined,
|
||||
deviceId: undefined,
|
||||
});
|
||||
expect(resolveMatrixStoragePathsMock).toHaveBeenCalledTimes(1);
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
|
||||
userId: "@bot:example.org",
|
||||
password: undefined,
|
||||
deviceId: undefined,
|
||||
encryption: undefined,
|
||||
localTimeoutMs: undefined,
|
||||
initialSyncLimit: undefined,
|
||||
storageRootDir: storagePaths.rootDir,
|
||||
recoveryKeyPath: storagePaths.recoveryKeyPath,
|
||||
idbSnapshotPath: storagePaths.idbSnapshotPath,
|
||||
cryptoDatabasePrefix: "openclaw-matrix-default-token-hash",
|
||||
autoBootstrapCrypto: undefined,
|
||||
ssrfPolicy: undefined,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ssrfPolicy from allowPrivateNetwork when no explicit policy is provided", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
|
||||
userId: "@bot:example.org",
|
||||
password: undefined,
|
||||
deviceId: undefined,
|
||||
encryption: undefined,
|
||||
localTimeoutMs: undefined,
|
||||
initialSyncLimit: undefined,
|
||||
storageRootDir: undefined,
|
||||
recoveryKeyPath: undefined,
|
||||
idbSnapshotPath: undefined,
|
||||
cryptoDatabasePrefix: undefined,
|
||||
autoBootstrapCrypto: undefined,
|
||||
ssrfPolicy: { allowPrivateNetwork: true },
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit ssrfPolicy over allowPrivateNetwork", async () => {
|
||||
const explicitPolicy = { allowPrivateNetwork: true, customField: "test" };
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
allowPrivateNetwork: false,
|
||||
ssrfPolicy: explicitPolicy as never,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
|
||||
userId: "@bot:example.org",
|
||||
password: undefined,
|
||||
deviceId: undefined,
|
||||
encryption: undefined,
|
||||
localTimeoutMs: undefined,
|
||||
initialSyncLimit: undefined,
|
||||
storageRootDir: undefined,
|
||||
recoveryKeyPath: undefined,
|
||||
idbSnapshotPath: undefined,
|
||||
cryptoDatabasePrefix: undefined,
|
||||
autoBootstrapCrypto: undefined,
|
||||
ssrfPolicy: explicitPolicy,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ssrfPolicy undefined when allowPrivateNetwork is falsy and no explicit policy", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
});
|
||||
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
|
||||
userId: "@bot:example.org",
|
||||
password: undefined,
|
||||
deviceId: undefined,
|
||||
encryption: undefined,
|
||||
localTimeoutMs: undefined,
|
||||
initialSyncLimit: undefined,
|
||||
storageRootDir: undefined,
|
||||
recoveryKeyPath: undefined,
|
||||
idbSnapshotPath: undefined,
|
||||
cryptoDatabasePrefix: undefined,
|
||||
autoBootstrapCrypto: undefined,
|
||||
ssrfPolicy: undefined,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips persistent storage wiring when persistence is disabled", async () => {
|
||||
await createMatrixClient({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok",
|
||||
persistStorage: false,
|
||||
});
|
||||
|
||||
expect(resolveMatrixStoragePathsMock).not.toHaveBeenCalled();
|
||||
expect(writeStorageMetaMock).not.toHaveBeenCalled();
|
||||
expect(MatrixClientMock).toHaveBeenCalledWith("https://matrix.example.org", "tok", {
|
||||
userId: "@bot:example.org",
|
||||
password: undefined,
|
||||
deviceId: undefined,
|
||||
encryption: undefined,
|
||||
localTimeoutMs: undefined,
|
||||
initialSyncLimit: undefined,
|
||||
storageRootDir: undefined,
|
||||
recoveryKeyPath: undefined,
|
||||
idbSnapshotPath: undefined,
|
||||
cryptoDatabasePrefix: undefined,
|
||||
autoBootstrapCrypto: undefined,
|
||||
ssrfPolicy: undefined,
|
||||
dispatcherPolicy: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
96
extensions/matrix/src/matrix/client/create-client.ts
Normal file
96
extensions/matrix/src/matrix/client/create-client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Matrix plugin module implements create client behavior.
|
||||
import fs from "node:fs";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher";
|
||||
import {
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
resolveMatrixStoragePaths,
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
const loadMatrixCreateClientRuntimeDeps = createLazyRuntimeModule(() =>
|
||||
Promise.all([import("../sdk.js"), import("./logging.js")]).then(([sdkModule, loggingModule]) => ({
|
||||
MatrixClient: sdkModule.MatrixClient,
|
||||
ensureMatrixSdkLoggingConfigured: loggingModule.ensureMatrixSdkLoggingConfigured,
|
||||
})),
|
||||
);
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId?: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
persistStorage?: boolean;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
autoBootstrapCrypto?: boolean;
|
||||
allowPrivateNetwork?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
dispatcherPolicy?: PinnedDispatcherPolicy;
|
||||
}): Promise<MatrixClient> {
|
||||
const { MatrixClient, ensureMatrixSdkLoggingConfigured } =
|
||||
await loadMatrixCreateClientRuntimeDeps();
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const homeserver = await resolveValidatedMatrixHomeserverUrl(params.homeserver, {
|
||||
dangerouslyAllowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
const matrixClientUserId = normalizeOptionalString(params.userId);
|
||||
const userId = matrixClientUserId ?? "unknown";
|
||||
const persistStorage = params.persistStorage !== false;
|
||||
const storagePaths = persistStorage
|
||||
? resolveMatrixStoragePaths({
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
deviceId: params.deviceId,
|
||||
env: process.env,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (storagePaths) {
|
||||
await maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env: process.env,
|
||||
});
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver,
|
||||
userId,
|
||||
accountId: params.accountId,
|
||||
deviceId: params.deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
const cryptoDatabasePrefix = storagePaths
|
||||
? `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`
|
||||
: undefined;
|
||||
|
||||
return new MatrixClient(homeserver, params.accessToken, {
|
||||
userId: matrixClientUserId,
|
||||
password: params.password,
|
||||
deviceId: params.deviceId,
|
||||
encryption: params.encryption,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
initialSyncLimit: params.initialSyncLimit,
|
||||
storageRootDir: storagePaths?.rootDir,
|
||||
recoveryKeyPath: storagePaths?.recoveryKeyPath,
|
||||
idbSnapshotPath: storagePaths?.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
autoBootstrapCrypto: params.autoBootstrapCrypto,
|
||||
ssrfPolicy:
|
||||
params.ssrfPolicy ?? ssrfPolicyFromDangerouslyAllowPrivateNetwork(params.allowPrivateNetwork),
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
});
|
||||
}
|
||||
96
extensions/matrix/src/matrix/client/env-auth.ts
Normal file
96
extensions/matrix/src/matrix/client/env-auth.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Matrix plugin module implements env auth behavior.
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
|
||||
type MatrixEnvConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
function cleanEnv(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
return {
|
||||
homeserver: cleanEnv(env.MATRIX_HOMESERVER),
|
||||
userId: cleanEnv(env.MATRIX_USER_ID),
|
||||
accessToken: cleanEnv(env.MATRIX_ACCESS_TOKEN) || undefined,
|
||||
password: cleanEnv(env.MATRIX_PASSWORD) || undefined,
|
||||
deviceId: cleanEnv(env.MATRIX_DEVICE_ID) || undefined,
|
||||
deviceName: cleanEnv(env.MATRIX_DEVICE_NAME) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasReadyMatrixEnvAuth(config: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
}): boolean {
|
||||
const homeserver = cleanEnv(config.homeserver);
|
||||
const userId = cleanEnv(config.userId);
|
||||
const accessToken = cleanEnv(config.accessToken);
|
||||
const password = cleanEnv(config.password);
|
||||
return Boolean(homeserver && (accessToken || (userId && password)));
|
||||
}
|
||||
|
||||
export function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixEnvConfig {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: cleanEnv(env[keys.homeserver]),
|
||||
userId: cleanEnv(env[keys.userId]),
|
||||
accessToken: cleanEnv(env[keys.accessToken]) || undefined,
|
||||
password: cleanEnv(env[keys.password]) || undefined,
|
||||
deviceId: cleanEnv(env[keys.deviceId]) || undefined,
|
||||
deviceName: cleanEnv(env[keys.deviceName]) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixEnvAuthReadiness(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): {
|
||||
ready: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
sourceHint: string;
|
||||
missingMessage: string;
|
||||
} {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scoped);
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const keys = getMatrixScopedEnvVarNames(normalizedAccountId);
|
||||
return {
|
||||
ready: scopedReady,
|
||||
homeserver: scoped.homeserver || undefined,
|
||||
userId: scoped.userId || undefined,
|
||||
sourceHint: `${keys.homeserver} (+ auth vars)`,
|
||||
missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
|
||||
const global = resolveGlobalMatrixEnvConfig(env);
|
||||
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped);
|
||||
const globalReady = hasReadyMatrixEnvAuth(global);
|
||||
const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID);
|
||||
return {
|
||||
ready: defaultScopedReady || globalReady,
|
||||
homeserver: defaultScoped.homeserver || global.homeserver || undefined,
|
||||
userId: defaultScoped.userId || global.userId || undefined,
|
||||
sourceHint: "MATRIX_* or MATRIX_DEFAULT_*",
|
||||
missingMessage:
|
||||
`Set Matrix env vars for the default account ` +
|
||||
`(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` +
|
||||
`or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`,
|
||||
};
|
||||
}
|
||||
311
extensions/matrix/src/matrix/client/file-sync-store.test.ts
Normal file
311
extensions/matrix/src/matrix/client/file-sync-store.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// Matrix tests cover sync cache plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ISyncResponse } from "matrix-js-sdk/lib/matrix.js";
|
||||
import {
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { installMatrixTestRuntime } from "../../test-runtime.js";
|
||||
import {
|
||||
openMatrixSyncCacheStoreOptions,
|
||||
SqliteBackedMatrixSyncStore,
|
||||
type MatrixSyncCacheRecord,
|
||||
} from "./file-sync-store.js";
|
||||
import { openMatrixStorageMetaStoreOptions } from "./storage.js";
|
||||
|
||||
function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
return {
|
||||
next_batch: nextBatch,
|
||||
rooms: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
},
|
||||
state: { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
body: "hello",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
event_id: "$message",
|
||||
origin_server_ts: 1,
|
||||
sender: "@user:example.org",
|
||||
type: "m.room.message",
|
||||
},
|
||||
],
|
||||
prev_batch: "t0",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
content: { theme: "dark" },
|
||||
type: "com.openclaw.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("SqliteBackedMatrixSyncStore", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createStorageRoot(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
installMatrixTestRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
it("persists sync data so restart resumes from the saved cursor", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
const syncResponse = createSyncResponse("s123");
|
||||
|
||||
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(firstStore.hasSavedSync()).toBe(false);
|
||||
await firstStore.setSyncData(syncResponse);
|
||||
await firstStore.flush();
|
||||
expect(fs.existsSync(path.join(storageRoot, "bot-storage.json"))).toBe(false);
|
||||
|
||||
const secondStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(secondStore.hasSavedSync()).toBe(true);
|
||||
await expect(secondStore.getSavedSyncToken()).resolves.toBe("s123");
|
||||
|
||||
const savedSync = await secondStore.getSavedSync();
|
||||
expect(savedSync).toEqual({
|
||||
nextBatch: "s123",
|
||||
accountData: syncResponse.account_data.events,
|
||||
roomsData: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {
|
||||
"m.heroes": [],
|
||||
},
|
||||
state: { events: [] },
|
||||
"org.matrix.msc4222.state_after": { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
body: "hello",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
event_id: "$message",
|
||||
origin_server_ts: 1,
|
||||
sender: "@user:example.org",
|
||||
type: "m.room.message",
|
||||
},
|
||||
],
|
||||
prev_batch: "t0",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
});
|
||||
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the sync cache after the storage root moves", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
const movedStorageRoot = `${storageRoot}-moved`;
|
||||
|
||||
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await firstStore.setSyncData(createSyncResponse("portable-token"));
|
||||
await firstStore.flush();
|
||||
resetPluginStateStoreForTests();
|
||||
fs.renameSync(storageRoot, movedStorageRoot);
|
||||
tempDirs.push(movedStorageRoot);
|
||||
|
||||
const secondStore = new SqliteBackedMatrixSyncStore(movedStorageRoot);
|
||||
expect(secondStore.hasSavedSync()).toBe(true);
|
||||
await expect(secondStore.getSavedSyncToken()).resolves.toBe("portable-token");
|
||||
});
|
||||
|
||||
it("ignores metadata with impossible chunk counts", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
const store = createPluginStateSyncKeyedStoreForTests<MatrixSyncCacheRecord>(
|
||||
"matrix",
|
||||
openMatrixSyncCacheStoreOptions(storageRoot),
|
||||
);
|
||||
store.register("current:meta", {
|
||||
kind: "meta",
|
||||
version: 1,
|
||||
generation: "corrupt",
|
||||
chunkCount: 20_000,
|
||||
cleanShutdown: true,
|
||||
});
|
||||
|
||||
const syncStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(syncStore.hasSavedSync()).toBe(false);
|
||||
await expect(syncStore.getSavedSyncToken()).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it("fails persistence instead of silently dropping sync data when sqlite is unavailable", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
const runtime = getMatrixRuntime();
|
||||
vi.spyOn(runtime.state, "openSyncKeyedStore").mockImplementation(() => {
|
||||
throw new Error("sqlite unavailable");
|
||||
});
|
||||
|
||||
const syncStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await syncStore.setSyncData(createSyncResponse("unavailable-token"));
|
||||
|
||||
await expect(syncStore.flush()).rejects.toThrow(/sqlite store is unavailable/i);
|
||||
});
|
||||
|
||||
it("claims current-token storage ownership when sync state is persisted", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
createPluginStateSyncKeyedStoreForTests<Record<string, unknown>>(
|
||||
"matrix",
|
||||
openMatrixStorageMetaStoreOptions(storageRoot),
|
||||
).register("current", {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accountId: "default",
|
||||
accessTokenHash: "token-hash",
|
||||
deviceId: null,
|
||||
});
|
||||
|
||||
const store = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await store.setSyncData(createSyncResponse("claimed-token"));
|
||||
await store.flush();
|
||||
|
||||
const meta = createPluginStateSyncKeyedStoreForTests<Record<string, unknown>>(
|
||||
"matrix",
|
||||
openMatrixStorageMetaStoreOptions(storageRoot),
|
||||
).lookup("current");
|
||||
expect(meta).toMatchObject({ currentTokenStateClaimed: true });
|
||||
});
|
||||
|
||||
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
|
||||
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
await firstStore.flush();
|
||||
|
||||
const afterDirtyPersist = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
|
||||
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const afterCleanShutdown = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
|
||||
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
|
||||
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const restartedStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
|
||||
await restartedStore.setSyncData(createSyncResponse("s456"));
|
||||
await restartedStore.flush();
|
||||
|
||||
const afterNewSync = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(afterNewSync.hasSavedSync()).toBe(true);
|
||||
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
|
||||
});
|
||||
|
||||
it("coalesces background persistence until the debounce window elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const storageRoot = createStorageRoot();
|
||||
|
||||
const store = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await store.setSyncData(createSyncResponse("s111"));
|
||||
await store.setSyncData(createSyncResponse("s222"));
|
||||
await store.storeClientOptions({ lazyLoadMembers: true });
|
||||
|
||||
const beforeDebounce = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(beforeDebounce.hasSavedSync()).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
const beforeElapsed = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(beforeElapsed.hasSavedSync()).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await Promise.resolve();
|
||||
await store.flush();
|
||||
|
||||
const persisted = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(persisted.hasSavedSync()).toBe(true);
|
||||
await expect(persisted.getSavedSyncToken()).resolves.toBe("s222");
|
||||
await expect(persisted.getClientOptions()).resolves.toEqual({ lazyLoadMembers: true });
|
||||
});
|
||||
|
||||
it("persists client options alongside sync state", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
|
||||
const firstStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await firstStore.storeClientOptions({ lazyLoadMembers: true });
|
||||
await firstStore.flush();
|
||||
|
||||
const secondStore = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
await expect(secondStore.getClientOptions()).resolves.toEqual({ lazyLoadMembers: true });
|
||||
});
|
||||
|
||||
it("ignores legacy raw sync cache files", async () => {
|
||||
const storageRoot = createStorageRoot();
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(storageRoot, "bot-storage.json"),
|
||||
JSON.stringify({
|
||||
next_batch: "legacy-token",
|
||||
rooms: {
|
||||
join: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = new SqliteBackedMatrixSyncStore(storageRoot);
|
||||
expect(store.hasSavedSync()).toBe(false);
|
||||
await expect(store.getSavedSyncToken()).resolves.toBe(null);
|
||||
});
|
||||
});
|
||||
604
extensions/matrix/src/matrix/client/file-sync-store.ts
Normal file
604
extensions/matrix/src/matrix/client/file-sync-store.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
// Matrix plugin module implements SQLite sync cache behavior.
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
Category,
|
||||
MemoryStore,
|
||||
SyncAccumulator,
|
||||
type ISyncData,
|
||||
type IRooms,
|
||||
type ISyncResponse,
|
||||
type IStoredClientOpts,
|
||||
} from "matrix-js-sdk/lib/matrix.js";
|
||||
import type {
|
||||
PluginStateKeyedStore,
|
||||
PluginStateSyncKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { isRecord } from "../../record-shared.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { createAsyncLock } from "../async-lock.js";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
import { resolveMatrixSqliteStateEnv } from "../sqlite-state.js";
|
||||
import { claimCurrentTokenStorageState } from "./storage.js";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const PERSIST_DEBOUNCE_MS = 250;
|
||||
const SYNC_CACHE_NAMESPACE = "sync-cache";
|
||||
const SYNC_CACHE_MAX_ENTRIES = 20_000;
|
||||
const SYNC_CACHE_MAX_CHUNKS = Math.floor((SYNC_CACHE_MAX_ENTRIES - 1) / 2);
|
||||
const SYNC_CACHE_STATE_KEY = "current";
|
||||
// PluginState serializes this string inside a row object; 24KB leaves room for JSON escaping.
|
||||
const SYNC_CACHE_CHUNK_BYTES = 24_000;
|
||||
|
||||
export type PersistedMatrixSyncStore = {
|
||||
version: number;
|
||||
savedSync: ISyncData | null;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
cleanShutdown?: boolean;
|
||||
};
|
||||
|
||||
type MatrixSyncCacheMeta = {
|
||||
kind: "meta";
|
||||
version: number;
|
||||
generation: string;
|
||||
chunkCount: number;
|
||||
syncDigest?: string;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
cleanShutdown?: boolean;
|
||||
};
|
||||
|
||||
type MatrixSyncCacheChunk = {
|
||||
kind: "sync-chunk";
|
||||
index: number;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type MatrixSyncCacheRecord = MatrixSyncCacheMeta | MatrixSyncCacheChunk;
|
||||
|
||||
type MatrixSyncCacheAsyncStore = Pick<
|
||||
PluginStateKeyedStore<MatrixSyncCacheRecord>,
|
||||
"delete" | "entries" | "lookup" | "register"
|
||||
>;
|
||||
|
||||
function normalizeRoomsData(value: unknown): IRooms | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
[Category.Join]: isRecord(value[Category.Join]) ? (value[Category.Join] as IRooms["join"]) : {},
|
||||
[Category.Invite]: isRecord(value[Category.Invite])
|
||||
? (value[Category.Invite] as IRooms["invite"])
|
||||
: {},
|
||||
[Category.Leave]: isRecord(value[Category.Leave])
|
||||
? (value[Category.Leave] as IRooms["leave"])
|
||||
: {},
|
||||
[Category.Knock]: isRecord(value[Category.Knock])
|
||||
? (value[Category.Knock] as IRooms["knock"])
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.nextBatch === "string" && value.nextBatch.trim()) {
|
||||
const roomsData = normalizeRoomsData(value.roomsData);
|
||||
if (!Array.isArray(value.accountData) || !roomsData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextBatch: value.nextBatch,
|
||||
accountData: value.accountData,
|
||||
roomsData,
|
||||
};
|
||||
}
|
||||
|
||||
// Older Matrix state files stored the raw /sync-shaped payload directly.
|
||||
if (typeof value.next_batch === "string" && value.next_batch.trim()) {
|
||||
const roomsData = normalizeRoomsData(value.rooms);
|
||||
if (!roomsData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextBatch: value.next_batch,
|
||||
accountData:
|
||||
isRecord(value.account_data) && Array.isArray(value.account_data.events)
|
||||
? value.account_data.events
|
||||
: [],
|
||||
roomsData,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePersistedStore(value: unknown): PersistedMatrixSyncStore | null {
|
||||
if (!isRecord(value) || value.version !== STORE_VERSION) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(value.savedSync),
|
||||
clientOptions: isRecord(value.clientOptions)
|
||||
? (value.clientOptions as IStoredClientOpts)
|
||||
: undefined,
|
||||
cleanShutdown: value.cleanShutdown === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLegacyPersistedStore(value: unknown): PersistedMatrixSyncStore | null {
|
||||
const persisted = normalizePersistedStore(value);
|
||||
if (persisted) {
|
||||
return persisted;
|
||||
}
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(value),
|
||||
cleanShutdown: false,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function syncDataToSyncResponse(syncData: ISyncData): ISyncResponse {
|
||||
return {
|
||||
next_batch: syncData.nextBatch,
|
||||
rooms: syncData.roomsData,
|
||||
account_data: {
|
||||
events: syncData.accountData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class SqliteBackedMatrixSyncStore extends MemoryStore {
|
||||
private readonly persistLock = createAsyncLock();
|
||||
private readonly accumulator = new SyncAccumulator();
|
||||
private readonly stateKey: string;
|
||||
private readonly store: PluginStateSyncKeyedStore<MatrixSyncCacheRecord>;
|
||||
private readonly storeUnavailableError: unknown;
|
||||
private savedSync: ISyncData | null = null;
|
||||
private savedClientOptions: IStoredClientOpts | undefined;
|
||||
private readonly hadSavedSyncOnLoad: boolean;
|
||||
private readonly hadCleanShutdownOnLoad: boolean;
|
||||
private cleanShutdown = false;
|
||||
private dirty = false;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private persistPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly storageRootDir: string) {
|
||||
super();
|
||||
this.stateKey = SYNC_CACHE_STATE_KEY;
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
let restoredCleanShutdown = false;
|
||||
let syncCacheStore = createNoopMatrixSyncCacheStore();
|
||||
let syncCacheStoreUnavailableError: unknown;
|
||||
try {
|
||||
syncCacheStore = openMatrixSyncCacheStore(storageRootDir);
|
||||
const persisted = readPersistedStoreFromSyncStore(syncCacheStore, this.stateKey);
|
||||
if (persisted) {
|
||||
restoredSavedSync = persisted.savedSync;
|
||||
restoredClientOptions = persisted.clientOptions;
|
||||
restoredCleanShutdown = persisted.cleanShutdown === true;
|
||||
}
|
||||
} catch (err) {
|
||||
syncCacheStoreUnavailableError = err;
|
||||
LogService.warn("MatrixSyncCacheStore", "Failed to load Matrix sync cache:", err);
|
||||
}
|
||||
this.store = syncCacheStore;
|
||||
this.storeUnavailableError = syncCacheStoreUnavailableError;
|
||||
|
||||
this.savedSync = restoredSavedSync;
|
||||
this.savedClientOptions = restoredClientOptions;
|
||||
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
|
||||
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
|
||||
this.cleanShutdown = this.hadCleanShutdownOnLoad;
|
||||
|
||||
if (this.savedSync) {
|
||||
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
|
||||
super.setSyncToken(this.savedSync.nextBatch);
|
||||
}
|
||||
if (this.savedClientOptions) {
|
||||
void super.storeClientOptions(this.savedClientOptions);
|
||||
}
|
||||
}
|
||||
|
||||
hasSavedSync(): boolean {
|
||||
return this.hadSavedSyncOnLoad;
|
||||
}
|
||||
|
||||
hasSavedSyncFromCleanShutdown(): boolean {
|
||||
return this.hadCleanShutdownOnLoad;
|
||||
}
|
||||
|
||||
override getSavedSync(): Promise<ISyncData | null> {
|
||||
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
|
||||
}
|
||||
|
||||
override getSavedSyncToken(): Promise<string | null> {
|
||||
return Promise.resolve(this.savedSync?.nextBatch ?? null);
|
||||
}
|
||||
|
||||
override setSyncData(syncData: ISyncResponse): Promise<void> {
|
||||
this.accumulator.accumulate(syncData);
|
||||
this.savedSync = this.accumulator.getJSON();
|
||||
this.markDirtyAndSchedulePersist();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override getClientOptions() {
|
||||
return Promise.resolve(
|
||||
this.savedClientOptions ? cloneJson(this.savedClientOptions) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
override storeClientOptions(options: IStoredClientOpts) {
|
||||
this.savedClientOptions = cloneJson(options);
|
||||
void super.storeClientOptions(options);
|
||||
this.markDirtyAndSchedulePersist();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override save(force = false) {
|
||||
if (force) {
|
||||
return this.flush();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override wantsSave(): boolean {
|
||||
// We persist directly from setSyncData/storeClientOptions so the SDK's
|
||||
// periodic save hook stays disabled. Shutdown uses flush() for a final sync.
|
||||
return false;
|
||||
}
|
||||
|
||||
override async deleteAllData(): Promise<void> {
|
||||
this.assertStoreAvailable();
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
this.dirty = false;
|
||||
await this.persistPromise?.catch(() => undefined);
|
||||
await super.deleteAllData();
|
||||
this.savedSync = null;
|
||||
this.savedClientOptions = undefined;
|
||||
this.cleanShutdown = false;
|
||||
this.store.delete(metaKey(this.stateKey));
|
||||
for (const row of this.store.entries()) {
|
||||
if (row.key.startsWith(chunkKeyPrefix(this.stateKey))) {
|
||||
this.store.delete(row.key);
|
||||
}
|
||||
}
|
||||
await fs
|
||||
.rm(resolveLegacySyncCachePath(this.storageRootDir), { force: true })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
markCleanShutdown(): void {
|
||||
this.cleanShutdown = true;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
while (this.dirty || this.persistPromise) {
|
||||
if (this.dirty && !this.persistPromise) {
|
||||
this.persistPromise = this.persist().finally(() => {
|
||||
this.persistPromise = null;
|
||||
});
|
||||
}
|
||||
await this.persistPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private markDirtyAndSchedulePersist(): void {
|
||||
this.cleanShutdown = false;
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
void this.flush().catch((err: unknown) => {
|
||||
LogService.warn("MatrixSyncCacheStore", "Failed to persist Matrix sync store:", err);
|
||||
});
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
this.persistTimer.unref?.();
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
this.assertStoreAvailable();
|
||||
this.dirty = false;
|
||||
const payload: PersistedMatrixSyncStore = {
|
||||
version: STORE_VERSION,
|
||||
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
|
||||
cleanShutdown: this.cleanShutdown,
|
||||
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
|
||||
};
|
||||
try {
|
||||
await this.persistLock(async () => {
|
||||
this.writePersistedStore(payload);
|
||||
claimCurrentTokenStorageState({
|
||||
rootDir: this.storageRootDir,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
this.dirty = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedStore(payload: PersistedMatrixSyncStore): void {
|
||||
const rows = buildSyncCacheRows(this.stateKey, payload);
|
||||
for (const row of rows.chunks) {
|
||||
this.store.register(row.key, row.value);
|
||||
}
|
||||
this.store.register(rows.meta.key, rows.meta.value);
|
||||
for (const row of this.store.entries()) {
|
||||
if (row.key.startsWith(chunkKeyPrefix(this.stateKey)) && !rows.nextChunkKeys.has(row.key)) {
|
||||
this.store.delete(row.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoreAvailable(): void {
|
||||
if (this.storeUnavailableError == null) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Matrix sync cache SQLite store is unavailable; cannot persist sync state", {
|
||||
cause: this.storeUnavailableError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopMatrixSyncCacheStore(): PluginStateSyncKeyedStore<MatrixSyncCacheRecord> {
|
||||
return {
|
||||
register: () => {},
|
||||
registerIfAbsent: () => false,
|
||||
lookup: () => undefined,
|
||||
consume: () => undefined,
|
||||
delete: () => false,
|
||||
entries: () => [],
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function readPersistedStoreFromSyncStore(
|
||||
store: PluginStateSyncKeyedStore<MatrixSyncCacheRecord>,
|
||||
stateKey: string,
|
||||
): PersistedMatrixSyncStore | null {
|
||||
const meta = store.lookup(metaKey(stateKey));
|
||||
if (!isSyncCacheMeta(meta)) {
|
||||
return null;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < meta.chunkCount; index += 1) {
|
||||
const chunk = store.lookup(chunkKey(stateKey, meta.generation, index));
|
||||
if (!isSyncCacheChunk(chunk) || chunk.index !== index) {
|
||||
return normalizePersistedStore({
|
||||
version: STORE_VERSION,
|
||||
savedSync: null,
|
||||
clientOptions: meta.clientOptions,
|
||||
cleanShutdown: false,
|
||||
});
|
||||
}
|
||||
chunks.push(chunk.data);
|
||||
}
|
||||
let savedSync: ISyncData | null = null;
|
||||
if (chunks.length > 0) {
|
||||
const syncJson = chunks.join("");
|
||||
if (meta.syncDigest !== digestText(syncJson)) {
|
||||
return normalizePersistedStore({
|
||||
version: STORE_VERSION,
|
||||
savedSync: null,
|
||||
clientOptions: meta.clientOptions,
|
||||
cleanShutdown: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
savedSync = toPersistedSyncData(JSON.parse(syncJson));
|
||||
} catch {
|
||||
savedSync = null;
|
||||
}
|
||||
}
|
||||
return normalizePersistedStore({
|
||||
version: STORE_VERSION,
|
||||
savedSync,
|
||||
clientOptions: meta.clientOptions,
|
||||
cleanShutdown: meta.cleanShutdown,
|
||||
});
|
||||
}
|
||||
|
||||
function openMatrixSyncCacheStore(
|
||||
storageRootDir: string,
|
||||
): PluginStateSyncKeyedStore<MatrixSyncCacheRecord> {
|
||||
return getMatrixRuntime().state.openSyncKeyedStore<MatrixSyncCacheRecord>(
|
||||
openMatrixSyncCacheStoreOptions(storageRootDir),
|
||||
);
|
||||
}
|
||||
|
||||
function metaKey(stateKey: string): string {
|
||||
return `${stateKey}:meta`;
|
||||
}
|
||||
|
||||
function chunkKeyPrefix(stateKey: string): string {
|
||||
return `${stateKey}:sync:`;
|
||||
}
|
||||
|
||||
function chunkKey(stateKey: string, generation: string, index: number): string {
|
||||
return `${chunkKeyPrefix(stateKey)}${generation}:${index}`;
|
||||
}
|
||||
|
||||
function resolveLegacySyncCachePath(storageRootDir: string): string {
|
||||
return path.join(storageRootDir, "bot-storage.json");
|
||||
}
|
||||
|
||||
function digestText(value: string): string {
|
||||
return createHash("sha256").update(value, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function isSyncCacheMeta(value: unknown): value is MatrixSyncCacheMeta {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.kind === "meta" &&
|
||||
value.version === STORE_VERSION &&
|
||||
typeof value.generation === "string" &&
|
||||
value.generation.trim() !== "" &&
|
||||
typeof value.chunkCount === "number" &&
|
||||
Number.isSafeInteger(value.chunkCount) &&
|
||||
value.chunkCount >= 0 &&
|
||||
value.chunkCount <= SYNC_CACHE_MAX_CHUNKS
|
||||
);
|
||||
}
|
||||
|
||||
function isSyncCacheChunk(value: unknown): value is MatrixSyncCacheChunk {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
value.kind === "sync-chunk" &&
|
||||
typeof value.index === "number" &&
|
||||
Number.isSafeInteger(value.index) &&
|
||||
value.index >= 0 &&
|
||||
typeof value.data === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function chunkSyncCacheJson(value: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
const pushChunk = (chunk: string) => {
|
||||
if (chunks.length >= SYNC_CACHE_MAX_CHUNKS) {
|
||||
throw new Error("Matrix sync cache exceeds SQLite chunk limit");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
let current = "";
|
||||
let currentBytes = 0;
|
||||
for (const char of value) {
|
||||
const charBytes = Buffer.byteLength(char, "utf8");
|
||||
if (current && currentBytes + charBytes > SYNC_CACHE_CHUNK_BYTES) {
|
||||
pushChunk(current);
|
||||
current = "";
|
||||
currentBytes = 0;
|
||||
}
|
||||
current += char;
|
||||
currentBytes += charBytes;
|
||||
}
|
||||
if (current) {
|
||||
pushChunk(current);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildSyncCacheRows(
|
||||
stateKey: string,
|
||||
payload: PersistedMatrixSyncStore,
|
||||
): {
|
||||
meta: { key: string; value: MatrixSyncCacheMeta };
|
||||
chunks: { key: string; value: MatrixSyncCacheChunk }[];
|
||||
nextChunkKeys: Set<string>;
|
||||
} {
|
||||
const generation = randomUUID().replaceAll("-", "");
|
||||
const syncJson = payload.savedSync ? JSON.stringify(payload.savedSync) : "";
|
||||
const chunkValues = syncJson ? chunkSyncCacheJson(syncJson) : [];
|
||||
const chunks = chunkValues.map((data, index) => ({
|
||||
key: chunkKey(stateKey, generation, index),
|
||||
value: {
|
||||
kind: "sync-chunk" as const,
|
||||
index,
|
||||
data,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
chunks,
|
||||
nextChunkKeys: new Set(chunks.map((chunk) => chunk.key)),
|
||||
meta: {
|
||||
key: metaKey(stateKey),
|
||||
value: {
|
||||
kind: "meta",
|
||||
version: STORE_VERSION,
|
||||
generation,
|
||||
chunkCount: chunks.length,
|
||||
...(syncJson ? { syncDigest: digestText(syncJson) } : {}),
|
||||
...(payload.clientOptions ? { clientOptions: payload.clientOptions } : {}),
|
||||
cleanShutdown: payload.cleanShutdown === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readLegacyMatrixSyncCacheState(
|
||||
storageRootDir: string,
|
||||
): Promise<PersistedMatrixSyncStore | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(resolveLegacySyncCachePath(storageRootDir), "utf8");
|
||||
const persisted = normalizeLegacyPersistedStore(JSON.parse(raw));
|
||||
if (!persisted?.savedSync && !persisted?.clientOptions) {
|
||||
return null;
|
||||
}
|
||||
return persisted;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasMatrixSyncCacheStateInStore(params: {
|
||||
storageRootDir: string;
|
||||
store: Pick<PluginStateKeyedStore<MatrixSyncCacheRecord>, "lookup">;
|
||||
}): Promise<boolean> {
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const meta = await params.store.lookup(metaKey(stateKey));
|
||||
if (!isSyncCacheMeta(meta) || meta.chunkCount <= 0) {
|
||||
return false;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < meta.chunkCount; index += 1) {
|
||||
const chunk = await params.store.lookup(chunkKey(stateKey, meta.generation, index));
|
||||
if (!isSyncCacheChunk(chunk) || chunk.index !== index) {
|
||||
return false;
|
||||
}
|
||||
chunks.push(chunk.data);
|
||||
}
|
||||
const syncJson = chunks.join("");
|
||||
if (meta.syncDigest !== digestText(syncJson)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return toPersistedSyncData(JSON.parse(syncJson)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeMatrixSyncCacheStateToStore(params: {
|
||||
storageRootDir: string;
|
||||
payload: PersistedMatrixSyncStore;
|
||||
store: MatrixSyncCacheAsyncStore;
|
||||
}): Promise<void> {
|
||||
const stateKey = SYNC_CACHE_STATE_KEY;
|
||||
const rows = buildSyncCacheRows(stateKey, params.payload);
|
||||
for (const row of rows.chunks) {
|
||||
await params.store.register(row.key, row.value);
|
||||
}
|
||||
await params.store.register(rows.meta.key, rows.meta.value);
|
||||
for (const row of await params.store.entries()) {
|
||||
if (row.key.startsWith(chunkKeyPrefix(stateKey)) && !rows.nextChunkKeys.has(row.key)) {
|
||||
await params.store.delete(row.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openMatrixSyncCacheStoreOptions(storageRootDir: string) {
|
||||
return {
|
||||
namespace: SYNC_CACHE_NAMESPACE,
|
||||
maxEntries: SYNC_CACHE_MAX_ENTRIES,
|
||||
env: resolveMatrixSqliteStateEnv({ stateDir: storageRootDir }),
|
||||
};
|
||||
}
|
||||
51
extensions/matrix/src/matrix/client/logging.test.ts
Normal file
51
extensions/matrix/src/matrix/client/logging.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Matrix tests cover logging plugin behavior.
|
||||
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixSdkLoggingConfigured, setMatrixSdkLogMode } from "./logging.js";
|
||||
|
||||
type MatrixJsSdkTestLogger = typeof matrixJsSdkRootLogger & {
|
||||
getLevel?: () => number | string;
|
||||
levels: { WARN: number };
|
||||
methodFactory?: unknown;
|
||||
rebuild?: () => void;
|
||||
setLevel?: (level: number | string, persist?: boolean) => void;
|
||||
};
|
||||
|
||||
describe("Matrix SDK logging", () => {
|
||||
it("restores the Matrix JS SDK global logger level after quiet mode", () => {
|
||||
const logger = matrixJsSdkRootLogger as MatrixJsSdkTestLogger;
|
||||
const originalLevel = logger.getLevel?.();
|
||||
const originalMethodFactory = logger.methodFactory;
|
||||
try {
|
||||
logger.setLevel?.("warn", false);
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
setMatrixSdkLogMode("quiet");
|
||||
setMatrixSdkLogMode("default");
|
||||
|
||||
expect(logger.getLevel?.()).toBe(logger.levels.WARN);
|
||||
expect(logger.methodFactory).toBe(originalMethodFactory);
|
||||
} finally {
|
||||
if (typeof originalLevel === "string" || typeof originalLevel === "number") {
|
||||
logger.setLevel?.(originalLevel, false);
|
||||
}
|
||||
logger.methodFactory = originalMethodFactory;
|
||||
logger.rebuild?.();
|
||||
setMatrixSdkLogMode("default");
|
||||
}
|
||||
});
|
||||
|
||||
it("quiets the Matrix JS SDK global logger for JSON-safe CLI commands", () => {
|
||||
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined);
|
||||
try {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
setMatrixSdkLogMode("quiet");
|
||||
|
||||
matrixJsSdkRootLogger.getChild("[MatrixRTCSession test]").debug("noisy diagnostic");
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setMatrixSdkLogMode("default");
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
141
extensions/matrix/src/matrix/client/logging.ts
Normal file
141
extensions/matrix/src/matrix/client/logging.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// Matrix plugin module implements logging behavior.
|
||||
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkLogMode: "default" | "quiet" = "default";
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
let matrixJsSdkRootLoggerSnapshot: MatrixJsSdkRootLoggerSnapshot | null = null;
|
||||
|
||||
type MatrixJsSdkLogger = {
|
||||
trace: (...messageOrObject: unknown[]) => void;
|
||||
debug: (...messageOrObject: unknown[]) => void;
|
||||
info: (...messageOrObject: unknown[]) => void;
|
||||
warn: (...messageOrObject: unknown[]) => void;
|
||||
error: (...messageOrObject: unknown[]) => void;
|
||||
getChild: (namespace: string) => MatrixJsSdkLogger;
|
||||
};
|
||||
|
||||
type MatrixJsSdkLoglevelLogger = {
|
||||
getLevel?: () => number | string;
|
||||
methodFactory?: unknown;
|
||||
rebuild?: () => void;
|
||||
setLevel?: (level: number | string, persist?: boolean) => void;
|
||||
};
|
||||
|
||||
type MatrixJsSdkRootLoggerSnapshot = {
|
||||
level?: number | string;
|
||||
methodFactory?: unknown;
|
||||
};
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||
if (!module.includes("MatrixHttpClient")) {
|
||||
return false;
|
||||
}
|
||||
return messageOrObject.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
matrixSdkLoggingConfigured = true;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
|
||||
matrixSdkLogMode = mode;
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
return;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function setMatrixSdkConsoleLogging(enabled: boolean): void {
|
||||
setMatrixConsoleLogging(enabled);
|
||||
}
|
||||
|
||||
export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLogger {
|
||||
return createMatrixJsSdkLoggerInstance(prefix);
|
||||
}
|
||||
|
||||
function applyMatrixSdkLogger(): void {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
setMatrixJsSdkRootLoggerLevel("silent");
|
||||
LogService.setLogger({
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setMatrixJsSdkRootLoggerLevel("debug");
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||
error: (module, ...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMatrixJsSdkRootLoggerLevel(level: "debug" | "silent"): void {
|
||||
const logger = matrixJsSdkRootLogger as unknown as MatrixJsSdkLoglevelLogger;
|
||||
matrixJsSdkRootLoggerSnapshot ??= {
|
||||
level: logger.getLevel?.(),
|
||||
methodFactory: logger.methodFactory,
|
||||
};
|
||||
if (level === "silent") {
|
||||
logger.methodFactory = () => () => undefined;
|
||||
logger.setLevel?.("silent", false);
|
||||
logger.rebuild?.();
|
||||
return;
|
||||
}
|
||||
logger.methodFactory = matrixJsSdkRootLoggerSnapshot.methodFactory;
|
||||
const previousLevel = matrixJsSdkRootLoggerSnapshot.level;
|
||||
if (typeof previousLevel === "string" || typeof previousLevel === "number") {
|
||||
logger.setLevel?.(previousLevel, false);
|
||||
}
|
||||
logger.rebuild?.();
|
||||
}
|
||||
|
||||
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
|
||||
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
return;
|
||||
}
|
||||
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
|
||||
prefix,
|
||||
...messageOrObject,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
trace: (...messageOrObject) => log("trace", ...messageOrObject),
|
||||
debug: (...messageOrObject) => log("debug", ...messageOrObject),
|
||||
info: (...messageOrObject) => log("info", ...messageOrObject),
|
||||
warn: (...messageOrObject) => log("warn", ...messageOrObject),
|
||||
error: (...messageOrObject) => {
|
||||
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
|
||||
return;
|
||||
}
|
||||
log("error", ...messageOrObject);
|
||||
},
|
||||
getChild: (namespace: string) => {
|
||||
const nextNamespace = namespace.trim();
|
||||
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Matrix plugin module implements migration snapshot behavior.
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../../migration-snapshot-backup.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Matrix plugin module implements private network host behavior.
|
||||
export { isPrivateOrLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user