fix(channels): add optional defaultAccount routing

This commit is contained in:
Peter Steinberger
2026-03-02 04:03:13 +00:00
parent 0437ac1a89
commit 41537e9303
45 changed files with 461 additions and 35 deletions

View File

@@ -118,7 +118,6 @@ Docs: https://docs.openclaw.ai
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
## Unreleased
### Changes
@@ -138,6 +137,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
- Feishu/Target routing + replies + dedupe: normalize provider-prefixed targets (`feishu:`/`lark:`), prefer configured `channels.feishu.defaultAccount` for tool execution, honor Feishu outbound `renderMode` in adapter text/caption sends, fall back to normal send when reply targets are withdrawn/deleted, and add synchronous in-memory dedupe guard for concurrent duplicate inbound events. Landed from contributor PRs #30428, #30438, #29958, #30444, and #29463. Thanks @bmendonca3 and @Yaxuan42.
- Channels/Multi-account default routing: add optional `channels.<channel>.defaultAccount` default-selection support across message channels so omitted `accountId` routes to an explicit configured account instead of relying on implicit first-entry ordering (fallback behavior unchanged when unset).
- Google Chat/Thread replies: set `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` on threaded sends so replies attach to existing threads instead of silently failing thread placement. Landed from contributor PR #30965 by @novan. Thanks @novan.
- Mattermost/Private channel policy routing: map Mattermost private channel type `P` to group chat type so `groupPolicy`/`groupAllowFrom` gates apply correctly instead of being treated as open public channels. Landed from contributor PR #30891 by @BlueBirdBack. Thanks @BlueBirdBack.
- Models/Custom provider keys: trim custom provider map keys during normalization so image-capable models remain discoverable when provider keys are configured with leading/trailing whitespace. Landed from contributor PR #31202 by @stakeswky. Thanks @stakeswky.

View File

@@ -15,6 +15,8 @@ host configuration.
- **Channel**: `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `webchat`.
- **AccountId**: perchannel account instance (when supported).
- Optional channel default account: `channels.<channel>.defaultAccount` chooses
which account is used when an outbound path does not specify `accountId`.
- **AgentId**: an isolated workspace + session store (“brain”).
- **SessionKey**: the bucket key used to store context and control concurrency.

View File

@@ -197,6 +197,16 @@ Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to i
each login. Each `accountId` can be routed to a different agent, so one server can host
multiple phone numbers without mixing sessions.
If you want a channel-wide default account when `accountId` is omitted, set
`channels.<channel>.defaultAccount` (optional). When unset, OpenClaw falls back
to `default` if present, otherwise the first configured account id (sorted).
Common channels supporting this pattern include:
- `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`
- `irc`, `line`, `googlechat`, `mattermost`, `matrix`, `nextcloud-talk`
- `bluebubbles`, `zalo`, `zalouser`, `nostr`, `feishu`
## Concepts
- `agentId`: one “brain” (workspace, per-agent auth, per-agent session store).

View File

@@ -143,6 +143,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- Optional `channels.whatsapp.defaultAccount` overrides that fallback default account selection when it matches a configured account id.
- Legacy single-account Baileys auth dir is migrated by `openclaw doctor` into `whatsapp/default`.
- Per-account overrides: `channels.whatsapp.accounts.<id>.sendReadReceipts`, `channels.whatsapp.accounts.<id>.dmPolicy`, `channels.whatsapp.accounts.<id>.allowFrom`.
@@ -203,6 +204,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
- Retry policy: see [Retry policy](/concepts/retry).
@@ -299,6 +301,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
```
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
@@ -410,6 +413,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
- `configWrites: false` blocks Slack-initiated config writes.
- Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id.
- `channels.slack.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
@@ -450,6 +454,7 @@ Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
- Optional `channels.mattermost.defaultAccount` overrides default account selection when it matches a configured account id.
### Signal
@@ -474,6 +479,7 @@ Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message
- `channels.signal.account`: pin channel startup to a specific Signal account identity.
- `channels.signal.configWrites`: allow or deny Signal-initiated config writes.
- Optional `channels.signal.defaultAccount` overrides default account selection when it matches a configured account id.
### BlueBubbles
@@ -493,6 +499,7 @@ BlueBubbles is the recommended iMessage path (plugin-backed, configured under `c
```
- Core key paths covered here: `channels.bluebubbles`, `channels.bluebubbles.dmPolicy`.
- Optional `channels.bluebubbles.defaultAccount` overrides default account selection when it matches a configured account id.
- Full BlueBubbles channel configuration is documented in [BlueBubbles](/channels/bluebubbles).
### iMessage
@@ -521,6 +528,8 @@ OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
}
```
- Optional `channels.imessage.defaultAccount` overrides default account selection when it matches a configured account id.
- Requires Full Disk Access to the Messages DB.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
- `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
@@ -581,6 +590,7 @@ IRC is extension-backed and configured under `channels.irc`.
```
- Core key paths covered here: `channels.irc`, `channels.irc.dmPolicy`, `channels.irc.configWrites`, `channels.irc.nickserv.*`.
- Optional `channels.irc.defaultAccount` overrides default account selection when it matches a configured account id.
- Full IRC channel configuration (host/port/TLS/channels/allowlists/mention gating) is documented in [IRC](/channels/irc).
### Multi-account (all channels)

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
@@ -28,6 +32,13 @@ export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
const preferred = normalizeOptionalAccountId(cfg.channels?.bluebubbles?.defaultAccount);
if (
preferred &&
listBlueBubblesAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -52,8 +63,9 @@ function mergeBlueBubblesAccountConfig(
): BlueBubblesAccountConfig {
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const { accounts: _ignored, ...rest } = base;
const { accounts: _ignored, defaultAccount: _ignoredDefaultAccount, ...rest } = base;
const account = resolveAccountConfig(cfg, accountId) ?? {};
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
return { ...rest, ...account, chunkMode };

View File

@@ -61,5 +61,6 @@ const bluebubblesAccountSchema = z
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
defaultAccount: z.string().optional(),
actions: bluebubblesActionSchema,
});

