feat(matrix): add multi-account support
Some checks failed
CI / install-check (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json --noEmit false, bun, build) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm tsgo, node, tsgo) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm canvas:a2ui:bundle && pnpm test, node, test) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt … (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled

- Add accounts field to MatrixConfig type for per-account configuration
- Update listMatrixAccountIds() to enumerate accounts from config + bindings
- Add mergeMatrixAccountConfig() to merge base config with account overrides
- Update resolveMatrixAuth() to accept accountId parameter
- Pass accountId through monitor to use correct credentials per account

This enables running multiple Matrix bot accounts simultaneously,
each connecting to different rooms with isolated sessions.
This commit is contained in:
Claudia 2026-02-01 11:02:12 +01:00
parent 247fab47ca
commit 6ac04375e6
4 changed files with 221 additions and 56 deletions

View file

@ -1,5 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixConfig } from "../types.js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@ -10,11 +10,53 @@ export type ResolvedMatrixAccount = {
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
accessToken?: string;
config: MatrixAccountConfig;
};
export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
return [DEFAULT_ACCOUNT_ID];
/**
* List account IDs explicitly configured in channels.matrix.accounts
*/
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (!key) continue;
ids.add(normalizeAccountId(key));
}
return [...ids];
}
/**
* List account IDs referenced in bindings for matrix channel
*/
function listBoundAccountIds(cfg: CoreConfig): string[] {
const bindings = cfg.bindings;
if (!Array.isArray(bindings)) return [];
const ids = new Set<string>();
for (const binding of bindings) {
if (binding.match?.channel === "matrix" && binding.match?.accountId) {
ids.add(normalizeAccountId(binding.match.accountId));
}
}
return [...ids];
}
/**
* List all Matrix account IDs (configured + bound)
*/
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = Array.from(
new Set([
DEFAULT_ACCOUNT_ID,
...listConfiguredAccountIds(cfg),
...listBoundAccountIds(cfg),
]),
);
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
@ -23,41 +65,100 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
/**
* Get account-specific config from channels.matrix.accounts[accountId]
*/
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find(
(key) => normalizeAccountId(key) === normalized
);
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
}
/**
* Merge base matrix config with account-specific overrides
*/
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
const base = cfg.channels?.matrix ?? {};
// Extract base config without 'accounts' key
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
// Account config overrides base config
return { ...baseConfig, ...accountConfig };
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const merged = mergeMatrixAccountConfig(params.cfg, accountId);
// Check if this is a non-default account - use account-specific auth
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID || accountId === "default";
// For non-default accounts, use account-specific credentials
// For default account, use base config or env
let homeserver = merged.homeserver;
let userId = merged.userId;
let accessToken = merged.accessToken;
if (isDefaultAccount) {
// Default account can fall back to env vars
const resolved = resolveMatrixConfig(params.cfg, process.env);
homeserver = homeserver || resolved.homeserver;
userId = userId || resolved.userId;
accessToken = accessToken || resolved.accessToken;
}
const baseEnabled = params.cfg.channels?.matrix?.enabled !== false;
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const hasHomeserver = Boolean(homeserver);
const hasAccessToken = Boolean(accessToken);
const hasPassword = Boolean(merged.password);
const hasUserId = Boolean(userId);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env);
// Check for stored credentials (only for default account)
const stored = isDefaultAccount ? loadMatrixCredentials(process.env) : null;
const hasStored =
stored && resolved.homeserver
stored && homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
homeserver: homeserver,
userId: userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,
name: base.name?.trim() || undefined,
name: merged.name?.trim() || undefined,
configured,
homeserver: resolved.homeserver || undefined,
userId: resolved.userId || undefined,
config: base,
homeserver: homeserver || undefined,
userId: userId || undefined,
accessToken: accessToken || undefined,
config: merged,
};
}
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
.filter((account) => account.enabled);
.filter((account) => account.enabled && account.configured);
}

View file

