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) - 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. - 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. - 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 ## 2026.2.6
@@ -332,6 +336,7 @@ Docs: https://docs.openclaw.ai
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety. - 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. - Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai.
## 2026.2.2-3 ## 2026.2.2-3
### Fixes ### 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. Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms. 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 ## Routing model
- Replies always go back to Matrix. - Replies always go back to Matrix.
@@ -256,4 +297,5 @@ Provider options:
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). - `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. - `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). - `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 { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.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 = { const meta = {
id: "matrix", id: "matrix",
label: "Matrix", label: "Matrix",
@@ -383,9 +386,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
probe: snapshot.probe, probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null, lastProbeAt: snapshot.lastProbeAt ?? null,
}), }),
probeAccount: async ({ timeoutMs, cfg }) => { probeAccount: async ({ account, timeoutMs, cfg }) => {
try { try {
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig }); const auth = await resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await probeMatrix({ return await probeMatrix({
homeserver: auth.homeserver, homeserver: auth.homeserver,
accessToken: auth.accessToken, accessToken: auth.accessToken,
@@ -424,8 +430,32 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
baseUrl: account.homeserver, baseUrl: account.homeserver,
}); });
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`); 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. // 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({ return monitorMatrixProvider({
runtime: ctx.runtime, runtime: ctx.runtime,
abortSignal: ctx.abortSignal, abortSignal: ctx.abortSignal,

View File

