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 <noreply@anthropic.com>
This commit is contained in:
Monty Taylor
2026-02-02 07:28:39 -08:00
committed by Peter Steinberger
parent 607b625aab
commit caf5d2dd7c
17 changed files with 367 additions and 103 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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<void> = Promise.resolve();
const meta = {
id: "matrix",
label: "Matrix",
@@ -383,9 +386,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
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<ResolvedMatrixAccount> = {
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<void>((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,

View File

@@ -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, {

View File

@@ -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 {

View File

@@ -57,6 +57,7 @@ export type MatrixRawEvent = {
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
export type MatrixMessageSummary = {

View File

@@ -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<string, MatrixClient>();
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();
}

View File

@@ -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";

View File

@@ -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<MatrixAuth> {
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;
}

View File

@@ -13,9 +13,10 @@ type SharedMatrixClientState = {
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
// Support multiple accounts with separate clients
const sharedClientStates = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
const sharedClientStartPromises = new Map<string, Promise<void>>();
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<MatrixClient> {
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);
}

View File

@@ -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<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
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);

View File

@@ -68,6 +68,7 @@ export type MatrixMonitorHandlerParams = {
roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
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,

View File

@@ -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();
}
};

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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",

View File

@@ -39,11 +39,16 @@ export type MatrixActionConfig = {
channelInfo?: boolean;
};
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
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<string, MatrixAccountConfig>;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
/** Matrix user id (@user:server). */