diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 6fecfa5ffa3..fce8376792c 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,6 +1,5 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; import { matrixPlugin } from "./src/channel.js"; -import { registerMatrixCli } from "./src/cli.js"; import { setMatrixRuntime } from "./src/runtime.js"; export { matrixPlugin } from "./src/channel.js"; @@ -41,7 +40,8 @@ export default defineChannelPluginEntry({ }); api.registerCli( - ({ program }) => { + async ({ program }) => { + const { registerMatrixCli } = await import("./src/cli.js"); registerMatrixCli({ program }); }, { commands: ["matrix"] }, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 2f97a6020a8..49659f59246 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,4 +1,5 @@ import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, @@ -12,8 +13,13 @@ import { createAllowlistProviderOpenWarningCollector, projectAccountConfigWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; -import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; +import { + buildChannelConfigSchema, + createChatChannelPlugin, + type ChannelPlugin, +} from "openclaw/plugin-sdk/core"; import { createChannelDirectoryAdapter, createResolvedDirectoryEntriesLister, @@ -23,6 +29,8 @@ import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared" import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime"; import { + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; @@ -46,15 +54,6 @@ import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity, } from "./matrix/target-ids.js"; -import { - buildChannelConfigSchema, - buildProbeChannelStatusSummary, - chunkTextForOutbound, - collectStatusIssuesFromLastError, - DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, - type ChannelPlugin, -} from "./runtime-api.js"; import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; @@ -63,6 +62,22 @@ import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) let matrixStartupLock: Promise = Promise.resolve(); +function chunkTextForOutbound(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const splitAt = Math.max(window.lastIndexOf("\n"), window.lastIndexOf(" ")); + const breakAt = splitAt > 0 ? splitAt : limit; + chunks.push(remaining.slice(0, breakAt).trimEnd()); + remaining = remaining.slice(breakAt).trimStart(); + } + if (remaining.length > 0 || text.length === 0) { + chunks.push(remaining); + } + return chunks; +} + const loadMatrixChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), "matrixChannelRuntime", diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 890a5649a35..756692c8485 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -24,7 +24,6 @@ import { import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js"; import { formatZonedTimestamp, normalizeAccountId, type ChannelSetupInput } from "./runtime-api.js"; import { getMatrixRuntime } from "./runtime.js"; -import { maybeBootstrapNewEncryptedMatrixAccount } from "./setup-bootstrap.js"; import { matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; @@ -215,6 +214,7 @@ async function addMatrixAccount(params: { backupVersion: null, }; if (accountConfig.encryption === true) { + const { maybeBootstrapNewEncryptedMatrixAccount } = await import("./setup-bootstrap.js"); verificationBootstrap = await maybeBootstrapNewEncryptedMatrixAccount({ previousCfg: cfg, cfg: updated, diff --git a/extensions/matrix/src/matrix/client/env-auth.ts b/extensions/matrix/src/matrix/client/env-auth.ts new file mode 100644 index 00000000000..c9aee208e6f --- /dev/null +++ b/extensions/matrix/src/matrix/client/env-auth.ts @@ -0,0 +1,92 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { getMatrixScopedEnvVarNames } from "../../env-vars.js"; + +type MatrixEnvConfig = { + homeserver: string; + userId: string; + accessToken?: string; + password?: string; + deviceId?: string; + deviceName?: string; +}; + +function clean(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig { + return { + homeserver: clean(env.MATRIX_HOMESERVER), + userId: clean(env.MATRIX_USER_ID), + accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined, + password: clean(env.MATRIX_PASSWORD) || undefined, + deviceId: clean(env.MATRIX_DEVICE_ID) || undefined, + deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined, + }; +} + +export function resolveScopedMatrixEnvConfig( + accountId: string, + env: NodeJS.ProcessEnv = process.env, +): MatrixEnvConfig { + const keys = getMatrixScopedEnvVarNames(accountId); + return { + homeserver: clean(env[keys.homeserver]), + userId: clean(env[keys.userId]), + accessToken: clean(env[keys.accessToken]) || undefined, + password: clean(env[keys.password]) || undefined, + deviceId: clean(env[keys.deviceId]) || undefined, + deviceName: clean(env[keys.deviceName]) || undefined, + }; +} + +export function hasReadyMatrixEnvAuth(config: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; +}): boolean { + const homeserver = clean(config.homeserver); + const userId = clean(config.userId); + const accessToken = clean(config.accessToken); + const password = clean(config.password); + return Boolean(homeserver && (accessToken || (userId && password))); +} + +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); + if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) { + const keys = getMatrixScopedEnvVarNames(normalizedAccountId); + return { + ready: hasReadyMatrixEnvAuth(scoped), + 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 defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID); + return { + ready: hasReadyMatrixEnvAuth(defaultScoped) || hasReadyMatrixEnvAuth(global), + 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}).`, + }; +} diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts index 9faac056a8d..73239bd9c61 100644 --- a/extensions/matrix/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime"; -import { normalizeAccountId, normalizeSecretInputString } from "../runtime-api.js"; +import { normalizeSecretInputString } from "openclaw/plugin-sdk/setup"; import type { CoreConfig, MatrixConfig } from "../types.js"; import { findMatrixAccountConfig } from "./account-config.js"; diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 9298ef10b75..72ecc2810a6 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -9,10 +9,10 @@ import { resolveMatrixAccountConfig, } from "./matrix/accounts.js"; import { - resolveMatrixEnvAuthReadiness, resolveValidatedMatrixHomeserverUrl, validateMatrixHomeserverUrl, } from "./matrix/client.js"; +import { resolveMatrixEnvAuthReadiness } from "./matrix/client/env-auth.js"; import { resolveMatrixConfigFieldPath, resolveMatrixConfigPath, @@ -34,7 +34,6 @@ import { type RuntimeEnv, type WizardPrompter, } from "./runtime-api.js"; -import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; @@ -644,6 +643,7 @@ export const matrixOnboardingAdapter: MatrixOnboardingAdapter = { }); }, afterConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => { + const { runMatrixSetupBootstrapAfterConfigWrite } = await import("./setup-bootstrap.js"); await runMatrixSetupBootstrapAfterConfigWrite({ previousCfg: previousCfg as CoreConfig, cfg: cfg as CoreConfig, diff --git a/extensions/matrix/src/setup-config.ts b/extensions/matrix/src/setup-config.ts index f1847fb2b0d..9bada4b11e3 100644 --- a/extensions/matrix/src/setup-config.ts +++ b/extensions/matrix/src/setup-config.ts @@ -1,16 +1,161 @@ -import { resolveMatrixEnvAuthReadiness } from "./matrix/client.js"; -import { updateMatrixAccountConfig } from "./matrix/config-update.js"; import { applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - moveSingleAccountChannelSectionToDefaultAccount, normalizeAccountId, normalizeSecretInputString, type ChannelSetupInput, -} from "./runtime-api.js"; +} from "openclaw/plugin-sdk/setup"; +import { resolveMatrixEnvAuthReadiness } from "./matrix/client/env-auth.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ + "name", + "enabled", + "httpPort", + "webhookPath", + "webhookUrl", + "webhookSecret", + "service", + "region", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "url", + "code", + "dmPolicy", + "allowFrom", + "groupPolicy", + "groupAllowFrom", + "defaultTo", +]); +const MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "allowBots", + "replyToMode", + "threadReplies", + "textChunkLimit", + "chunkMode", + "responsePrefix", + "ackReaction", + "ackReactionScope", + "reactionNotifications", + "threadBindings", + "startupVerification", + "startupVerificationCooldownHours", + "mediaMaxMb", + "autoJoin", + "autoJoinAllowlist", + "dm", + "groups", + "rooms", + "actions", +]); +const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", +]); + +function cloneIfObject(value: T): T { + if (value && typeof value === "object") { + return structuredClone(value); + } + return value; +} + +function moveSingleMatrixAccountConfigToNamedAccount(cfg: CoreConfig): CoreConfig { + const channels = cfg.channels as Record | undefined; + const baseConfig = channels?.[channel]; + const base = + typeof baseConfig === "object" && baseConfig + ? (baseConfig as Record) + : undefined; + if (!base) { + return cfg; + } + + const accounts = + typeof base.accounts === "object" && base.accounts + ? (base.accounts as Record>) + : {}; + const hasNamedAccounts = Object.keys(accounts).filter(Boolean).length > 0; + const keysToMove = Object.entries(base) + .filter(([key, value]) => { + if (key === "accounts" || key === "enabled" || value === undefined) { + return false; + } + if ( + !COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(key) && + !MATRIX_SINGLE_ACCOUNT_KEYS_TO_MOVE.has(key) + ) { + return false; + } + if (hasNamedAccounts && !MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS.has(key)) { + return false; + } + return true; + }) + .map(([key]) => key); + if (keysToMove.length === 0) { + return cfg; + } + + const defaultAccount = + typeof base.defaultAccount === "string" && base.defaultAccount.trim() + ? normalizeAccountId(base.defaultAccount) + : undefined; + const targetAccountId = + defaultAccount && defaultAccount !== DEFAULT_ACCOUNT_ID + ? (Object.entries(accounts).find( + ([accountId, value]) => + accountId && + value && + typeof value === "object" && + normalizeAccountId(accountId) === defaultAccount, + )?.[0] ?? DEFAULT_ACCOUNT_ID) + : (defaultAccount ?? + (Object.keys(accounts).filter(Boolean).length === 1 + ? Object.keys(accounts).filter(Boolean)[0] + : DEFAULT_ACCOUNT_ID)); + + const nextAccount: Record = { ...(accounts[targetAccountId] ?? {}) }; + for (const key of keysToMove) { + nextAccount[key] = cloneIfObject(base[key]); + } + const nextChannel = { ...base }; + for (const key of keysToMove) { + delete nextChannel[key]; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...nextChannel, + accounts: { + ...accounts, + [targetAccountId]: nextAccount, + }, + }, + }, + }; +} export function validateMatrixSetupInput(params: { accountId: string; @@ -49,10 +194,7 @@ export function applyMatrixSetupAccountConfig(params: { const normalizedAccountId = normalizeAccountId(params.accountId); const migratedCfg = normalizedAccountId !== DEFAULT_ACCOUNT_ID - ? (moveSingleAccountChannelSectionToDefaultAccount({ - cfg: params.cfg, - channelKey: channel, - }) as CoreConfig) + ? moveSingleMatrixAccountConfigToNamedAccount(params.cfg) : params.cfg; const next = applyAccountNameToChannelSection({ cfg: migratedCfg, diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index d6ea1649cd1..a78ccf30b90 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -5,7 +5,6 @@ import { type ChannelSetupAdapter, } from "openclaw/plugin-sdk/setup"; import { updateMatrixAccountConfig } from "./matrix/config-update.js"; -import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js"; import { applyMatrixSetupAccountConfig, validateMatrixSetupInput } from "./setup-config.js"; import type { CoreConfig } from "./types.js"; @@ -65,6 +64,7 @@ export const matrixSetupAdapter: ChannelSetupAdapter = { input, }), afterAccountConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => { + const { runMatrixSetupBootstrapAfterConfigWrite } = await import("./setup-bootstrap.js"); await runMatrixSetupBootstrapAfterConfigWrite({ previousCfg: previousCfg as CoreConfig, cfg: cfg as CoreConfig,