@@ -1,6 +1,6 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixConfig } from "../types.js"; import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js"; import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
export type ResolvedMatrixAccount = { export type ResolvedMatrixAccount = {
@@ -13,8 +13,21 @@ export type ResolvedMatrixAccount = {
config: MatrixConfig; config: MatrixConfig;
}; };
export function listMatrixAccountIds(_cfg: CoreConfig): string[] { function listConfiguredAccountIds(cfg: CoreConfig): string[] {
return [DEFAULT_ACCOUNT_ID]; 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 { export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
@@ -25,20 +38,35 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
return ids[0] ?? DEFAULT_ACCOUNT_ID; 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: { export function resolveMatrixAccount(params: {
cfg: CoreConfig; cfg: CoreConfig;
accountId?: string | null; accountId?: string | null;
}): ResolvedMatrixAccount { }): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const base = params.cfg.channels?.matrix ?? {}; const matrixBase = params.cfg.channels?.matrix ?? {};
const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env); // 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 hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId); const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken); const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password); const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword; const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env); const stored = loadMatrixCredentials(process.env, accountId);
const hasStored = const hasStored =
stored && resolved.homeserver stored && resolved.homeserver
? credentialsMatchConfig(stored, { ? credentialsMatchConfig(stored, {

View File

@@ -1,3 +1,4 @@
import { normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
@@ -22,7 +23,9 @@ export async function resolveActionClient(
if (opts.client) { if (opts.client) {
return { client: opts.client, stopOnDone: false }; 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) { if (active) {
return { client: active, stopOnDone: false }; return { client: active, stopOnDone: false };
} }
@@ -31,11 +34,13 @@ export async function resolveActionClient(
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
accountId,
}); });
return { client, stopOnDone: false }; return { client, stopOnDone: false };
} }
const auth = await resolveMatrixAuth({ const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId,
}); });
const client = await createMatrixClient({ const client = await createMatrixClient({
homeserver: auth.homeserver, homeserver: auth.homeserver,
@@ -43,6 +48,7 @@ export async function resolveActionClient(
accessToken: auth.accessToken, accessToken: auth.accessToken,
encryption: auth.encryption, encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs, localTimeoutMs: opts.timeoutMs,
accountId,
}); });
if (auth.encryption && client.crypto) { if (auth.encryption && client.crypto) {
try { try {

View File

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

View File

@@ -1,11 +1,32 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; 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 { export function setActiveMatrixClient(
activeClient = client; 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 { export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
return activeClient; 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 type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.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 { 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 { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
@@ -8,11 +9,27 @@ function clean(value?: string): string {
return value?.trim() ?? ""; 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, cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId?: string | null,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig { ): 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 homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; 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?: { export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig; cfg?: CoreConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> { }): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env; const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env); const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env);
if (!resolved.homeserver) { if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)"); throw new Error("Matrix homeserver is required (matrix.homeserver)");
} }
@@ -52,7 +80,8 @@ export async function resolveMatrixAuth(params?: {
touchMatrixCredentials, touchMatrixCredentials,
} = await import("../credentials.js"); } = await import("../credentials.js");
const cached = loadMatrixCredentials(env); const accountId = params?.accountId;
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials = const cachedCredentials =
cached && cached &&
credentialsMatchConfig(cached, { credentialsMatchConfig(cached, {
@@ -72,13 +101,17 @@ export async function resolveMatrixAuth(params?: {
const whoami = await tempClient.getUserId(); const whoami = await tempClient.getUserId();
userId = whoami; userId = whoami;
// Save the credentials with the fetched userId // Save the credentials with the fetched userId
saveMatrixCredentials({ saveMatrixCredentials(
homeserver: resolved.homeserver, {
userId, homeserver: resolved.homeserver,
accessToken: resolved.accessToken, userId,
}); accessToken: resolved.accessToken,
},
env,
accountId,
);
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env); touchMatrixCredentials(env, accountId);
} }
return { return {
homeserver: resolved.homeserver, homeserver: resolved.homeserver,
@@ -91,7 +124,7 @@ export async function resolveMatrixAuth(params?: {
} }
if (cachedCredentials) { if (cachedCredentials) {
touchMatrixCredentials(env); touchMatrixCredentials(env, accountId);
return { return {
homeserver: cachedCredentials.homeserver, homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId, userId: cachedCredentials.userId,
@@ -149,12 +182,16 @@ export async function resolveMatrixAuth(params?: {
encryption: resolved.encryption, encryption: resolved.encryption,
}; };
saveMatrixCredentials({ saveMatrixCredentials(
homeserver: auth.homeserver, {
userId: auth.userId, homeserver: auth.homeserver,
accessToken: auth.accessToken, userId: auth.userId,
deviceId: login.device_id, accessToken: auth.accessToken,
}); deviceId: login.device_id,
},
env,
accountId,
);
return auth; return auth;
} }

View File

@@ -13,9 +13,10 @@ type SharedMatrixClientState = {
cryptoReady: boolean; cryptoReady: boolean;
}; };
let sharedClientState: SharedMatrixClientState | null = null; // Support multiple accounts with separate clients
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null; const sharedClientStates = new Map<string, SharedMatrixClientState>();
let sharedClientStartPromise: Promise<void> | null = null; const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
const sharedClientStartPromises = new Map<string, Promise<void>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [ return [
@@ -57,11 +58,13 @@ async function ensureSharedClientStarted(params: {
if (params.state.started) { if (params.state.started) {
return; return;
} }
if (sharedClientStartPromise) { const key = params.state.key;
await sharedClientStartPromise; const existingStartPromise = sharedClientStartPromises.get(key);
if (existingStartPromise) {
await existingStartPromise;
return; return;
} }
sharedClientStartPromise = (async () => { const startPromise = (async () => {
const client = params.state.client; const client = params.state.client;
// Initialize crypto if enabled // Initialize crypto if enabled
@@ -82,10 +85,11 @@ async function ensureSharedClientStarted(params: {
await client.start(); await client.start();
params.state.started = true; params.state.started = true;
})(); })();
sharedClientStartPromises.set(key, startPromise);
try { try {
await sharedClientStartPromise; await startPromise;
} finally { } finally {
sharedClientStartPromise = null; sharedClientStartPromises.delete(key);
} }
} }
@@ -99,48 +103,51 @@ export async function resolveSharedMatrixClient(
accountId?: string | null; accountId?: string | null;
} = {}, } = {},
): Promise<MatrixClient> { ): 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 key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false; 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) { if (shouldStart) {
await ensureSharedClientStarted({ await ensureSharedClientStarted({
state: sharedClientState, state: existingState,
timeoutMs: params.timeoutMs, timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit, initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption, encryption: auth.encryption,
}); });
} }
return sharedClientState.client; return existingState.client;
} }
if (sharedClientPromise) { // Check if there's a pending creation for this key
const pending = await sharedClientPromise; const existingPromise = sharedClientPromises.get(key);
if (pending.key === key) { if (existingPromise) {
if (shouldStart) { const pending = await existingPromise;
await ensureSharedClientStarted({ if (shouldStart) {
state: pending, await ensureSharedClientStarted({
timeoutMs: params.timeoutMs, state: pending,
initialSyncLimit: auth.initialSyncLimit, timeoutMs: params.timeoutMs,
encryption: auth.encryption, initialSyncLimit: auth.initialSyncLimit,
}); encryption: auth.encryption,
} });
return pending.client;
} }
pending.client.stop(); return pending.client;
sharedClientState = null;
sharedClientPromise = null;
} }
sharedClientPromise = createSharedMatrixClient({ // Create a new client for this account
const createPromise = createSharedMatrixClient({
auth, auth,
timeoutMs: params.timeoutMs, timeoutMs: params.timeoutMs,
accountId: params.accountId, accountId: params.accountId,
}); });
sharedClientPromises.set(key, createPromise);
try { try {
const created = await sharedClientPromise; const created = await createPromise;
sharedClientState = created; sharedClientStates.set(key, created);
if (shouldStart) { if (shouldStart) {
await ensureSharedClientStarted({ await ensureSharedClientStarted({
state: created, state: created,
@@ -151,7 +158,7 @@ export async function resolveSharedMatrixClient(
} }
return created.client; return created.client;
} finally { } 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 // This is kept for API compatibility but is essentially a no-op now
} }
export function stopSharedClient(): void { export function stopSharedClient(key?: string): void {
if (sharedClientState) { if (key) {
sharedClientState.client.stop(); // Stop a specific client
sharedClientState = null; 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 fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js"; import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = { export type MatrixStoredCredentials = {
@@ -12,7 +13,15 @@ export type MatrixStoredCredentials = {
lastUsedAt?: string; 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( export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
@@ -22,15 +31,19 @@ export function resolveMatrixCredentialsDir(
return path.join(resolvedStateDir, "credentials", "matrix"); 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); const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, CREDENTIALS_FILENAME); return path.join(dir, credentialsFilename(accountId));
} }
export function loadMatrixCredentials( export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null { ): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env); const credPath = resolveMatrixCredentialsPath(env, accountId);
try { try {
if (!fs.existsSync(credPath)) { if (!fs.existsSync(credPath)) {
return null; return null;
@@ -53,13 +66,14 @@ export function loadMatrixCredentials(
export function saveMatrixCredentials( export function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">, credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void { ): void {
const dir = resolveMatrixCredentialsDir(env); const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true }); 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 now = new Date().toISOString();
const toSave: MatrixStoredCredentials = { const toSave: MatrixStoredCredentials = {
@@ -71,19 +85,25 @@ export function saveMatrixCredentials(
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
} }
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { export function touchMatrixCredentials(
const existing = loadMatrixCredentials(env); env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const existing = loadMatrixCredentials(env, accountId);
if (!existing) { if (!existing) {
return; return;
} }
existing.lastUsedAt = new Date().toISOString(); existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env); const credPath = resolveMatrixCredentialsPath(env, accountId);
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
} }
export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { export function clearMatrixCredentials(
const credPath = resolveMatrixCredentialsPath(env); env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try { try {
if (fs.existsSync(credPath)) { if (fs.existsSync(credPath)) {
fs.unlinkSync(credPath); fs.unlinkSync(credPath);

View File

@@ -68,6 +68,7 @@ export type MatrixMonitorHandlerParams = {
roomId: string, roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>; getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
accountId?: string | null;
}; };
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
@@ -93,6 +94,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
directTracker, directTracker,
getRoomInfo, getRoomInfo,
getMemberDisplayName, getMemberDisplayName,
accountId,
} = params; } = params;
return async (roomId: string, event: MatrixRawEvent) => { return async (roomId: string, event: MatrixRawEvent) => {
@@ -435,6 +437,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const baseRoute = core.channel.routing.resolveAgentRoute({ const baseRoute = core.channel.routing.resolveAgentRoute({
cfg, cfg,
channel: "matrix", channel: "matrix",
accountId,
peer: { peer: {
kind: isDirectMessage ? "direct" : "channel", kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? senderId : roomId, 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 type { CoreConfig, ReplyToMode } from "../../types.js";
import { resolveMatrixTargets } from "../../resolve-targets.js"; import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import { resolveMatrixAccount } from "../accounts.js";
import { setActiveMatrixClient } from "../active-client.js"; import { setActiveMatrixClient } from "../active-client.js";
import { import {
isBunRuntime, isBunRuntime,
resolveMatrixAuth, resolveMatrixAuth,
resolveSharedMatrixClient, resolveSharedMatrixClient,
stopSharedClient, stopSharedClientForAccount,
} from "../client.js"; } from "../client.js";
import { normalizeMatrixUserId } from "./allowlist.js"; import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js"; import { registerMatrixAutoJoin } from "./auto-join.js";
@@ -121,10 +122,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return allowList.map(String); return allowList.map(String);
}; };
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; // Resolve account-specific config for multi-account support
let allowFrom: string[] = (cfg.channels?.matrix?.dm?.allowFrom ?? []).map(String); const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
let groupAllowFrom: string[] = (cfg.channels?.matrix?.groupAllowFrom ?? []).map(String); const accountConfig = account.config;
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
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); allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); 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 = const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number" typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit)) ? Math.max(0, Math.floor(opts.initialSyncLimit))
@@ -234,20 +239,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
startClient: false, startClient: false,
accountId: opts.accountId, accountId: opts.accountId,
}); });
setActiveMatrixClient(client); setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; 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 groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off"; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound"; const threadReplies = accountConfig.threadReplies ?? "inbound";
const dmConfig = cfg.channels?.matrix?.dm; const dmConfig = accountConfig.dm;
const dmEnabled = dmConfig?.enabled ?? true; const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing"; const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); 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 mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now(); const startupMs = Date.now();
const startupGraceMs = 0; const startupGraceMs = 0;
@@ -279,6 +284,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
directTracker, directTracker,
getRoomInfo, getRoomInfo,
getMemberDisplayName, getMemberDisplayName,
accountId: opts.accountId,
}); });
registerMatrixMonitorEvents({ registerMatrixMonitorEvents({
@@ -324,9 +330,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const onAbort = () => { const onAbort = () => {
try { try {
logVerboseMessage("matrix: stopping client"); logVerboseMessage("matrix: stopping client");
stopSharedClient(); stopSharedClientForAccount(auth, opts.accountId);
} finally { } finally {
setActiveMatrixClient(null); setActiveMatrixClient(null, opts.accountId);
resolve(); resolve();
} }
}; };

View File

@@ -45,6 +45,7 @@ export async function sendMessageMatrix(
const { client, stopOnDone } = await resolveMatrixClient({ const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client, client: opts.client,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
}); });
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
@@ -78,7 +79,7 @@ export async function sendMessageMatrix(
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(); const maxBytes = resolveMediaMaxBytes(opts.accountId);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType, contentType: media.contentType,
@@ -166,6 +167,7 @@ export async function sendPollMatrix(
const { client, stopOnDone } = await resolveMatrixClient({ const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client, client: opts.client,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
}); });
try { try {

View File

@@ -1,7 +1,7 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../../types.js"; import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js"; import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js"; import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
import { import {
createMatrixClient, createMatrixClient,
isBunRuntime, 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; 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") { if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
} }
@@ -28,29 +36,40 @@ export function resolveMediaMaxBytes(): number | undefined {
export async function resolveMatrixClient(opts: { export async function resolveMatrixClient(opts: {
client?: MatrixClient; client?: MatrixClient;
timeoutMs?: number; timeoutMs?: number;
accountId?: string;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { }): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime(); ensureNodeRuntime();
if (opts.client) { if (opts.client) {
return { client: opts.client, stopOnDone: false }; 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) { if (active) {
return { client: active, stopOnDone: false }; 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); const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) { if (shouldShareClient) {
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
}); });
return { client, stopOnDone: false }; return { client, stopOnDone: false };
} }
const auth = await resolveMatrixAuth(); const auth = await resolveMatrixAuth({ accountId: opts.accountId });
const client = await createMatrixClient({ const client = await createMatrixClient({
homeserver: auth.homeserver, homeserver: auth.homeserver,
userId: auth.userId, userId: auth.userId,
accessToken: auth.accessToken, accessToken: auth.accessToken,
encryption: auth.encryption, encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs, localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
}); });
if (auth.encryption && client.crypto) { if (auth.encryption && client.crypto) {
try { try {

View File

@@ -7,13 +7,14 @@ export const matrixOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId }) => { sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined; threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, { const result = await send(to, text, {
replyToId: replyToId ?? undefined, replyToId: replyToId ?? undefined,
threadId: resolvedThreadId, threadId: resolvedThreadId,
accountId: accountId ?? undefined,
}); });
return { return {
channel: "matrix", channel: "matrix",
@@ -21,7 +22,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
roomId: result.roomId, 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 send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined; threadId !== undefined && threadId !== null ? String(threadId) : undefined;
@@ -29,6 +30,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
mediaUrl, mediaUrl,
replyToId: replyToId ?? undefined, replyToId: replyToId ?? undefined,
threadId: resolvedThreadId, threadId: resolvedThreadId,
accountId: accountId ?? undefined,
}); });
return { return {
channel: "matrix", channel: "matrix",
@@ -36,11 +38,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
roomId: result.roomId, roomId: result.roomId,
}; };
}, },
sendPoll: async ({ to, poll, threadId }) => { sendPoll: async ({ to, poll, threadId, accountId }) => {
const resolvedThreadId = const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined; threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await sendPollMatrix(to, poll, { const result = await sendPollMatrix(to, poll, {
threadId: resolvedThreadId, threadId: resolvedThreadId,
accountId: accountId ?? undefined,
}); });
return { return {
channel: "matrix", channel: "matrix",

View File

@@ -39,11 +39,16 @@ export type MatrixActionConfig = {
channelInfo?: boolean; channelInfo?: boolean;
}; };
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
export type MatrixConfig = { export type MatrixConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
name?: string; name?: string;
/** If false, do not start Matrix. Default: true. */ /** If false, do not start Matrix. Default: true. */
enabled?: boolean; enabled?: boolean;
/** Multi-account configuration keyed by account ID. */
accounts?: Record<string, MatrixAccountConfig>;
/** Matrix homeserver URL (https://matrix.example.org). */ /** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string; homeserver?: string;
/** Matrix user id (@user:server). */ /** Matrix user id (@user:server). */