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

@@ -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