mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 22:10:51 +00:00
Matrix: move helper seams and fix routing
This commit is contained in:
3
extensions/matrix/runtime-api.ts
Normal file
3
extensions/matrix/runtime-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./src/account-selection.js";
|
||||
export * from "./src/env-vars.js";
|
||||
export * from "./src/storage-paths.js";
|
||||
96
extensions/matrix/src/account-selection.ts
Normal file
96
extensions/matrix/src/account-selection.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
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 normalizedAccountId = normalizeAccountId(accountId);
|
||||
for (const [rawAccountId, value] of Object.entries(accounts)) {
|
||||
if (normalizeAccountId(rawAccountId) === normalizedAccountId && isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredMatrixAccountIds(cfg: OpenClawConfig): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
const ids = Object.entries(accounts)
|
||||
.filter(([, value]) => isRecord(value))
|
||||
.map(([accountId]) => normalizeAccountId(accountId));
|
||||
|
||||
return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixDefaultOrOnlyAccountId(cfg: OpenClawConfig): 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);
|
||||
if (configuredDefault && configuredAccountIds.includes(configuredDefault)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
if (configuredAccountIds.length === 1) {
|
||||
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function requiresExplicitMatrixDefaultAccount(cfg: OpenClawConfig): boolean {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg);
|
||||
if (configuredAccountIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelMessageToolDiscovery,
|
||||
type ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -217,4 +217,37 @@ describe("matrix setup post-write bootstrap", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
|
||||
const previousEnv = {
|
||||
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
|
||||
MATRIX_USER_ID: process.env.MATRIX_USER_ID,
|
||||
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
|
||||
MATRIX_PASSWORD: process.env.MATRIX_PASSWORD,
|
||||
MATRIX_DEFAULT_HOMESERVER: process.env.MATRIX_DEFAULT_HOMESERVER,
|
||||
MATRIX_DEFAULT_USER_ID: process.env.MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN: process.env.MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_PASSWORD: process.env.MATRIX_DEFAULT_PASSWORD,
|
||||
};
|
||||
for (const key of Object.keys(previousEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
expect(
|
||||
matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "default",
|
||||
input: { useEnv: true },
|
||||
}),
|
||||
).toContain("Set Matrix env vars for the default account");
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,12 +28,7 @@ import {
|
||||
resolveMatrixAccount,
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixAuth,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
} from "./matrix/client.js";
|
||||
import { resolveMatrixEnvAuthReadiness, resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
|
||||
import { resolveMatrixConfigFieldPath, resolveMatrixConfigPath } from "./matrix/config-update.js";
|
||||
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
@@ -382,13 +377,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
|
||||
}
|
||||
if (input.useEnv) {
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
|
||||
}
|
||||
return null;
|
||||
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
|
||||
return envReadiness.ready ? null : envReadiness.missingMessage;
|
||||
}
|
||||
if (!input.homeserver?.trim()) {
|
||||
return "Matrix requires --homeserver";
|
||||
|
||||
30
extensions/matrix/src/env-vars.ts
Normal file
30
extensions/matrix/src/env-vars.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
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`,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeAccountId,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { resolveMatrixDefaultOrOnlyAccountId } from "../account-selection.js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export type { MatrixAuth } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export { getMatrixScopedEnvVarNames } from "openclaw/plugin-sdk/matrix";
|
||||
export { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
export {
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveMatrixAuth,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getMatrixScopedEnvVarNames,
|
||||
isPrivateOrLoopbackHost,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
} from "../../account-selection.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
@@ -111,7 +113,48 @@ function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export { getMatrixScopedEnvVarNames } from "openclaw/plugin-sdk/matrix";
|
||||
export { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
|
||||
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}).`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { maybeCreateMatrixMigrationSnapshot, normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
normalizeAccountId,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
} from "../../account-selection.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../../storage-paths.js";
|
||||
import type { MatrixStoragePaths } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||
|
||||
@@ -2,14 +2,16 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
writeJsonFileAtomically,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
} from "../storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
|
||||
@@ -117,6 +117,85 @@ describe("matrix onboarding", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes legacy top-level Matrix config before adding a named account", async () => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
|
||||
(homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Matrix already configured. What do you want to do?") {
|
||||
return "add-account";
|
||||
}
|
||||
if (message === "Matrix auth method") {
|
||||
return "token";
|
||||
}
|
||||
throw new Error(`unexpected select prompt: ${message}`);
|
||||
}),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Matrix account name") {
|
||||
return "ops";
|
||||
}
|
||||
if (message === "Matrix homeserver URL") {
|
||||
return "https://matrix.ops.example.org";
|
||||
}
|
||||
if (message === "Matrix access token") {
|
||||
return "ops-token";
|
||||
}
|
||||
if (message === "Matrix device name (optional)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`unexpected text prompt: ${message}`);
|
||||
}),
|
||||
confirm: vi.fn(async () => false),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const result = await matrixOnboardingAdapter.configureInteractive!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.main.example.org",
|
||||
userId: "@main:example.org",
|
||||
accessToken: "main-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
|
||||
prompter,
|
||||
options: undefined,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: true,
|
||||
forceAllowFrom: false,
|
||||
configured: true,
|
||||
label: "Matrix",
|
||||
});
|
||||
|
||||
expect(result).not.toBe("skip");
|
||||
if (result === "skip") {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined();
|
||||
expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined();
|
||||
expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({
|
||||
homeserver: "https://matrix.main.example.org",
|
||||
userId: "@main:example.org",
|
||||
accessToken: "main-token",
|
||||
});
|
||||
expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({
|
||||
name: "ops",
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes device env var names in auth help text", async () => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
moveSingleAccountChannelSectionToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type RuntimeEnv,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizardAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
@@ -22,12 +23,7 @@ import {
|
||||
resolveMatrixAccount,
|
||||
resolveMatrixAccountConfig,
|
||||
} from "./matrix/accounts.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./matrix/client.js";
|
||||
import { resolveMatrixEnvAuthReadiness, validateMatrixHomeserverUrl } from "./matrix/client.js";
|
||||
import {
|
||||
resolveMatrixConfigFieldPath,
|
||||
resolveMatrixConfigPath,
|
||||
@@ -238,6 +234,12 @@ async function runMatrixConfigure(params: {
|
||||
if (enteredName !== accountId) {
|
||||
await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account");
|
||||
}
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
next = moveSingleAccountChannelSectionToDefaultAccount({
|
||||
cfg: next,
|
||||
channelKey: channel,
|
||||
}) as CoreConfig;
|
||||
}
|
||||
next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true });
|
||||
} else {
|
||||
const override = params.accountOverrides?.[channel]?.trim();
|
||||
@@ -261,27 +263,10 @@ async function runMatrixConfigure(params: {
|
||||
await noteMatrixAuthHelp(params.prompter);
|
||||
}
|
||||
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
|
||||
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env);
|
||||
const globalEnv = {
|
||||
homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "",
|
||||
userId: process.env.MATRIX_USER_ID?.trim() ?? "",
|
||||
accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined,
|
||||
password: process.env.MATRIX_PASSWORD?.trim() || undefined,
|
||||
};
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
|
||||
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv);
|
||||
const globalReady = hasReadyMatrixEnvAuth(globalEnv);
|
||||
const envReady =
|
||||
scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady));
|
||||
const envHomeserver =
|
||||
scopedEnv.homeserver ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID
|
||||
? defaultScopedEnv.homeserver || globalEnv.homeserver
|
||||
: undefined);
|
||||
const envUserId =
|
||||
scopedEnv.userId ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined);
|
||||
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
|
||||
const envReady = envReadiness.ready;
|
||||
const envHomeserver = envReadiness.homeserver;
|
||||
const envUserId = envReadiness.userId;
|
||||
|
||||
if (
|
||||
envReady &&
|
||||
@@ -290,13 +275,8 @@ async function runMatrixConfigure(params: {
|
||||
!existing.accessToken &&
|
||||
!existing.password
|
||||
) {
|
||||
const scopedEnvNames = getMatrixScopedEnvVarNames(accountId);
|
||||
const envSourceHint =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? "MATRIX_* or MATRIX_DEFAULT_*"
|
||||
: `${scopedEnvNames.homeserver} (+ auth vars)`;
|
||||
const useEnv = await params.prompter.confirm({
|
||||
message: `Matrix env vars detected (${envSourceHint}). Use env values?`,
|
||||
message: `Matrix env vars detected (${envReadiness.sourceHint}). Use env values?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
prepareScopedSetupConfig,
|
||||
type ChannelSetupAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveMatrixEnvAuthReadiness } from "./matrix/client.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
@@ -49,9 +50,10 @@ export const matrixSetupAdapter: ChannelSetupAdapter = {
|
||||
accountId,
|
||||
name,
|
||||
}) as CoreConfig,
|
||||
validateInput: ({ input }) => {
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv) {
|
||||
return null;
|
||||
const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env);
|
||||
return envReadiness.ready ? null : envReadiness.missingMessage;
|
||||
}
|
||||
if (!input.homeserver?.trim()) {
|
||||
return "Matrix requires --homeserver";
|
||||
|
||||
93
extensions/matrix/src/storage-paths.ts
Normal file
93
extensions/matrix/src/storage-paths.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
export function sanitizeMatrixPathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
|
||||
export function resolveMatrixHomeserverKey(homeserver: string): string {
|
||||
try {
|
||||
const url = new URL(homeserver);
|
||||
if (url.host) {
|
||||
return sanitizeMatrixPathSegment(url.host);
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return sanitizeMatrixPathSegment(homeserver);
|
||||
}
|
||||
|
||||
export function hashMatrixAccessToken(accessToken: string): string {
|
||||
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(stateDir: string): string {
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(params: {
|
||||
stateDir: string;
|
||||
accountId?: string | null;
|
||||
}): string {
|
||||
return path.join(
|
||||
resolveMatrixCredentialsDir(params.stateDir),
|
||||
resolveMatrixCredentialsFilename(params.accountId),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string {
|
||||
return path.join(stateDir, "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
|
||||
rootDir: string;
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir);
|
||||
return {
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountStorageRoot(params: {
|
||||
stateDir: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
rootDir: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
} {
|
||||
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const userKey = sanitizeMatrixPathSegment(params.userId);
|
||||
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
|
||||
const tokenHash = hashMatrixAccessToken(params.accessToken);
|
||||
return {
|
||||
rootDir: path.join(
|
||||
params.stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
),
|
||||
accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
}
|
||||
@@ -452,7 +452,7 @@ describe("spawnAcpDirect", () => {
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
expect(agentCall?.params?.channel).toBe("matrix");
|
||||
expect(agentCall?.params?.to).toBe("room:child-thread");
|
||||
expect(agentCall?.params?.to).toBe("room:!room:example");
|
||||
expect(agentCall?.params?.threadId).toBe("child-thread");
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
deliveryContextFromSession,
|
||||
formatConversationTarget,
|
||||
normalizeDeliveryContext,
|
||||
resolveConversationDeliveryTarget,
|
||||
} from "../utils/delivery-context.js";
|
||||
import {
|
||||
type AcpSpawnParentRelayHandle,
|
||||
@@ -670,16 +671,19 @@ export async function spawnAcpDirect(
|
||||
const fallbackThreadId =
|
||||
fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined;
|
||||
const deliveryThreadId = boundThreadId ?? fallbackThreadId;
|
||||
const boundDeliveryTarget = resolveConversationDeliveryTarget({
|
||||
channel: requesterOrigin?.channel ?? binding?.conversation.channel,
|
||||
conversationId: binding?.conversation.conversationId,
|
||||
parentConversationId: binding?.conversation.parentConversationId,
|
||||
});
|
||||
const inferredDeliveryTo =
|
||||
formatConversationTarget({
|
||||
channel: requesterOrigin?.channel ?? binding?.conversation.channel,
|
||||
conversationId: boundThreadId,
|
||||
}) ??
|
||||
boundDeliveryTarget.to ??
|
||||
requesterOrigin?.to?.trim() ??
|
||||
formatConversationTarget({
|
||||
channel: requesterOrigin?.channel,
|
||||
conversationId: deliveryThreadId,
|
||||
});
|
||||
const resolvedDeliveryThreadId = boundDeliveryTarget.threadId ?? deliveryThreadId;
|
||||
const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo);
|
||||
// Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers
|
||||
// decide how to relay status. Inline delivery is reserved for thread-bound sessions.
|
||||
@@ -714,7 +718,7 @@ export async function spawnAcpDirect(
|
||||
channel: useInlineDelivery ? requesterOrigin?.channel : undefined,
|
||||
to: useInlineDelivery ? inferredDeliveryTo : undefined,
|
||||
accountId: useInlineDelivery ? (requesterOrigin?.accountId ?? undefined) : undefined,
|
||||
threadId: useInlineDelivery ? deliveryThreadId : undefined,
|
||||
threadId: useInlineDelivery ? resolvedDeliveryThreadId : undefined,
|
||||
idempotencyKey: childIdem,
|
||||
deliver: useInlineDelivery,
|
||||
label: params.label || undefined,
|
||||
|
||||
@@ -897,7 +897,8 @@ describe("subagent announce formatting", () => {
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||
expect(call?.params?.channel).toBe("matrix");
|
||||
expect(call?.params?.to).toBe("room:$thread-bound-1");
|
||||
expect(call?.params?.to).toBe("room:!room:example");
|
||||
expect(call?.params?.threadId).toBe("$thread-bound-1");
|
||||
});
|
||||
|
||||
it("includes completion status details for error and timeout outcomes", async () => {
|
||||
|
||||
@@ -20,9 +20,9 @@ import { extractTextFromChatContent } from "../shared/chat-content.js";
|
||||
import {
|
||||
type DeliveryContext,
|
||||
deliveryContextFromSession,
|
||||
formatConversationTarget,
|
||||
mergeDeliveryContext,
|
||||
normalizeDeliveryContext,
|
||||
resolveConversationDeliveryTarget,
|
||||
} from "../utils/delivery-context.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
@@ -554,18 +554,21 @@ async function resolveSubagentCompletionOrigin(params: {
|
||||
failClosed: false,
|
||||
});
|
||||
if (route.mode === "bound" && route.binding) {
|
||||
const boundTarget = resolveConversationDeliveryTarget({
|
||||
channel: route.binding.conversation.channel,
|
||||
conversationId: route.binding.conversation.conversationId,
|
||||
parentConversationId: route.binding.conversation.parentConversationId,
|
||||
});
|
||||
return mergeDeliveryContext(
|
||||
{
|
||||
channel: route.binding.conversation.channel,
|
||||
accountId: route.binding.conversation.accountId,
|
||||
to: formatConversationTarget({
|
||||
channel: route.binding.conversation.channel,
|
||||
conversationId: route.binding.conversation.conversationId,
|
||||
}),
|
||||
to: boundTarget.to,
|
||||
threadId:
|
||||
requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
|
||||
boundTarget.threadId ??
|
||||
(requesterOrigin?.threadId != null && requesterOrigin.threadId !== ""
|
||||
? String(requesterOrigin.threadId)
|
||||
: undefined,
|
||||
: undefined),
|
||||
},
|
||||
requesterOrigin,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-storage-paths.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
|
||||
@@ -411,8 +411,7 @@ describe("doctor config flow", () => {
|
||||
|
||||
expect(
|
||||
doctorWarnings.some(
|
||||
(line) =>
|
||||
line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
|
||||
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
describe("matrix account selection", () => {
|
||||
it("resolves configured account ids from non-canonical account keys", () => {
|
||||
|
||||
@@ -1,75 +1,108 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
|
||||
|
||||
function writeFile(filePath: string, value: string) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, value, "utf8");
|
||||
}
|
||||
|
||||
function writeMatrixPluginFixture(rootDir: string): void {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "matrix",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "legacy-crypto-inspector.js"),
|
||||
[
|
||||
"export async function inspectLegacyMatrixCryptoStore() {",
|
||||
' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };',
|
||||
"}",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const matrixHelperEnv = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"),
|
||||
};
|
||||
|
||||
describe("matrix legacy encrypted-state migration", () => {
|
||||
it("extracts a saved backup key into the new recovery-key path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}');
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
|
||||
const inspectLegacyStore = vi.fn(async () => ({
|
||||
deviceId: "DEVICE123",
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}));
|
||||
const inspectLegacyStore = vi.fn(async () => ({
|
||||
deviceId: "DEVICE123",
|
||||
roomKeyCounts: { total: 12, backedUp: 12 },
|
||||
backupVersion: "1",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}));
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: { inspectLegacyStore },
|
||||
});
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: { inspectLegacyStore },
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(inspectLegacyStore).toHaveBeenCalledOnce();
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(inspectLegacyStore).toHaveBeenCalledOnce();
|
||||
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
decryptionKeyImported: boolean;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("pending");
|
||||
expect(state.decryptionKeyImported).toBe(true);
|
||||
});
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
restoreStatus: string;
|
||||
decryptionKeyImported: boolean;
|
||||
};
|
||||
expect(state.restoreStatus).toBe("pending");
|
||||
expect(state.decryptionKeyImported).toBe(true);
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
|
||||
@@ -170,84 +203,89 @@ describe("matrix legacy encrypted-state migration", () => {
|
||||
});
|
||||
|
||||
it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICEOPS" }),
|
||||
);
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
accessToken: "tok-ops",
|
||||
deviceId: "DEVICEOPS",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICEOPS" }),
|
||||
);
|
||||
writeFile(
|
||||
path.join(stateDir, "credentials", "matrix", "credentials-ops.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
accessToken: "tok-ops",
|
||||
deviceId: "DEVICEOPS",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
accessToken: "tok-ops",
|
||||
accountId: "ops",
|
||||
});
|
||||
};
|
||||
const { rootDir } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops-bot:example.org",
|
||||
accessToken: "tok-ops",
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.plans[0]?.accountId).toBe("ops");
|
||||
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
|
||||
expect(detection.warnings).toEqual([]);
|
||||
expect(detection.plans).toHaveLength(1);
|
||||
expect(detection.plans[0]?.accountId).toBe("ops");
|
||||
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: "DEVICEOPS",
|
||||
roomKeyCounts: { total: 6, backedUp: 6 },
|
||||
backupVersion: "21868",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg,
|
||||
env: process.env,
|
||||
deps: {
|
||||
inspectLegacyStore: async () => ({
|
||||
deviceId: "DEVICEOPS",
|
||||
roomKeyCounts: { total: 6, backedUp: 6 },
|
||||
backupVersion: "21868",
|
||||
decryptionKeyBase64: "YWJjZA==",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
accountId: string;
|
||||
};
|
||||
expect(state.accountId).toBe("ops");
|
||||
});
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(result.warnings).toEqual([]);
|
||||
const recovery = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
|
||||
) as {
|
||||
privateKeyBase64: string;
|
||||
};
|
||||
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
|
||||
const state = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"),
|
||||
) as {
|
||||
accountId: string;
|
||||
};
|
||||
expect(state.accountId).toBe("ops");
|
||||
},
|
||||
{ env: matrixHelperEnv },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
|
||||
await withTempHome(
|
||||
async (home) => {
|
||||
writeMatrixPluginFixture(path.join(home, "bundled", "matrix"));
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
writeFile(
|
||||
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
|
||||
@@ -300,6 +338,7 @@ describe("matrix legacy encrypted-state migration", () => {
|
||||
},
|
||||
{
|
||||
env: {
|
||||
...matrixHelperEnv,
|
||||
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
|
||||
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js";
|
||||
import { resolveConfiguredMatrixAccountIds } from "./matrix-account-selection.js";
|
||||
import {
|
||||
resolveLegacyMatrixFlatStoreTarget,
|
||||
resolveMatrixMigrationAccountTarget,
|
||||
@@ -15,7 +18,6 @@ import {
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
type MatrixLegacyCryptoInspector,
|
||||
} from "./matrix-plugin-helper.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
|
||||
|
||||
type MatrixLegacyCryptoCounts = {
|
||||
total: number;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "../../extensions/matrix/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
|
||||
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-storage-paths.js";
|
||||
|
||||
export type MatrixLegacyStateMigrationResult = {
|
||||
migrated: boolean;
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
getMatrixScopedEnvVarNames,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import {
|
||||
findMatrixAccountEntry,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "./matrix-account-selection.js";
|
||||
import { getMatrixScopedEnvVarNames } from "./matrix-env-vars.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixCredentialsPath,
|
||||
} from "./matrix-storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js";
|
||||
import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js";
|
||||
|
||||
const createBackupArchiveMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
||||
@@ -106,12 +106,12 @@ export {
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../infra/matrix-storage-paths.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../infra/matrix-env-vars.js";
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/runtime-api.js";
|
||||
export {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../infra/matrix-account-selection.js";
|
||||
} from "../../extensions/matrix/runtime-api.js";
|
||||
export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js";
|
||||
export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js";
|
||||
export { isPrivateOrLoopbackHost } from "../gateway/net.js";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mergeDeliveryContext,
|
||||
normalizeDeliveryContext,
|
||||
normalizeSessionDeliveryFields,
|
||||
resolveConversationDeliveryTarget,
|
||||
} from "./delivery-context.js";
|
||||
|
||||
describe("delivery context helpers", () => {
|
||||
@@ -85,9 +86,29 @@ describe("delivery context helpers", () => {
|
||||
expect(formatConversationTarget({ channel: "matrix", conversationId: "!room:example" })).toBe(
|
||||
"room:!room:example",
|
||||
);
|
||||
expect(
|
||||
formatConversationTarget({
|
||||
channel: "matrix",
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example",
|
||||
}),
|
||||
).toBe("room:!room:example");
|
||||
expect(formatConversationTarget({ channel: "matrix", conversationId: " " })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves delivery targets for Matrix child threads", () => {
|
||||
expect(
|
||||
resolveConversationDeliveryTarget({
|
||||
channel: "matrix",
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example",
|
||||
}),
|
||||
).toEqual({
|
||||
to: "room:!room:example",
|
||||
threadId: "$thread",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives delivery context from a session entry", () => {
|
||||
expect(
|
||||
deliveryContextFromSession({
|
||||
|
||||
@@ -52,6 +52,7 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon
|
||||
export function formatConversationTarget(params: {
|
||||
channel?: string;
|
||||
conversationId?: string | number;
|
||||
parentConversationId?: string | number;
|
||||
}): string | undefined {
|
||||
const channel =
|
||||
typeof params.channel === "string"
|
||||
@@ -66,7 +67,55 @@ export function formatConversationTarget(params: {
|
||||
if (!channel || !conversationId) {
|
||||
return undefined;
|
||||
}
|
||||
return channel === "matrix" ? `room:${conversationId}` : `channel:${conversationId}`;
|
||||
if (channel === "matrix") {
|
||||
const parentConversationId =
|
||||
typeof params.parentConversationId === "number" &&
|
||||
Number.isFinite(params.parentConversationId)
|
||||
? String(Math.trunc(params.parentConversationId))
|
||||
: typeof params.parentConversationId === "string"
|
||||
? params.parentConversationId.trim()
|
||||
: undefined;
|
||||
const roomId =
|
||||
parentConversationId && parentConversationId !== conversationId
|
||||
? parentConversationId
|
||||
: conversationId;
|
||||
return `room:${roomId}`;
|
||||
}
|
||||
return `channel:${conversationId}`;
|
||||
}
|
||||
|
||||
export function resolveConversationDeliveryTarget(params: {
|
||||
channel?: string;
|
||||
conversationId?: string | number;
|
||||
parentConversationId?: string | number;
|
||||
}): { to?: string; threadId?: string } {
|
||||
const to = formatConversationTarget(params);
|
||||
const channel =
|
||||
typeof params.channel === "string"
|
||||
? (normalizeMessageChannel(params.channel) ?? params.channel.trim())
|
||||
: undefined;
|
||||
const conversationId =
|
||||
typeof params.conversationId === "number" && Number.isFinite(params.conversationId)
|
||||
? String(Math.trunc(params.conversationId))
|
||||
: typeof params.conversationId === "string"
|
||||
? params.conversationId.trim()
|
||||
: undefined;
|
||||
const parentConversationId =
|
||||
typeof params.parentConversationId === "number" && Number.isFinite(params.parentConversationId)
|
||||
? String(Math.trunc(params.parentConversationId))
|
||||
: typeof params.parentConversationId === "string"
|
||||
? params.parentConversationId.trim()
|
||||
: undefined;
|
||||
if (
|
||||
channel === "matrix" &&
|
||||
to &&
|
||||
conversationId &&
|
||||
parentConversationId &&
|
||||
parentConversationId !== conversationId
|
||||
) {
|
||||
return { to, threadId: conversationId };
|
||||
}
|
||||
return { to };
|
||||
}
|
||||
|
||||
export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): {
|
||||
|
||||
Reference in New Issue
Block a user