View File

@@ -75,6 +75,8 @@ export type BlueBubblesActionConfig = {
export type BlueBubblesConfig = {
/** Optional per-account BlueBubbles configuration (multi-account). */
accounts?: Record<string, BlueBubblesAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
/** Per-action tool gating (default: true for all). */
actions?: BlueBubblesActionConfig;
} & BlueBubblesAccountConfig;

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { GoogleChatAccountConfig } from "./types.config.js";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
@@ -35,8 +39,12 @@ export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] {
export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string {
const channel = cfg.channels?.["googlechat"];
if (channel?.defaultAccount?.trim()) {
return channel.defaultAccount.trim();
const preferred = normalizeOptionalAccountId(channel?.defaultAccount);
if (
preferred &&
listGoogleChatAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listGoogleChatAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {

View File

@@ -1,5 +1,9 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
@@ -78,8 +82,13 @@ function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountCon
}
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.irc ?? {}) as IrcAccountConfig & {
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
...base
} = (cfg.channels?.irc ?? {}) as IrcAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
const merged: IrcAccountConfig = { ...base, ...account };
@@ -155,6 +164,13 @@ export function listIrcAccountIds(cfg: CoreConfig): string[] {
}
export function resolveDefaultIrcAccountId(cfg: CoreConfig): string {
const preferred = normalizeOptionalAccountId(cfg.channels?.irc?.defaultAccount);
if (
preferred &&
listIrcAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listIrcAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;

View File

@@ -80,6 +80,7 @@ export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) =>
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,

View File

@@ -68,6 +68,7 @@ export type IrcAccountConfig = {
export type IrcConfig = IrcAccountConfig & {
accounts?: Record<string, IrcAccountConfig>;
defaultAccount?: string;
};
export type CoreConfig = OpenClawConfig & {

View File

@@ -37,6 +37,8 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
defaultAccount: z.string().optional(),
accounts: z.record(z.string(), z.unknown()).optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
@@ -80,3 +80,52 @@ describe("resolveMatrixAccount", () => {
expect(account.configured).toBe(true);
});
});
describe("resolveDefaultMatrixAccountId", () => {
it("prefers channels.matrix.defaultAccount when it matches a configured account", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "alerts",
accounts: {
default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
},
},
},
};
expect(resolveDefaultMatrixAccountId(cfg)).toBe("alerts");
});
it("normalizes channels.matrix.defaultAccount before lookup", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "Team Alerts",
accounts: {
"team-alerts": { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
},
},
},
};
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-alerts");
});
it("falls back when channels.matrix.defaultAccount is not configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
defaultAccount: "missing",
accounts: {
default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
},
},
},
};
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
});

