From caf5d2dd7c241ab66b5f293a9dad31f254fc9213 Mon Sep 17 00:00:00 2001 From: Monty Taylor Date: Mon, 2 Feb 2026 07:28:39 -0800 Subject: [PATCH] feat(matrix): Add multi-account support to Matrix channel The Matrix channel previously hardcoded `listMatrixAccountIds` to always return only `DEFAULT_ACCOUNT_ID`, ignoring any accounts configured in `channels.matrix.accounts`. This prevented running multiple Matrix bot accounts simultaneously. Changes: - Update `listMatrixAccountIds` to read from `channels.matrix.accounts` config, falling back to `DEFAULT_ACCOUNT_ID` for legacy single-account configurations - Add `resolveMatrixConfigForAccount` to resolve config for a specific account ID, merging account-specific values with top-level defaults - Update `resolveMatrixAccount` to use account-specific config when available - The multi-account config structure (channels.matrix.accounts) was not defined in the MatrixConfig type, causing TypeScript to not recognize the field. Added the accounts field to properly type the multi-account configuration. - Add stopSharedClientForAccount() to stop only the specific account's client instead of all clients when an account shuts down - Wrap dynamic import in try/finally to prevent startup mutex deadlock if the import fails - Pass accountId to resolveSharedMatrixClient(), resolveMatrixAuth(), and createMatrixClient() to ensure the correct account's credentials are used for outbound messages - Add accountId parameter to resolveMediaMaxBytes to check account-specific config before falling back to top-level config - Maintain backward compatibility with existing single-account setups This follows the same pattern already used by the WhatsApp channel for multi-account support. Fixes #3165 Fixes #3085 Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 5 + docs/channels/matrix.md | 42 ++++++++ extensions/matrix/src/channel.ts | 36 ++++++- extensions/matrix/src/matrix/accounts.ts | 42 ++++++-- .../matrix/src/matrix/actions/client.ts | 8 +- extensions/matrix/src/matrix/actions/types.ts | 1 + extensions/matrix/src/matrix/active-client.ts | 31 +++++- extensions/matrix/src/matrix/client.ts | 13 ++- extensions/matrix/src/matrix/client/config.ts | 71 ++++++++++---- extensions/matrix/src/matrix/client/shared.ts | 97 ++++++++++++------- extensions/matrix/src/matrix/credentials.ts | 42 +++++--- .../matrix/src/matrix/monitor/handler.ts | 3 + extensions/matrix/src/matrix/monitor/index.ts | 34 ++++--- extensions/matrix/src/matrix/send.ts | 4 +- extensions/matrix/src/matrix/send/client.ts | 27 +++++- extensions/matrix/src/outbound.ts | 9 +- extensions/matrix/src/types.ts | 5 + 17 files changed, 367 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f81c44e4f4..0953c1c885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -236,6 +236,10 @@ Docs: https://docs.openclaw.ai - Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. +- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras. +- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. +- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123. +- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#3165, #3085) Thanks @emonty. ## 2026.2.6 @@ -332,6 +336,7 @@ Docs: https://docs.openclaw.ai - macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety. - Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai. + ## 2026.2.2-3 ### Fixes diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 68a5ac5050..93bcaada56 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -136,6 +136,47 @@ When E2EE is enabled, the bot will request verification from your other sessions Open Element (or another client) and approve the verification request to establish trust. Once verified, the bot can decrypt messages in encrypted rooms. +## Multi-account + +Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. + +Each account runs as a separate Matrix user on any homeserver. Per-account config +inherits from the top-level `channels.matrix` settings and can override any option +(DM policy, groups, encryption, etc.). + +```json5 +{ + channels: { + matrix: { + enabled: true, + dm: { policy: "pairing" }, + accounts: { + assistant: { + name: "Main assistant", + homeserver: "https://matrix.example.org", + accessToken: "syt_assistant_***", + encryption: true, + }, + alerts: { + name: "Alerts bot", + homeserver: "https://matrix.example.org", + accessToken: "syt_alerts_***", + dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] }, + }, + }, + }, + }, +} +``` + +Notes: + +- Account startup is serialized to avoid race conditions with concurrent module imports. +- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account. +- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account. +- Use `bindings[].match.accountId` to route each account to a different agent. +- Crypto state is stored per account + access token (separate key stores per account). + ## Routing model - Replies always go back to Matrix. @@ -256,4 +297,5 @@ Provider options: - `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). - `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. +- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings). - `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo). diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 366f74ade0..26b794c9bd 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -31,6 +31,9 @@ import { matrixOnboardingAdapter } from "./onboarding.js"; import { matrixOutbound } from "./outbound.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; +// Mutex for serializing account startup (workaround for concurrent dynamic import race condition) +let matrixStartupLock: Promise = Promise.resolve(); + const meta = { id: "matrix", label: "Matrix", @@ -383,9 +386,12 @@ export const matrixPlugin: ChannelPlugin = { probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - probeAccount: async ({ timeoutMs, cfg }) => { + probeAccount: async ({ account, timeoutMs, cfg }) => { try { - const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig }); + const auth = await resolveMatrixAuth({ + cfg: cfg as CoreConfig, + accountId: account.accountId, + }); return await probeMatrix({ homeserver: auth.homeserver, accessToken: auth.accessToken, @@ -424,8 +430,32 @@ export const matrixPlugin: ChannelPlugin = { baseUrl: account.homeserver, }); ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`); + + // Serialize startup: wait for any previous startup to complete import phase. + // This works around a race condition with concurrent dynamic imports. + // + // INVARIANT: The import() below cannot hang because: + // 1. It only loads local ESM modules with no circular awaits + // 2. Module initialization is synchronous (no top-level await in ./matrix/index.js) + // 3. The lock only serializes the import phase, not the provider startup + const previousLock = matrixStartupLock; + let releaseLock: () => void = () => {}; + matrixStartupLock = new Promise((resolve) => { + releaseLock = resolve; + }); + await previousLock; + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - const { monitorMatrixProvider } = await import("./matrix/index.js"); + // Wrap in try/finally to ensure lock is released even if import fails. + let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider; + try { + const module = await import("./matrix/index.js"); + monitorMatrixProvider = module.monitorMatrixProvider; + } finally { + // Release lock after import completes or fails + releaseLock(); + } + return monitorMatrixProvider({ runtime: ctx.runtime, abortSignal: ctx.abortSignal, diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 99593b8a3c..385c99864a 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -1,6 +1,6 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; import type { CoreConfig, MatrixConfig } from "../types.js"; -import { resolveMatrixConfig } from "./client.js"; +import { resolveMatrixConfigForAccount } from "./client.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; export type ResolvedMatrixAccount = { @@ -13,8 +13,21 @@ export type ResolvedMatrixAccount = { config: MatrixConfig; }; -export function listMatrixAccountIds(_cfg: CoreConfig): string[] { - return [DEFAULT_ACCOUNT_ID]; +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + return Object.keys(accounts).filter(Boolean); +} + +export function listMatrixAccountIds(cfg: CoreConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + // Fall back to default if no accounts configured (legacy top-level config) + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); } export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { @@ -25,20 +38,35 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { return ids[0] ?? DEFAULT_ACCOUNT_ID; } +function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + return accounts[accountId] as MatrixConfig | undefined; +} + export function resolveMatrixAccount(params: { cfg: CoreConfig; accountId?: string | null; }): ResolvedMatrixAccount { const accountId = normalizeAccountId(params.accountId); - const base = params.cfg.channels?.matrix ?? {}; - const enabled = base.enabled !== false; - const resolved = resolveMatrixConfig(params.cfg, process.env); + const matrixBase = params.cfg.channels?.matrix ?? {}; + + // Check if this account exists in accounts structure + const accountConfig = resolveAccountConfig(params.cfg, accountId); + + // Use account-specific config if available, otherwise fall back to top-level + const base: MatrixConfig = accountConfig ?? matrixBase; + const enabled = base.enabled !== false && matrixBase.enabled !== false; + + const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env); const hasHomeserver = Boolean(resolved.homeserver); const hasUserId = Boolean(resolved.userId); const hasAccessToken = Boolean(resolved.accessToken); const hasPassword = Boolean(resolved.password); const hasPasswordAuth = hasUserId && hasPassword; - const stored = loadMatrixCredentials(process.env); + const stored = loadMatrixCredentials(process.env, accountId); const hasStored = stored && resolved.homeserver ? credentialsMatchConfig(stored, { diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index d990b13f56..8db29b68ff 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,3 +1,4 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk"; import type { CoreConfig } from "../../types.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; import { getMatrixRuntime } from "../../runtime.js"; @@ -22,7 +23,9 @@ export async function resolveActionClient( if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(); + // Normalize accountId early to ensure consistent keying across all lookups + const accountId = normalizeAccountId(opts.accountId); + const active = getActiveMatrixClient(accountId); if (active) { return { client: active, stopOnDone: false }; } @@ -31,11 +34,13 @@ export async function resolveActionClient( const client = await resolveSharedMatrixClient({ cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, timeoutMs: opts.timeoutMs, + accountId, }); return { client, stopOnDone: false }; } const auth = await resolveMatrixAuth({ cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + accountId, }); const client = await createMatrixClient({ homeserver: auth.homeserver, @@ -43,6 +48,7 @@ export async function resolveActionClient( accessToken: auth.accessToken, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, + accountId, }); if (auth.encryption && client.crypto) { try { diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 75fddbd9cf..96694f4c74 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -57,6 +57,7 @@ export type MatrixRawEvent = { export type MatrixActionClientOpts = { client?: MatrixClient; timeoutMs?: number; + accountId?: string | null; }; export type MatrixMessageSummary = { diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts index 5ff5409267..a643f343b5 100644 --- a/extensions/matrix/src/matrix/active-client.ts +++ b/extensions/matrix/src/matrix/active-client.ts @@ -1,11 +1,32 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; -let activeClient: MatrixClient | null = null; +// Support multiple active clients for multi-account +const activeClients = new Map(); -export function setActiveMatrixClient(client: MatrixClient | null): void { - activeClient = client; +export function setActiveMatrixClient( + client: MatrixClient | null, + accountId?: string | null, +): void { + const key = accountId ?? DEFAULT_ACCOUNT_ID; + if (client) { + activeClients.set(key, client); + } else { + activeClients.delete(key); + } } -export function getActiveMatrixClient(): MatrixClient | null { - return activeClient; +export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null { + const key = accountId ?? DEFAULT_ACCOUNT_ID; + return activeClients.get(key) ?? null; +} + +export function getAnyActiveMatrixClient(): MatrixClient | null { + // Return any available client (for backward compatibility) + const first = activeClients.values().next(); + return first.done ? null : first.value; +} + +export function clearAllActiveMatrixClients(): void { + activeClients.clear(); } diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 0d35cde2e2..53abe1c3d5 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -1,5 +1,14 @@ export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; export { isBunRuntime } from "./client/runtime.js"; -export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js"; +export { + resolveMatrixConfig, + resolveMatrixConfigForAccount, + resolveMatrixAuth, +} from "./client/config.js"; export { createMatrixClient } from "./client/create-client.js"; -export { resolveSharedMatrixClient, waitForMatrixSync, stopSharedClient } from "./client/shared.js"; +export { + resolveSharedMatrixClient, + waitForMatrixSync, + stopSharedClient, + stopSharedClientForAccount, +} from "./client/shared.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 7eba0d59a5..3e48c28e99 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,4 +1,5 @@ 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 { MatrixAuth, MatrixResolvedConfig } from "./types.js"; import { getMatrixRuntime } from "../../runtime.js"; @@ -8,11 +9,27 @@ function clean(value?: string): string { return value?.trim() ?? ""; } -export function resolveMatrixConfig( +/** + * Resolve Matrix config for a specific account, with fallback to top-level config. + * This supports both multi-account (channels.matrix.accounts.*) and + * single-account (channels.matrix.*) configurations. + */ +export function resolveMatrixConfigForAccount( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + accountId?: string | null, env: NodeJS.ProcessEnv = process.env, ): MatrixResolvedConfig { - const matrix = cfg.channels?.matrix ?? {}; + const normalizedAccountId = normalizeAccountId(accountId); + const matrixBase = cfg.channels?.matrix ?? {}; + + // Try to get account-specific config first + const accountConfig = matrixBase.accounts?.[normalizedAccountId]; + + // Merge: account-specific values override top-level values + // For DEFAULT_ACCOUNT_ID with no accounts, use top-level directly + const useAccountConfig = accountConfig !== undefined; + const matrix = useAccountConfig ? { ...matrixBase, ...accountConfig } : matrixBase; + 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; @@ -34,13 +51,24 @@ export function resolveMatrixConfig( }; } +/** + * Single-account function for backward compatibility - resolves default account config. + */ +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): MatrixResolvedConfig { + return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env); +} + export async function resolveMatrixAuth(params?: { cfg?: CoreConfig; env?: NodeJS.ProcessEnv; + accountId?: string | null; }): Promise { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; - const resolved = resolveMatrixConfig(cfg, env); + const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env); if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } @@ -52,7 +80,8 @@ export async function resolveMatrixAuth(params?: { touchMatrixCredentials, } = await import("../credentials.js"); - const cached = loadMatrixCredentials(env); + const accountId = params?.accountId; + const cached = loadMatrixCredentials(env, accountId); const cachedCredentials = cached && credentialsMatchConfig(cached, { @@ -72,13 +101,17 @@ export async function resolveMatrixAuth(params?: { const whoami = await tempClient.getUserId(); userId = whoami; // Save the credentials with the fetched userId - saveMatrixCredentials({ - homeserver: resolved.homeserver, - userId, - accessToken: resolved.accessToken, - }); + saveMatrixCredentials( + { + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }, + env, + accountId, + ); } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { - touchMatrixCredentials(env); + touchMatrixCredentials(env, accountId); } return { homeserver: resolved.homeserver, @@ -91,7 +124,7 @@ export async function resolveMatrixAuth(params?: { } if (cachedCredentials) { - touchMatrixCredentials(env); + touchMatrixCredentials(env, accountId); return { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, @@ -149,12 +182,16 @@ export async function resolveMatrixAuth(params?: { encryption: resolved.encryption, }; - saveMatrixCredentials({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - deviceId: login.device_id, - }); + saveMatrixCredentials( + { + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + deviceId: login.device_id, + }, + env, + accountId, + ); return auth; } diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index e43de205ee..5c9a8a8df7 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -13,9 +13,10 @@ type SharedMatrixClientState = { cryptoReady: boolean; }; -let sharedClientState: SharedMatrixClientState | null = null; -let sharedClientPromise: Promise | null = null; -let sharedClientStartPromise: Promise | null = null; +// Support multiple accounts with separate clients +const sharedClientStates = new Map(); +const sharedClientPromises = new Map>(); +const sharedClientStartPromises = new Map>(); function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { return [ @@ -57,11 +58,13 @@ async function ensureSharedClientStarted(params: { if (params.state.started) { return; } - if (sharedClientStartPromise) { - await sharedClientStartPromise; + const key = params.state.key; + const existingStartPromise = sharedClientStartPromises.get(key); + if (existingStartPromise) { + await existingStartPromise; return; } - sharedClientStartPromise = (async () => { + const startPromise = (async () => { const client = params.state.client; // Initialize crypto if enabled @@ -82,10 +85,11 @@ async function ensureSharedClientStarted(params: { await client.start(); params.state.started = true; })(); + sharedClientStartPromises.set(key, startPromise); try { - await sharedClientStartPromise; + await startPromise; } finally { - sharedClientStartPromise = null; + sharedClientStartPromises.delete(key); } } @@ -99,48 +103,51 @@ export async function resolveSharedMatrixClient( accountId?: string | null; } = {}, ): Promise { - const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env })); + const auth = + params.auth ?? + (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId })); const key = buildSharedClientKey(auth, params.accountId); const shouldStart = params.startClient !== false; - if (sharedClientState?.key === key) { + // Check if we already have a client for this key + const existingState = sharedClientStates.get(key); + if (existingState) { if (shouldStart) { await ensureSharedClientStarted({ - state: sharedClientState, + state: existingState, timeoutMs: params.timeoutMs, initialSyncLimit: auth.initialSyncLimit, encryption: auth.encryption, }); } - return sharedClientState.client; + return existingState.client; } - if (sharedClientPromise) { - const pending = await sharedClientPromise; - if (pending.key === key) { - if (shouldStart) { - await ensureSharedClientStarted({ - state: pending, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return pending.client; + // Check if there's a pending creation for this key + const existingPromise = sharedClientPromises.get(key); + if (existingPromise) { + const pending = await existingPromise; + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); } - pending.client.stop(); - sharedClientState = null; - sharedClientPromise = null; + return pending.client; } - sharedClientPromise = createSharedMatrixClient({ + // Create a new client for this account + const createPromise = createSharedMatrixClient({ auth, timeoutMs: params.timeoutMs, accountId: params.accountId, }); + sharedClientPromises.set(key, createPromise); try { - const created = await sharedClientPromise; - sharedClientState = created; + const created = await createPromise; + sharedClientStates.set(key, created); if (shouldStart) { await ensureSharedClientStarted({ state: created, @@ -151,7 +158,7 @@ export async function resolveSharedMatrixClient( } return created.client; } finally { - sharedClientPromise = null; + sharedClientPromises.delete(key); } } @@ -164,9 +171,29 @@ export async function waitForMatrixSync(_params: { // This is kept for API compatibility but is essentially a no-op now } -export function stopSharedClient(): void { - if (sharedClientState) { - sharedClientState.client.stop(); - sharedClientState = null; +export function stopSharedClient(key?: string): void { + if (key) { + // Stop a specific client + const state = sharedClientStates.get(key); + if (state) { + state.client.stop(); + sharedClientStates.delete(key); + } + } else { + // Stop all clients (backward compatible behavior) + for (const state of sharedClientStates.values()) { + state.client.stop(); + } + sharedClientStates.clear(); } } + +/** + * Stop the shared client for a specific account. + * Use this instead of stopSharedClient() when shutting down a single account + * to avoid stopping all accounts. + */ +export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { + const key = buildSharedClientKey(auth, accountId); + stopSharedClient(key); +} diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index 04072dc72f..9fa29c5118 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; export type MatrixStoredCredentials = { @@ -12,7 +13,15 @@ export type MatrixStoredCredentials = { lastUsedAt?: string; }; -const CREDENTIALS_FILENAME = "credentials.json"; +function credentialsFilename(accountId?: string | null): string { + const normalized = normalizeAccountId(accountId); + if (normalized === DEFAULT_ACCOUNT_ID) { + return "credentials.json"; + } + // Sanitize accountId for use in filename + const safe = normalized.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `credentials-${safe}.json`; +} export function resolveMatrixCredentialsDir( env: NodeJS.ProcessEnv = process.env, @@ -22,15 +31,19 @@ export function resolveMatrixCredentialsDir( return path.join(resolvedStateDir, "credentials", "matrix"); } -export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string { +export function resolveMatrixCredentialsPath( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): string { const dir = resolveMatrixCredentialsDir(env); - return path.join(dir, CREDENTIALS_FILENAME); + return path.join(dir, credentialsFilename(accountId)); } export function loadMatrixCredentials( env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, ): MatrixStoredCredentials | null { - const credPath = resolveMatrixCredentialsPath(env); + const credPath = resolveMatrixCredentialsPath(env, accountId); try { if (!fs.existsSync(credPath)) { return null; @@ -53,13 +66,14 @@ export function loadMatrixCredentials( export function saveMatrixCredentials( credentials: Omit, env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, ): void { const dir = resolveMatrixCredentialsDir(env); fs.mkdirSync(dir, { recursive: true }); - const credPath = resolveMatrixCredentialsPath(env); + const credPath = resolveMatrixCredentialsPath(env, accountId); - const existing = loadMatrixCredentials(env); + const existing = loadMatrixCredentials(env, accountId); const now = new Date().toISOString(); const toSave: MatrixStoredCredentials = { @@ -71,19 +85,25 @@ export function saveMatrixCredentials( fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); } -export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { - const existing = loadMatrixCredentials(env); +export function touchMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): void { + const existing = loadMatrixCredentials(env, accountId); if (!existing) { return; } existing.lastUsedAt = new Date().toISOString(); - const credPath = resolveMatrixCredentialsPath(env); + const credPath = resolveMatrixCredentialsPath(env, accountId); fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); } -export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { - const credPath = resolveMatrixCredentialsPath(env); +export function clearMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): void { + const credPath = resolveMatrixCredentialsPath(env, accountId); try { if (fs.existsSync(credPath)) { fs.unlinkSync(credPath); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index c63ea3eee4..f370701b71 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -68,6 +68,7 @@ export type MatrixMonitorHandlerParams = { roomId: string, ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; getMemberDisplayName: (roomId: string, userId: string) => Promise; + accountId?: string | null; }; export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { @@ -93,6 +94,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam directTracker, getRoomInfo, getMemberDisplayName, + accountId, } = params; return async (roomId: string, event: MatrixRawEvent) => { @@ -435,6 +437,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const baseRoute = core.channel.routing.resolveAgentRoute({ cfg, channel: "matrix", + accountId, peer: { kind: isDirectMessage ? "direct" : "channel", id: isDirectMessage ? senderId : roomId, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index eae70509a5..03d8c1a95f 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -3,12 +3,13 @@ import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plug import type { CoreConfig, ReplyToMode } from "../../types.js"; import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; +import { resolveMatrixAccount } from "../accounts.js"; import { setActiveMatrixClient } from "../active-client.js"; import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient, - stopSharedClient, + stopSharedClientForAccount, } from "../client.js"; import { normalizeMatrixUserId } from "./allowlist.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; @@ -121,10 +122,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return allowList.map(String); }; - const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; - let allowFrom: string[] = (cfg.channels?.matrix?.dm?.allowFrom ?? []).map(String); - let groupAllowFrom: string[] = (cfg.channels?.matrix?.groupAllowFrom ?? []).map(String); - let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms; + // Resolve account-specific config for multi-account support + const account = resolveMatrixAccount({ cfg, accountId: opts.accountId }); + const accountConfig = account.config; + + const allowlistOnly = accountConfig.allowlistOnly === true; + let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String); + let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); + let roomsConfig = accountConfig.groups ?? accountConfig.rooms; allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom); groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); @@ -219,7 +224,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)) @@ -234,20 +239,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi startClient: false, accountId: opts.accountId, }); - setActiveMatrixClient(client); + setActiveMatrixClient(client, opts.accountId); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; - const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off"; - const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound"; - const dmConfig = cfg.channels?.matrix?.dm; + const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; + const threadReplies = accountConfig.threadReplies ?? "inbound"; + const dmConfig = accountConfig.dm; const dmEnabled = dmConfig?.enabled ?? true; const dmPolicyRaw = dmConfig?.policy ?? "pairing"; const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); - const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; + const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const startupMs = Date.now(); const startupGraceMs = 0; @@ -279,6 +284,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi directTracker, getRoomInfo, getMemberDisplayName, + accountId: opts.accountId, }); registerMatrixMonitorEvents({ @@ -324,9 +330,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const onAbort = () => { try { logVerboseMessage("matrix: stopping client"); - stopSharedClient(); + stopSharedClientForAccount(auth, opts.accountId); } finally { - setActiveMatrixClient(null); + setActiveMatrixClient(null, opts.accountId); resolve(); } }; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index b9bfae4fe0..b531b55dcd 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -45,6 +45,7 @@ export async function sendMessageMatrix( const { client, stopOnDone } = await resolveMatrixClient({ client: opts.client, timeoutMs: opts.timeoutMs, + accountId: opts.accountId, }); try { const roomId = await resolveMatrixRoomId(client, to); @@ -78,7 +79,7 @@ export async function sendMessageMatrix( let lastMessageId = ""; if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(); + const maxBytes = resolveMediaMaxBytes(opts.accountId); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { contentType: media.contentType, @@ -166,6 +167,7 @@ export async function sendPollMatrix( const { client, stopOnDone } = await resolveMatrixClient({ client: opts.client, timeoutMs: opts.timeoutMs, + accountId: opts.accountId, }); try { diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 485b9c1cd0..c1ea1a65b8 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,7 +1,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { CoreConfig } from "../../types.js"; import { getMatrixRuntime } from "../../runtime.js"; -import { getActiveMatrixClient } from "../active-client.js"; +import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js"; import { createMatrixClient, isBunRuntime, @@ -17,8 +17,16 @@ export function ensureNodeRuntime() { } } -export function resolveMediaMaxBytes(): number | undefined { +export function resolveMediaMaxBytes(accountId?: string): number | undefined { const cfg = getCore().config.loadConfig() as CoreConfig; + // Check account-specific config first + if (accountId) { + const accountConfig = cfg.channels?.matrix?.accounts?.[accountId]; + if (typeof accountConfig?.mediaMaxMb === "number") { + return accountConfig.mediaMaxMb * 1024 * 1024; + } + } + // Fall back to top-level config if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; } @@ -28,29 +36,40 @@ export function resolveMediaMaxBytes(): number | undefined { export async function resolveMatrixClient(opts: { client?: MatrixClient; timeoutMs?: number; + accountId?: string; }): Promise<{ client: MatrixClient; stopOnDone: boolean }> { ensureNodeRuntime(); if (opts.client) { return { client: opts.client, stopOnDone: false }; } - const active = getActiveMatrixClient(); + // Try to get the client for the specific account + const active = getActiveMatrixClient(opts.accountId); if (active) { return { client: active, stopOnDone: false }; } + // Only fall back to any active client when no specific account is requested + if (!opts.accountId) { + const anyActive = getAnyActiveMatrixClient(); + if (anyActive) { + return { client: anyActive, stopOnDone: false }; + } + } const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); if (shouldShareClient) { const client = await resolveSharedMatrixClient({ timeoutMs: opts.timeoutMs, + accountId: opts.accountId, }); return { client, stopOnDone: false }; } - const auth = await resolveMatrixAuth(); + const auth = await resolveMatrixAuth({ accountId: opts.accountId }); const client = await createMatrixClient({ homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, + accountId: opts.accountId, }); if (auth.encryption && client.crypto) { try { diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 86e660e663..5ad3afbaf0 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -7,13 +7,14 @@ export const matrixOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ to, text, deps, replyToId, threadId }) => { + sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { replyToId: replyToId ?? undefined, threadId: resolvedThreadId, + accountId: accountId ?? undefined, }); return { channel: "matrix", @@ -21,7 +22,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId }) => { + sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; @@ -29,6 +30,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { mediaUrl, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, + accountId: accountId ?? undefined, }); return { channel: "matrix", @@ -36,11 +38,12 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendPoll: async ({ to, poll, threadId }) => { + sendPoll: async ({ to, poll, threadId, accountId }) => { const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await sendPollMatrix(to, poll, { threadId: resolvedThreadId, + accountId: accountId ?? undefined, }); return { channel: "matrix", diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index e372744c11..2c12c673d1 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -39,11 +39,16 @@ export type MatrixActionConfig = { channelInfo?: boolean; }; +/** Per-account Matrix config (excludes the accounts field to prevent recursion). */ +export type MatrixAccountConfig = Omit; + export type MatrixConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; /** If false, do not start Matrix. Default: true. */ enabled?: boolean; + /** Multi-account configuration keyed by account ID. */ + accounts?: Record; /** Matrix homeserver URL (https://matrix.example.org). */ homeserver?: string; /** Matrix user id (@user:server). */