diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 802564241..f761732af 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -1,5 +1,5 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; -import type { CoreConfig, MatrixConfig } from "../types.js"; +import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js"; import { resolveMatrixConfig } from "./client.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; @@ -10,11 +10,53 @@ export type ResolvedMatrixAccount = { configured: boolean; homeserver?: string; userId?: string; - config: MatrixConfig; + accessToken?: string; + config: MatrixAccountConfig; }; -export function listMatrixAccountIds(_cfg: CoreConfig): string[] { - return [DEFAULT_ACCOUNT_ID]; +/** + * List account IDs explicitly configured in channels.matrix.accounts + */ +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) continue; + ids.add(normalizeAccountId(key)); + } + return [...ids]; +} + +/** + * List account IDs referenced in bindings for matrix channel + */ +function listBoundAccountIds(cfg: CoreConfig): string[] { + const bindings = cfg.bindings; + if (!Array.isArray(bindings)) return []; + const ids = new Set(); + for (const binding of bindings) { + if (binding.match?.channel === "matrix" && binding.match?.accountId) { + ids.add(normalizeAccountId(binding.match.accountId)); + } + } + return [...ids]; +} + +/** + * List all Matrix account IDs (configured + bound) + */ +export function listMatrixAccountIds(cfg: CoreConfig): string[] { + const ids = Array.from( + new Set([ + DEFAULT_ACCOUNT_ID, + ...listConfiguredAccountIds(cfg), + ...listBoundAccountIds(cfg), + ]), + ); + return ids.toSorted((a, b) => a.localeCompare(b)); } export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { @@ -23,41 +65,100 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { return ids[0] ?? DEFAULT_ACCOUNT_ID; } +/** + * Get account-specific config from channels.matrix.accounts[accountId] + */ +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): MatrixAccountConfig | undefined { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as MatrixAccountConfig | undefined; + if (direct) return direct; + + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find( + (key) => normalizeAccountId(key) === normalized + ); + return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined; +} + +/** + * Merge base matrix config with account-specific overrides + */ +function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig { + const base = cfg.channels?.matrix ?? {}; + // Extract base config without 'accounts' key + const { accounts: _ignored, ...baseConfig } = base as MatrixConfig; + const accountConfig = resolveAccountConfig(cfg, accountId) ?? {}; + + // Account config overrides base config + return { ...baseConfig, ...accountConfig }; +} + export function resolveMatrixAccount(params: { cfg: CoreConfig; accountId?: string | null; }): ResolvedMatrixAccount { const accountId = normalizeAccountId(params.accountId); - const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig; - const enabled = base.enabled !== false; - const resolved = resolveMatrixConfig(params.cfg, process.env); - const hasHomeserver = Boolean(resolved.homeserver); - const hasUserId = Boolean(resolved.userId); - const hasAccessToken = Boolean(resolved.accessToken); - const hasPassword = Boolean(resolved.password); + const merged = mergeMatrixAccountConfig(params.cfg, accountId); + + // Check if this is a non-default account - use account-specific auth + const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID || accountId === "default"; + + // For non-default accounts, use account-specific credentials + // For default account, use base config or env + let homeserver = merged.homeserver; + let userId = merged.userId; + let accessToken = merged.accessToken; + + if (isDefaultAccount) { + // Default account can fall back to env vars + const resolved = resolveMatrixConfig(params.cfg, process.env); + homeserver = homeserver || resolved.homeserver; + userId = userId || resolved.userId; + accessToken = accessToken || resolved.accessToken; + } + + const baseEnabled = params.cfg.channels?.matrix?.enabled !== false; + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const hasHomeserver = Boolean(homeserver); + const hasAccessToken = Boolean(accessToken); + const hasPassword = Boolean(merged.password); + const hasUserId = Boolean(userId); const hasPasswordAuth = hasUserId && hasPassword; - const stored = loadMatrixCredentials(process.env); + + // Check for stored credentials (only for default account) + const stored = isDefaultAccount ? loadMatrixCredentials(process.env) : null; const hasStored = - stored && resolved.homeserver + stored && homeserver ? credentialsMatchConfig(stored, { - homeserver: resolved.homeserver, - userId: resolved.userId || "", + homeserver: homeserver, + userId: userId || "", }) : false; + const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored)); + return { accountId, enabled, - name: base.name?.trim() || undefined, + name: merged.name?.trim() || undefined, configured, - homeserver: resolved.homeserver || undefined, - userId: resolved.userId || undefined, - config: base, + homeserver: homeserver || undefined, + userId: userId || undefined, + accessToken: accessToken || undefined, + config: merged, }; } export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] { return listMatrixAccountIds(cfg) .map((accountId) => resolveMatrixAccount({ cfg, accountId })) - .filter((account) => account.enabled); + .filter((account) => account.enabled && account.configured); } diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 3dafcf724..cb9cc38b3 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,6 +1,7 @@ import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; -import type { CoreConfig } from "../types.js"; +import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js"; import { getMatrixRuntime } from "../../runtime.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -9,23 +10,60 @@ function clean(value?: string): string { return value?.trim() ?? ""; } +/** + * Get account-specific config from channels.matrix.accounts[accountId] + */ +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): MatrixAccountConfig | undefined { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as MatrixAccountConfig | undefined; + if (direct) return direct; + + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find( + (key) => normalizeAccountId(key) === normalized + ); + return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined; +} + +/** + * Merge base matrix config with account-specific overrides + */ +function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig { + const base = cfg.channels?.matrix ?? {}; + const { accounts: _ignored, ...baseConfig } = base as MatrixConfig; + const accountConfig = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...baseConfig, ...accountConfig }; +} + export function resolveMatrixConfig( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, env: NodeJS.ProcessEnv = process.env, + accountId?: string, ): MatrixResolvedConfig { - const matrix = cfg.channels?.matrix ?? {}; - const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); - const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); - const accessToken = - clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; - const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; - const deviceName = - clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default"; + + // Get merged config for this account + const merged = mergeMatrixAccountConfig(cfg, normalizedAccountId); + + // For default account, allow env var fallbacks + const homeserver = clean(merged.homeserver) || (isDefaultAccount ? clean(env.MATRIX_HOMESERVER) : ""); + const userId = clean(merged.userId) || (isDefaultAccount ? clean(env.MATRIX_USER_ID) : ""); + const accessToken = clean(merged.accessToken) || (isDefaultAccount ? clean(env.MATRIX_ACCESS_TOKEN) : "") || undefined; + const password = clean(merged.password) || (isDefaultAccount ? clean(env.MATRIX_PASSWORD) : "") || undefined; + const deviceName = clean(merged.deviceName) || (isDefaultAccount ? clean(env.MATRIX_DEVICE_NAME) : "") || undefined; const initialSyncLimit = - typeof matrix.initialSyncLimit === "number" - ? Math.max(0, Math.floor(matrix.initialSyncLimit)) + typeof merged.initialSyncLimit === "number" + ? Math.max(0, Math.floor(merged.initialSyncLimit)) : undefined; - const encryption = matrix.encryption ?? false; + const encryption = merged.encryption ?? false; + return { homeserver, userId, @@ -40,14 +78,21 @@ export function resolveMatrixConfig( export async function resolveMatrixAuth(params?: { cfg?: CoreConfig; env?: NodeJS.ProcessEnv; + accountId?: string; }): Promise { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; - const resolved = resolveMatrixConfig(cfg, env); + const accountId = params?.accountId; + const resolved = resolveMatrixConfig(cfg, env, accountId); + if (!resolved.homeserver) { - throw new Error("Matrix homeserver is required (matrix.homeserver)"); + throw new Error(`Matrix homeserver is required for account ${accountId ?? "default"} (matrix.homeserver)`); } + const normalizedAccountId = normalizeAccountId(accountId); + const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default"; + + // Only use cached credentials for default account const { loadMatrixCredentials, saveMatrixCredentials, @@ -55,7 +100,7 @@ export async function resolveMatrixAuth(params?: { touchMatrixCredentials, } = await import("../credentials.js"); - const cached = loadMatrixCredentials(env); + const cached = isDefaultAccount ? loadMatrixCredentials(env) : null; const cachedCredentials = cached && credentialsMatchConfig(cached, { @@ -74,13 +119,15 @@ export async function resolveMatrixAuth(params?: { const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); const whoami = await tempClient.getUserId(); userId = whoami; - // Save the credentials with the fetched userId - saveMatrixCredentials({ - homeserver: resolved.homeserver, - userId, - accessToken: resolved.accessToken, - }); - } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + // Only save credentials for default account + if (isDefaultAccount) { + saveMatrixCredentials({ + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }); + } + } else if (isDefaultAccount && cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { touchMatrixCredentials(env); } return { @@ -93,7 +140,8 @@ export async function resolveMatrixAuth(params?: { }; } - if (cachedCredentials) { + // Try cached credentials (only for default account) + if (isDefaultAccount && cachedCredentials) { touchMatrixCredentials(env); return { homeserver: cachedCredentials.homeserver, @@ -107,13 +155,13 @@ export async function resolveMatrixAuth(params?: { if (!resolved.userId) { throw new Error( - "Matrix userId is required when no access token is configured (matrix.userId)", + `Matrix userId is required for account ${accountId ?? "default"} when no access token is configured`, ); } if (!resolved.password) { throw new Error( - "Matrix password is required when no access token is configured (matrix.password)", + `Matrix password is required for account ${accountId ?? "default"} when no access token is configured`, ); } @@ -131,7 +179,7 @@ export async function resolveMatrixAuth(params?: { if (!loginResponse.ok) { const errorText = await loginResponse.text(); - throw new Error(`Matrix login failed: ${errorText}`); + throw new Error(`Matrix login failed for account ${accountId ?? "default"}: ${errorText}`); } const login = (await loginResponse.json()) as { @@ -142,7 +190,7 @@ export async function resolveMatrixAuth(params?: { const accessToken = login.access_token?.trim(); if (!accessToken) { - throw new Error("Matrix login did not return an access token"); + throw new Error(`Matrix login did not return an access token for account ${accountId ?? "default"}`); } const auth: MatrixAuth = { @@ -154,12 +202,15 @@ export async function resolveMatrixAuth(params?: { encryption: resolved.encryption, }; - saveMatrixCredentials({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - deviceId: login.device_id, - }); + // Only save credentials for default account + if (isDefaultAccount) { + saveMatrixCredentials({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + deviceId: login.device_id, + }); + } return auth; } diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index ca3e745bb..14ff18d85 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -163,7 +163,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }, }; - const auth = await resolveMatrixAuth({ cfg }); + const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId }); const resolvedInitialSyncLimit = typeof opts.initialSyncLimit === "number" ? Math.max(0, Math.floor(opts.initialSyncLimit)) diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index f03734130..17ff35a7a 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -38,10 +38,10 @@ export type MatrixActionConfig = { channelInfo?: boolean; }; -export type MatrixConfig = { +export type MatrixAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; - /** If false, do not start Matrix. Default: true. */ + /** If false, do not start this account. Default: true. */ enabled?: boolean; /** Matrix homeserver URL (https://matrix.example.org). */ homeserver?: string; @@ -87,9 +87,22 @@ export type MatrixConfig = { actions?: MatrixActionConfig; }; +export type MatrixConfig = { + /** Optional per-account Matrix configuration (multi-account). */ + accounts?: Record; +} & MatrixAccountConfig; + export type CoreConfig = { channels?: { matrix?: MatrixConfig; }; + bindings?: Array<{ + agentId?: string; + match?: { + channel?: string; + accountId?: string; + peer?: { kind?: string; id?: string }; + }; + }>; [key: string]: unknown; };