View File

@@ -1,4 +1,8 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
@@ -16,6 +20,7 @@ function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixCo
}
// Don't propagate the accounts map into the merged per-account config
delete (merged as Record<string, unknown>).accounts;
delete (merged as Record<string, unknown>).defaultAccount;
return merged;
}
@@ -54,6 +59,13 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] {
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
const preferred = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
if (
preferred &&
listMatrixAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listMatrixAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;

View File

@@ -49,6 +49,8 @@ export type MatrixConfig = {
enabled?: boolean;
/** Multi-account configuration keyed by account ID. */
accounts?: Record<string, MatrixAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
/** Matrix user id (@user:server). */

View File

@@ -50,6 +50,7 @@ const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value,
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
@@ -40,6 +44,13 @@ export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
const preferred = normalizeOptionalAccountId(cfg.channels?.mattermost?.defaultAccount);
if (
preferred &&
listMattermostAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listMattermostAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -62,8 +73,14 @@ function mergeMattermostAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): MattermostAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
{}) as MattermostAccountConfig & { accounts?: unknown };
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
...base
} = (cfg.channels?.mattermost ?? {}) as MattermostAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}

View File

@@ -59,4 +59,6 @@ export type MattermostAccountConfig = {
export type MattermostConfig = {
/** Optional per-account Mattermost configuration (multi-account). */
accounts?: Record<string, MattermostAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & MattermostAccountConfig;

View File

@@ -1,5 +1,9 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
function isTruthyEnvValue(value?: string): boolean {
@@ -48,6 +52,15 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
}
export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
const preferred = normalizeOptionalAccountId(cfg.channels?.["nextcloud-talk"]?.defaultAccount);
if (
preferred &&
listNextcloudTalkAccountIds(cfg).some(
(accountId) => normalizeAccountId(accountId) === preferred,
)
) {
return preferred;
}
const ids = listNextcloudTalkAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -76,8 +89,14 @@ function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
{}) as NextcloudTalkAccountConfig & { accounts?: unknown };
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
...base
} = (cfg.channels?.["nextcloud-talk"] ?? {}) as NextcloudTalkAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}

View File

@@ -60,6 +60,7 @@ export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRe
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,

View File

