From 41537e93039d6767acf34d303cf1d33e98f4b2c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 04:03:13 +0000 Subject: [PATCH] fix(channels): add optional defaultAccount routing --- CHANGELOG.md | 2 +- docs/channels/channel-routing.md | 2 + docs/concepts/multi-agent.md | 10 ++++ docs/gateway/configuration-reference.md | 10 ++++ extensions/bluebubbles/src/accounts.ts | 16 +++++- extensions/bluebubbles/src/config-schema.ts | 1 + extensions/bluebubbles/src/types.ts | 2 + extensions/googlechat/src/accounts.ts | 14 +++-- extensions/irc/src/accounts.ts | 20 +++++++- extensions/irc/src/config-schema.ts | 1 + extensions/irc/src/types.ts | 1 + extensions/matrix/src/config-schema.ts | 2 + extensions/matrix/src/matrix/accounts.test.ts | 51 ++++++++++++++++++- extensions/matrix/src/matrix/accounts.ts | 14 ++++- extensions/matrix/src/types.ts | 2 + extensions/mattermost/src/config-schema.ts | 1 + .../mattermost/src/mattermost/accounts.ts | 23 +++++++-- extensions/mattermost/src/types.ts | 2 + extensions/nextcloud-talk/src/accounts.ts | 25 +++++++-- .../nextcloud-talk/src/config-schema.ts | 1 + extensions/nextcloud-talk/src/types.ts | 2 + extensions/nostr/src/config-schema.ts | 3 ++ extensions/nostr/src/types.test.ts | 18 +++++++ extensions/nostr/src/types.ts | 21 ++++++-- extensions/zalo/src/accounts.ts | 14 +++-- extensions/zalouser/src/accounts.ts | 14 +++-- src/channels/plugins/account-helpers.test.ts | 31 +++++++++-- src/channels/plugins/account-helpers.ts | 25 ++++++++- src/config/types.channels.ts | 2 + src/config/types.discord.ts | 2 + src/config/types.imessage.ts | 2 + src/config/types.irc.ts | 2 + src/config/types.signal.ts | 2 + src/config/types.slack.ts | 2 + src/config/types.telegram.ts | 2 + src/config/types.whatsapp.ts | 2 + src/config/zod-schema.providers-core.ts | 7 +++ src/config/zod-schema.providers-whatsapp.ts | 1 + src/line/accounts.test.ts | 49 ++++++++++++++++++ src/line/accounts.ts | 20 +++++++- src/line/config-schema.ts | 1 + src/line/types.ts | 2 + src/plugin-sdk/account-id.ts | 6 ++- src/telegram/accounts.test.ts | 47 ++++++++++++++++- src/telegram/accounts.ts | 19 ++++++- 45 files changed, 461 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d374e964f..6a168591bf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..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. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 49c4a6120d6..ac4480f69b2 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -15,6 +15,8 @@ host configuration. - **Channel**: `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `webchat`. - **AccountId**: per‑channel account instance (when supported). +- Optional channel default account: `channels..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. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 842531cc2a6..6f0bd086690 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -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..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). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index f37ddf49caa..f345c4a0e7f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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..sendReadReceipts`, `channels.whatsapp.accounts..dmPolicy`, `channels.whatsapp.accounts..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:` (DM) or `channel:` (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:` (DM) or `channel:` 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:` 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) diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts index 36a51ff50c4..6d09b5cbd16 100644 --- a/extensions/bluebubbles/src/accounts.ts +++ b/extensions/bluebubbles/src/accounts.ts @@ -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 }; diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index e4bef3fd73b..7f9b6ee4679 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -61,5 +61,6 @@ const bluebubblesAccountSchema = z export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(), + defaultAccount: z.string().optional(), actions: bluebubblesActionSchema, }); diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 72ccd991857..d3dc46bd692 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -75,6 +75,8 @@ export type BlueBubblesActionConfig = { export type BlueBubblesConfig = { /** Optional per-account BlueBubbles configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; /** Per-action tool gating (default: true for all). */ actions?: BlueBubblesActionConfig; } & BlueBubblesAccountConfig; diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 2c7126a58b7..3f0303b8fbd 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -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)) { diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index e0caab243d6..ccaea982e77 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -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; diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts index 74a7ac363af..f08fd0585fd 100644 --- a/extensions/irc/src/config-schema.ts +++ b/extensions/irc/src/config-schema.ts @@ -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, diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts index 03e2d3f5eb3..59dd21ef270 100644 --- a/extensions/irc/src/types.ts +++ b/extensions/irc/src/types.ts @@ -68,6 +68,7 @@ export type IrcAccountConfig = { export type IrcConfig = IrcAccountConfig & { accounts?: Record; + defaultAccount?: string; }; export type CoreConfig = OpenClawConfig & { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 4fa99e882f6..d381259ff30 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -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(), diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index d453684756c..56319b78b3a 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -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"); + }); +}); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index ca0716ce505..fbc1a69a7e8 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -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).accounts; + delete (merged as Record).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; diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index 564ad3118ce..a8a1254b461 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -49,6 +49,8 @@ export type MatrixConfig = { enabled?: boolean; /** Multi-account configuration keyed by account ID. */ accounts?: Record; + /** 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). */ diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index bb0d99e5667..fb6dba87316 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -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, diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index 0da9465613b..767306d4dac 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -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 }; } diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 150989b7b44..356ef418fdc 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -59,4 +59,6 @@ export type MattermostAccountConfig = { export type MattermostConfig = { /** Optional per-account Mattermost configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & MattermostAccountConfig; diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index 0a5a1e725cb..4a059be4981 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -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 }; } diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index b52522983c2..e2ffaefcf5c 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -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, diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index e7af64a965c..b519efc2242 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -79,6 +79,8 @@ export type NextcloudTalkAccountConfig = { export type NextcloudTalkConfig = { /** Optional per-account Nextcloud Talk configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & NextcloudTalkAccountConfig; export type CoreConfig = { diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index d70e6b6c05c..45afce68163 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -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(), diff --git a/extensions/nostr/src/types.test.ts b/extensions/nostr/src/types.test.ts index 29c58573a2b..f6466751f21 100644 --- a/extensions/nostr/src/types.test.ts +++ b/extensions/nostr/src/types.test.ts @@ -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", () => { diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 84640b93430..9dd8d6a8c0e 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -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 | 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 | undefined)?.nostr as | NostrAccountConfig | undefined; diff --git a/extensions/zalo/src/accounts.ts b/extensions/zalo/src/accounts.ts index 7296a842e42..bc351e6034d 100644 --- a/extensions/zalo/src/accounts.ts +++ b/extensions/zalo/src/accounts.ts @@ -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)) { diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 81a84343c99..39bb6bfecc5 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -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)) { diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 121aed38f9b..eeddae81e17 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -5,14 +5,25 @@ import { createAccountListHelpers } from "./account-helpers.js"; const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("testchannel"); -function cfg(accounts?: Record | null): OpenClawConfig { +function cfg(accounts?: Record | 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"); }); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 406faa44f0c..1a86648ab5e 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -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 | 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 | 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; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 8f679f54107..caa33631bb1 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -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; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 9045ecb6af1..cd0edbe05f4 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -321,4 +321,6 @@ export type DiscordAccountConfig = { export type DiscordConfig = { /** Optional per-account Discord configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & DiscordAccountConfig; diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 836f3ae6d7e..9fe1b96fef2 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -84,4 +84,6 @@ export type IMessageAccountConfig = { export type IMessageConfig = { /** Optional per-account iMessage configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & IMessageAccountConfig; diff --git a/src/config/types.irc.ts b/src/config/types.irc.ts index 61794523195..c316c5f213b 100644 --- a/src/config/types.irc.ts +++ b/src/config/types.irc.ts @@ -56,4 +56,6 @@ export type IrcAccountConfig = CommonChannelMessagingConfig & { export type IrcConfig = { /** Optional per-account IRC configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & IrcAccountConfig; diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index cc31ea869ed..1f3d5180b92 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -48,4 +48,6 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & { export type SignalConfig = { /** Optional per-account Signal configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & SignalAccountConfig; diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 560a76d141a..0ed20d87797 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -192,4 +192,6 @@ export type SlackAccountConfig = { export type SlackConfig = { /** Optional per-account Slack configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & SlackAccountConfig; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 408e7906ed5..6e2aba3583d 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -229,4 +229,6 @@ export type TelegramDirectConfig = { export type TelegramConfig = { /** Optional per-account Telegram configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } & TelegramAccountConfig; diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 395ce3b06b2..a39a5c28e1f 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -99,6 +99,8 @@ export type WhatsAppConfig = WhatsAppConfigCore & WhatsAppSharedConfig & { /** Optional per-account WhatsApp configuration (multi-account). */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; /** Per-action tool gating (default: true for all). */ actions?: WhatsAppActionConfig; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1ca8aad7888..ccfe0b150d1 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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({ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index b8ff2938abb..2faba715bad 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -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({ diff --git a/src/line/accounts.test.ts b/src/line/accounts.test.ts index c74841b219f..6a4770c379a 100644 --- a/src/line/accounts.test.ts +++ b/src/line/accounts.test.ts @@ -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", () => { diff --git a/src/line/accounts.ts b/src/line/accounts.ts index 28a65667342..6e93cf40fea 100644 --- a/src/line/accounts.ts +++ b/src/line/accounts.ts @@ -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; diff --git a/src/line/config-schema.ts b/src/line/config-schema.ts index 7e1af506ae0..8d57d2163d5 100644 --- a/src/line/config-schema.ts +++ b/src/line/config-schema.ts @@ -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(); diff --git a/src/line/types.ts b/src/line/types.ts index 2d160910eba..3a866f6151e 100644 --- a/src/line/types.ts +++ b/src/line/types.ts @@ -32,6 +32,8 @@ interface LineAccountBaseConfig { export interface LineConfig extends LineAccountBaseConfig { /** Per-account overrides keyed by account id. */ accounts?: Record; + /** Optional default account id when multiple accounts are configured. */ + defaultAccount?: string; } export interface LineAccountConfig extends LineAccountBaseConfig {} diff --git a/src/plugin-sdk/account-id.ts b/src/plugin-sdk/account-id.ts index fa82eca8a80..5a8d0ee7d03 100644 --- a/src/plugin-sdk/account-id.ts +++ b/src/plugin-sdk/account-id.ts @@ -1 +1,5 @@ -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index 9d6440db2a6..6c7f350ca43 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -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({ diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 17be565c8a8..d81781a25cb 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -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