From 2d41d5e98be67c96bf835d090de20a7d70644da1 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Mar 2026 03:08:29 +0000 Subject: [PATCH] Matrix: move helper seams and fix routing --- extensions/matrix/runtime-api.ts | 3 + extensions/matrix/src/account-selection.ts | 96 +++++++ extensions/matrix/src/actions.ts | 2 +- extensions/matrix/src/channel.setup.test.ts | 33 +++ extensions/matrix/src/channel.ts | 16 +- extensions/matrix/src/env-vars.ts | 30 ++ extensions/matrix/src/matrix/accounts.ts | 2 +- extensions/matrix/src/matrix/client.ts | 3 +- extensions/matrix/src/matrix/client/config.ts | 49 +++- .../matrix/src/matrix/client/storage.ts | 11 +- extensions/matrix/src/matrix/credentials.ts | 8 +- extensions/matrix/src/onboarding.test.ts | 79 +++++ extensions/matrix/src/onboarding.ts | 48 +--- extensions/matrix/src/setup-core.ts | 6 +- extensions/matrix/src/storage-paths.ts | 93 ++++++ src/agents/acp-spawn.test.ts | 2 +- src/agents/acp-spawn.ts | 14 +- .../subagent-announce.format.e2e.test.ts | 3 +- src/agents/subagent-announce.ts | 17 +- src/commands/doctor-config-flow.test.ts | 5 +- src/infra/matrix-account-selection.test.ts | 4 +- src/infra/matrix-legacy-crypto.test.ts | 271 ++++++++++-------- src/infra/matrix-legacy-crypto.ts | 6 +- src/infra/matrix-legacy-state.ts | 2 +- src/infra/matrix-migration-config.ts | 22 +- src/infra/matrix-migration-snapshot.test.ts | 2 +- src/plugin-sdk/matrix.ts | 6 +- src/utils/delivery-context.test.ts | 21 ++ src/utils/delivery-context.ts | 51 +++- 29 files changed, 687 insertions(+), 218 deletions(-) create mode 100644 extensions/matrix/runtime-api.ts create mode 100644 extensions/matrix/src/account-selection.ts create mode 100644 extensions/matrix/src/env-vars.ts create mode 100644 extensions/matrix/src/storage-paths.ts diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts new file mode 100644 index 00000000000..1ed6a08fbc3 --- /dev/null +++ b/extensions/matrix/runtime-api.ts @@ -0,0 +1,3 @@ +export * from "./src/account-selection.js"; +export * from "./src/env-vars.js"; +export * from "./src/storage-paths.js"; diff --git a/extensions/matrix/src/account-selection.ts b/extensions/matrix/src/account-selection.ts new file mode 100644 index 00000000000..337cdc61ac3 --- /dev/null +++ b/extensions/matrix/src/account-selection.ts @@ -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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +export function findMatrixAccountEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | 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)); +} diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index f99245c77f3..57f19b938df 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -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"; diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts index d515f58d228..07f61ef3469 100644 --- a/extensions/matrix/src/channel.setup.test.ts +++ b/extensions/matrix/src/channel.setup.test.ts @@ -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; + } + } + } + }); }); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index ca7116270d5..0287339f364 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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 = { 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"; diff --git a/extensions/matrix/src/env-vars.ts b/extensions/matrix/src/env-vars.ts new file mode 100644 index 00000000000..181b1bd3ff5 --- /dev/null +++ b/extensions/matrix/src/env-vars.ts @@ -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`, + }; +} diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 1d576b02f1e..cfb10d743c4 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -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, diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index c6b4f9ef653..9fe0f667678 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -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, diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 32a12d06f71..1a9eab29c27 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -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, diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index c40a8e1c505..e6671de82c2 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index 3a492774c74..8efa77e45f4 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -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; diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 1ee7b29f163..2107fa2ec05 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -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: { diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index b407195d775..acb5354f3f3 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -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) { diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index 5e5973bd05e..506ac34012b 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -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"; diff --git a/extensions/matrix/src/storage-paths.ts b/extensions/matrix/src/storage-paths.ts new file mode 100644 index 00000000000..5e1a3d394c3 --- /dev/null +++ b/extensions/matrix/src/storage-paths.ts @@ -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, + }; +} diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 18c75a673be..b0bab2d0170 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -452,7 +452,7 @@ describe("spawnAcpDirect", () => { .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .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"); }); diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index b35a9be923b..1e9a72fff8b 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -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, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 583eb117f0a..c17714e1795 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -897,7 +897,8 @@ describe("subagent announce formatting", () => { expect(agentSpy).toHaveBeenCalledTimes(1); const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; 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 () => { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index eea8b5156e3..eeef9db6b9b 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -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, ); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index c5acc5cb7a1..44258d6a39d 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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); }); diff --git a/src/infra/matrix-account-selection.test.ts b/src/infra/matrix-account-selection.test.ts index a8cff25e005..69da21d8ab0 100644 --- a/src/infra/matrix-account-selection.test.ts +++ b/src/infra/matrix-account-selection.test.ts @@ -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", () => { diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts index 2e3d52fdd24..08501260943 100644 --- a/src/infra/matrix-legacy-crypto.test.ts +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -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", diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts index 00caf4c10f2..1e0d5050ab8 100644 --- a/src/infra/matrix-legacy-crypto.ts +++ b/src/infra/matrix-legacy-crypto.ts @@ -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; diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts index 1bbecebe261..050ae7dd793 100644 --- a/src/infra/matrix-legacy-state.ts +++ b/src/infra/matrix-legacy-state.ts @@ -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; diff --git a/src/infra/matrix-migration-config.ts b/src/infra/matrix-migration-config.ts index bb991e4cc27..3eead4cfb16 100644 --- a/src/infra/matrix-migration-config.ts +++ b/src/infra/matrix-migration-config.ts @@ -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; diff --git a/src/infra/matrix-migration-snapshot.test.ts b/src/infra/matrix-migration-snapshot.test.ts index 31d24f0fdc7..2d0fb850109 100644 --- a/src/infra/matrix-migration-snapshot.test.ts +++ b/src/infra/matrix-migration-snapshot.test.ts @@ -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()); diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 497c5a720c5..9a265cf248b 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -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"; diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 3c62f7f5a39..1328e03977b 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -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({ diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 6c0fd829e14..7eeb75d02c6 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -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): {