@@ -79,6 +79,8 @@ export type NextcloudTalkAccountConfig = {
export type NextcloudTalkConfig = {
/** Optional per-account Nextcloud Talk configuration (multi-account). */
accounts?: Record<string, NextcloudTalkAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & NextcloudTalkAccountConfig;
export type CoreConfig = {

View File

@@ -60,6 +60,9 @@ export const NostrConfigSchema = z.object({
/** Account name (optional display name) */
name: z.string().optional(),
/** Optional default account id for routing/account selection. */
defaultAccount: z.string().optional(),
/** Whether this channel is enabled */
enabled: z.boolean().optional(),

View File

@@ -22,6 +22,15 @@ describe("listNostrAccountIds", () => {
};
expect(listNostrAccountIds(cfg)).toEqual(["default"]);
});
it("returns configured defaultAccount when privateKey is configured", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
},
};
expect(listNostrAccountIds(cfg)).toEqual(["work"]);
});
});
describe("resolveDefaultNostrAccountId", () => {
@@ -38,6 +47,15 @@ describe("resolveDefaultNostrAccountId", () => {
const cfg = { channels: {} };
expect(resolveDefaultNostrAccountId(cfg)).toBe("default");
});
it("prefers configured defaultAccount when present", () => {
const cfg = {
channels: {
nostr: { privateKey: TEST_PRIVATE_KEY, defaultAccount: "work" },
},
};
expect(resolveDefaultNostrAccountId(cfg)).toBe("work");
});
});
describe("resolveNostrAccount", () => {

View File

@@ -1,4 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { NostrProfile } from "./config-schema.js";
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
import { DEFAULT_RELAYS } from "./nostr-bus.js";
@@ -6,6 +11,7 @@ import { DEFAULT_RELAYS } from "./nostr-bus.js";
export interface NostrAccountConfig {
enabled?: boolean;
name?: string;
defaultAccount?: string;
privateKey?: string;
relays?: string[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
@@ -25,7 +31,12 @@ export interface ResolvedNostrAccount {
config: NostrAccountConfig;
}
const DEFAULT_ACCOUNT_ID = "default";
function resolveConfiguredDefaultNostrAccountId(cfg: OpenClawConfig): string | undefined {
const nostrCfg = (cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;
return normalizeOptionalAccountId(nostrCfg?.defaultAccount);
}
/**
* List all configured Nostr account IDs
@@ -37,7 +48,7 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
// If privateKey is configured at top level, we have a default account
if (nostrCfg?.privateKey) {
return [DEFAULT_ACCOUNT_ID];
return [resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID];
}
return [];
@@ -47,6 +58,10 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
* Get the default account ID
*/
export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string {
const preferred = resolveConfiguredDefaultNostrAccountId(cfg);
if (preferred) {
return preferred;
}
const ids = listNostrAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -61,7 +76,7 @@ export function resolveNostrAccount(opts: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedNostrAccount {
const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID;
const accountId = normalizeAccountId(opts.accountId ?? resolveDefaultNostrAccountId(opts.cfg));
const nostrCfg = (opts.cfg.channels as Record<string, unknown> | undefined)?.nostr as
| NostrAccountConfig
| undefined;

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import { resolveZaloToken } from "./token.js";
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
@@ -23,8 +27,12 @@ export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
if (zaloConfig?.defaultAccount?.trim()) {
return zaloConfig.defaultAccount.trim();
const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
if (
preferred &&
listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listZaloAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
import { runZca, parseJsonOutput } from "./zca.js";
@@ -21,8 +25,12 @@ export function listZalouserAccountIds(cfg: OpenClawConfig): string[] {
export function resolveDefaultZalouserAccountId(cfg: OpenClawConfig): string {
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
if (zalouserConfig?.defaultAccount?.trim()) {
return zalouserConfig.defaultAccount.trim();
const preferred = normalizeOptionalAccountId(zalouserConfig?.defaultAccount);
if (
preferred &&
listZalouserAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listZalouserAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {

View File

@@ -5,14 +5,25 @@ import { createAccountListHelpers } from "./account-helpers.js";
const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } =
createAccountListHelpers("testchannel");
function cfg(accounts?: Record<string, unknown> | null): OpenClawConfig {
function cfg(accounts?: Record<string, unknown> | null, defaultAccount?: string): OpenClawConfig {
if (accounts === null) {
return { channels: { testchannel: {} } } as unknown as OpenClawConfig;
return {
channels: {
testchannel: defaultAccount ? { defaultAccount } : {},
},
} as unknown as OpenClawConfig;
}
if (accounts === undefined) {
if (accounts === undefined && !defaultAccount) {
return {} as unknown as OpenClawConfig;
}
return { channels: { testchannel: { accounts } } } as unknown as OpenClawConfig;
return {
channels: {
testchannel: {
...(accounts === undefined ? {} : { accounts }),
...(defaultAccount ? { defaultAccount } : {}),
},
},
} as unknown as OpenClawConfig;
}
describe("createAccountListHelpers", () => {
@@ -56,6 +67,18 @@ describe("createAccountListHelpers", () => {
});
describe("resolveDefaultAccountId", () => {
it("prefers configured defaultAccount when it matches a configured account id", () => {
expect(resolveDefaultAccountId(cfg({ alpha: {}, beta: {} }, "beta"))).toBe("beta");
});
it("normalizes configured defaultAccount before matching", () => {
expect(resolveDefaultAccountId(cfg({ "router-d": {} }, "Router D"))).toBe("router-d");
});
it("falls back when configured defaultAccount is missing", () => {
expect(resolveDefaultAccountId(cfg({ beta: {}, alpha: {} }, "missing"))).toBe("alpha");
});
it('returns "default" when present', () => {
expect(resolveDefaultAccountId(cfg({ default: {}, other: {} }))).toBe("default");
});

View File

@@ -1,7 +1,26 @@
import type { OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../../routing/session-key.js";
export function createAccountListHelpers(channelKey: string) {
function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined {
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
const preferred = normalizeOptionalAccountId(
typeof channel?.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
if (!preferred) {
return undefined;
}
const ids = listAccountIds(cfg);
if (ids.some((id) => normalizeAccountId(id) === preferred)) {
return preferred;
}
return undefined;
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const channel = cfg.channels?.[channelKey];
const accounts = (channel as Record<string, unknown> | undefined)?.accounts;
@@ -20,6 +39,10 @@ export function createAccountListHelpers(channelKey: string) {
}
function resolveDefaultAccountId(cfg: OpenClawConfig): string {
const preferred = resolveConfiguredDefaultAccountId(cfg);
if (preferred) {
return preferred;
}
const ids = listAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;

View File

@@ -35,6 +35,8 @@ export type ExtensionChannelConfig = {
allowFrom?: string | string[];
/** Default delivery target for CLI --deliver when no explicit --reply-to is provided. */
defaultTo?: string;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
dmPolicy?: string;
groupPolicy?: GroupPolicy;
accounts?: Record<string, unknown>;

View File

@@ -321,4 +321,6 @@ export type DiscordAccountConfig = {
export type DiscordConfig = {
/** Optional per-account Discord configuration (multi-account). */
accounts?: Record<string, DiscordAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & DiscordAccountConfig;

View File

@@ -84,4 +84,6 @@ export type IMessageAccountConfig = {
export type IMessageConfig = {
/** Optional per-account iMessage configuration (multi-account). */
accounts?: Record<string, IMessageAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & IMessageAccountConfig;

View File

@@ -56,4 +56,6 @@ export type IrcAccountConfig = CommonChannelMessagingConfig & {
export type IrcConfig = {
/** Optional per-account IRC configuration (multi-account). */
accounts?: Record<string, IrcAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & IrcAccountConfig;

View File

@@ -48,4 +48,6 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
export type SignalConfig = {
/** Optional per-account Signal configuration (multi-account). */
accounts?: Record<string, SignalAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & SignalAccountConfig;

View File

@@ -192,4 +192,6 @@ export type SlackAccountConfig = {
export type SlackConfig = {
/** Optional per-account Slack configuration (multi-account). */
accounts?: Record<string, SlackAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & SlackAccountConfig;

View File

@@ -229,4 +229,6 @@ export type TelegramDirectConfig = {
export type TelegramConfig = {
/** Optional per-account Telegram configuration (multi-account). */
accounts?: Record<string, TelegramAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & TelegramAccountConfig;

View File

@@ -99,6 +99,8 @@ export type WhatsAppConfig = WhatsAppConfigCore &
WhatsAppSharedConfig & {
/** Optional per-account WhatsApp configuration (multi-account). */
accounts?: Record<string, WhatsAppAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
/** Per-action tool gating (default: true for all). */
actions?: WhatsAppActionConfig;
};

View File

@@ -244,6 +244,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
normalizeTelegramStreamingConfig(value);
requireOpenAllowFrom({
@@ -581,6 +582,7 @@ export const DiscordAccountSchema = z
export const DiscordConfigSchema = DiscordAccountSchema.extend({
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing";
const allowFrom = value.allowFrom ?? value.dm?.allowFrom;
@@ -843,6 +845,7 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({
webhookPath: z.string().optional().default("/slack/events"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
const dmPolicy = value.dmPolicy ?? value.dm?.policy ?? "pairing";
const allowFrom = value.allowFrom ?? value.dm?.allowFrom;
@@ -971,6 +974,7 @@ export const SignalAccountSchema = SignalAccountSchemaBase;
export const SignalConfigSchema = SignalAccountSchemaBase.extend({
accounts: z.record(z.string(), SignalAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
@@ -1119,6 +1123,7 @@ export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) =>
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
refineIrcAllowFromAndNickserv(value, ctx);
if (!value.accounts) {
@@ -1209,6 +1214,7 @@ export const IMessageAccountSchema = IMessageAccountSchemaBase;
export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
accounts: z.record(z.string(), IMessageAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
@@ -1319,6 +1325,7 @@ export const BlueBubblesAccountSchema = BlueBubblesAccountSchemaBase;
export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({
accounts: z.record(z.string(), BlueBubblesAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
actions: BlueBubblesActionSchema,
}).superRefine((value, ctx) => {
requireOpenAllowFrom({

View File

@@ -114,6 +114,7 @@ export const WhatsAppAccountSchema = WhatsAppSharedSchema.extend({
export const WhatsAppConfigSchema = WhatsAppSharedSchema.extend({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
actions: z
.object({

View File

@@ -100,6 +100,39 @@ describe("LINE accounts", () => {
});
describe("resolveDefaultLineAccountId", () => {
it("prefers channels.line.defaultAccount when configured", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
defaultAccount: "business",
accounts: {
business: { enabled: true },
support: { enabled: true },
},
},
},
};
const id = resolveDefaultLineAccountId(cfg);
expect(id).toBe("business");
});
it("normalizes channels.line.defaultAccount before lookup", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
defaultAccount: "Business Ops",
accounts: {
"business-ops": { enabled: true },
},
},
},
};
const id = resolveDefaultLineAccountId(cfg);
expect(id).toBe("business-ops");
});
it("returns first named account when default not configured", () => {
const cfg: OpenClawConfig = {
channels: {
@@ -115,6 +148,22 @@ describe("LINE accounts", () => {
expect(id).toBe("business");
});
it("falls back when channels.line.defaultAccount is missing", () => {
const cfg: OpenClawConfig = {
channels: {
line: {
defaultAccount: "missing",
accounts: {
business: { enabled: true },
},
},
},
};
const id = resolveDefaultLineAccountId(cfg);
expect(id).toBe("business");
});
});
describe("normalizeAccountId", () => {

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId as normalizeSharedAccountId,
normalizeOptionalAccountId,
} from "../routing/account-id.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import type {
@@ -124,8 +125,16 @@ export function resolveLineAccount(params: {
accountConfig,
});
const {
accounts: _ignoredAccounts,
defaultAccount: _ignoredDefaultAccount,
...lineBase
} = (lineConfig ?? {}) as LineConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const mergedConfig: LineConfig & LineAccountConfig = {
...lineConfig,
...lineBase,
...accountConfig,
};
@@ -172,6 +181,15 @@ export function listLineAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultLineAccountId(cfg: OpenClawConfig): string {
const preferred = normalizeOptionalAccountId(
(cfg.channels?.line as LineConfig | undefined)?.defaultAccount,
);
if (
preferred &&
listLineAccountIds(cfg).some((accountId) => normalizeSharedAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listLineAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;

View File

@@ -35,6 +35,7 @@ const LineAccountConfigSchema = LineCommonConfigSchema.extend({
export const LineConfigSchema = LineCommonConfigSchema.extend({
accounts: z.record(z.string(), LineAccountConfigSchema.optional()).optional(),
defaultAccount: z.string().optional(),
groups: z.record(z.string(), LineGroupConfigSchema.optional()).optional(),
}).strict();

View File

@@ -32,6 +32,8 @@ interface LineAccountBaseConfig {
export interface LineConfig extends LineAccountBaseConfig {
/** Per-account overrides keyed by account id. */
accounts?: Record<string, LineAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
}
export interface LineAccountConfig extends LineAccountBaseConfig {}

View File

@@ -1 +1,5 @@
export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";

View File

@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnv } from "../test-utils/env.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "./accounts.js";
const { warnMock } = vi.hoisted(() => ({
warnMock: vi.fn(),
@@ -100,6 +104,47 @@ describe("resolveTelegramAccount", () => {
});
});
describe("resolveDefaultTelegramAccountId", () => {
it("prefers channels.telegram.defaultAccount when it matches a configured account", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "work",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("work");
});
it("normalizes channels.telegram.defaultAccount before lookup", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "Router D",
accounts: { "router-d": { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("router-d");
});
it("falls back when channels.telegram.defaultAccount is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
telegram: {
defaultAccount: "missing",
accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } },
},
},
};
expect(resolveDefaultTelegramAccountId(cfg)).toBe("default");
});
});
describe("resolveTelegramAccount allowFrom precedence", () => {
it("prefers accounts.default allowlists over top-level for default account", () => {
const resolved = resolveTelegramAccount({

View File

@@ -6,7 +6,11 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
import { resolveTelegramToken } from "./token.js";
const log = createSubsystemLogger("telegram/accounts");
@@ -68,6 +72,13 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
if (boundDefault) {
return boundDefault;
}
const preferred = normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount);
if (
preferred &&
listTelegramAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
) {
return preferred;
}
const ids = listTelegramAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
@@ -86,9 +97,13 @@ function resolveAccountConfig(
function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig {
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
groups: channelGroups,
...base
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & { accounts?: unknown };
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
// In multi-account setups, channel-level `groups` must NOT be inherited by