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

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