@ -1,6 +1,7 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../types.js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
@ -9,23 +10,60 @@ function clean(value?: string): string {
return value?.trim() ?? "";
}
/**
* Get account-specific config from channels.matrix.accounts[accountId]
*/
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): MatrixAccountConfig | undefined {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as MatrixAccountConfig | undefined;
if (direct) return direct;
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find(
(key) => normalizeAccountId(key) === normalized
);
return matchKey ? (accounts[matchKey] as MatrixAccountConfig | undefined) : undefined;
}
/**
* Merge base matrix config with account-specific overrides
*/
function mergeMatrixAccountConfig(cfg: CoreConfig, accountId: string): MatrixAccountConfig {
const base = cfg.channels?.matrix ?? {};
const { accounts: _ignored, ...baseConfig } = base as MatrixConfig;
const accountConfig = resolveAccountConfig(cfg, accountId) ?? {};
return { ...baseConfig, ...accountConfig };
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
accountId?: string,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const normalizedAccountId = normalizeAccountId(accountId);
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
// Get merged config for this account
const merged = mergeMatrixAccountConfig(cfg, normalizedAccountId);
// For default account, allow env var fallbacks
const homeserver = clean(merged.homeserver) || (isDefaultAccount ? clean(env.MATRIX_HOMESERVER) : "");
const userId = clean(merged.userId) || (isDefaultAccount ? clean(env.MATRIX_USER_ID) : "");
const accessToken = clean(merged.accessToken) || (isDefaultAccount ? clean(env.MATRIX_ACCESS_TOKEN) : "") || undefined;
const password = clean(merged.password) || (isDefaultAccount ? clean(env.MATRIX_PASSWORD) : "") || undefined;
const deviceName = clean(merged.deviceName) || (isDefaultAccount ? clean(env.MATRIX_DEVICE_NAME) : "") || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
typeof merged.initialSyncLimit === "number"
? Math.max(0, Math.floor(merged.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
const encryption = merged.encryption ?? false;
return {
homeserver,
userId,
@ -40,14 +78,21 @@ export function resolveMatrixConfig(
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
const accountId = params?.accountId;
const resolved = resolveMatrixConfig(cfg, env, accountId);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
throw new Error(`Matrix homeserver is required for account ${accountId ?? "default"} (matrix.homeserver)`);
}
const normalizedAccountId = normalizeAccountId(accountId);
const isDefaultAccount = normalizedAccountId === DEFAULT_ACCOUNT_ID || normalizedAccountId === "default";
// Only use cached credentials for default account
const {
loadMatrixCredentials,
saveMatrixCredentials,
@ -55,7 +100,7 @@ export async function resolveMatrixAuth(params?: {
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env);
const cached = isDefaultAccount ? loadMatrixCredentials(env) : null;
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
@ -74,13 +119,15 @@ export async function resolveMatrixAuth(params?: {
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
// Only save credentials for default account
if (isDefaultAccount) {
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
}
} else if (isDefaultAccount && cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
@ -93,7 +140,8 @@ export async function resolveMatrixAuth(params?: {
};
}
if (cachedCredentials) {
// Try cached credentials (only for default account)
if (isDefaultAccount && cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
@ -107,13 +155,13 @@ export async function resolveMatrixAuth(params?: {
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix.userId)",
`Matrix userId is required for account ${accountId ?? "default"} when no access token is configured`,
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix.password)",
`Matrix password is required for account ${accountId ?? "default"} when no access token is configured`,
);
}
@ -131,7 +179,7 @@ export async function resolveMatrixAuth(params?: {
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
throw new Error(`Matrix login failed for account ${accountId ?? "default"}: ${errorText}`);
}
const login = (await loginResponse.json()) as {
@ -142,7 +190,7 @@ export async function resolveMatrixAuth(params?: {
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
throw new Error(`Matrix login did not return an access token for account ${accountId ?? "default"}`);
}
const auth: MatrixAuth = {
@ -154,12 +202,15 @@ export async function resolveMatrixAuth(params?: {
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
// Only save credentials for default account
if (isDefaultAccount) {
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
}
return auth;
}

View file

@ -163,7 +163,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
},
};
const auth = await resolveMatrixAuth({ cfg });
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))

View file

@ -38,10 +38,10 @@ export type MatrixActionConfig = {
channelInfo?: boolean;
};
export type MatrixConfig = {
export type MatrixAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start Matrix. Default: true. */
/** If false, do not start this account. Default: true. */
enabled?: boolean;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
@ -87,9 +87,22 @@ export type MatrixConfig = {
actions?: MatrixActionConfig;
};
export type MatrixConfig = {
/** Optional per-account Matrix configuration (multi-account). */
accounts?: Record<string, MatrixAccountConfig>;
} & MatrixAccountConfig;
export type CoreConfig = {
channels?: {
matrix?: MatrixConfig;
};
bindings?: Array<{
agentId?: string;
match?: {
channel?: string;
accountId?: string;
peer?: { kind?: string; id?: string };
};
}>;
[key: string]: unknown;
};