diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b29bd07..81d73a467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. - Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone. - Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index f51f6c414..2d8243593 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -17,6 +17,7 @@ host configuration. - **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`. + - In multi-account setups, set an explicit default (`defaultAccount` or `accounts.default`) when two or more accounts are configured. Without it, fallback routing may pick the first normalized account ID. - **AgentId**: an isolated workspace + session store (“brain”). - **SessionKey**: the bucket key used to store context and control concurrency. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index d03530f30..32bed072e 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -739,6 +739,8 @@ Primary reference: - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`). - Multi-account precedence: + - When two or more account IDs are configured, set `channels.telegram.defaultAccount` (or include `channels.telegram.accounts.default`) to make default routing explicit. + - If neither is set, OpenClaw falls back to the first normalized account ID and `openclaw doctor` warns. - `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account. - Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset. - Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ca0a17f95..ceba7b19d 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -205,6 +205,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. +- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid. - `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). diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 87f2ff760..3718b01b2 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -128,6 +128,11 @@ Current migrations: → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` - `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +Doctor warnings also include account-default guidance for multi-account channels: + +- If two or more `channels..accounts` entries are configured without `channels..defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account. +- If `channels..defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs. + ### 2b) OpenCode Zen provider overrides If you’ve added `models.providers.opencode` (or `opencode-zen`) manually, it diff --git a/src/agents/pi-embedded-runner/skills-runtime.test.ts b/src/agents/pi-embedded-runner/skills-runtime.test.ts index 9ddead32d..516d96d8b 100644 --- a/src/agents/pi-embedded-runner/skills-runtime.test.ts +++ b/src/agents/pi-embedded-runner/skills-runtime.test.ts @@ -3,14 +3,17 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SkillSnapshot } from "../skills.js"; const hoisted = vi.hoisted(() => ({ - loadWorkspaceSkillEntries: vi.fn(() => []), + loadWorkspaceSkillEntries: vi.fn( + (_workspaceDir: string, _options?: { config?: OpenClawConfig }) => [], + ), })); vi.mock("../skills.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadWorkspaceSkillEntries: (...args: unknown[]) => hoisted.loadWorkspaceSkillEntries(...args), + loadWorkspaceSkillEntries: (workspaceDir: string, options?: { config?: OpenClawConfig }) => + hoisted.loadWorkspaceSkillEntries(workspaceDir, options), }; }); diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index f7bb9aaf9..c37501d73 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -90,77 +90,118 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); it("fails fast when gateway-backed resolution is unavailable", async () => { + const envKey = "TALK_API_KEY_FAILFAST"; + const priorValue = process.env[envKey]; + delete process.env[envKey]; callGateway.mockRejectedValueOnce(new Error("gateway closed")); - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i); + try { + await expect( + resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "memory status", + targetIds: new Set(["talk.apiKey"]), + }), + ).rejects.toThrow(/failed to resolve secrets from the active gateway snapshot/i); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } }); it("falls back to local resolution when gateway secrets.resolve is unavailable", async () => { + const priorValue = process.env.TALK_API_KEY; process.env.TALK_API_KEY = "local-fallback-key"; callGateway.mockRejectedValueOnce(new Error("gateway closed")); - const result = await resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - secrets: { - providers: { - default: { source: "env" }, - }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }); - delete process.env.TALK_API_KEY; - - expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key"); - expect( - result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), - ).toBe(true); - }); - - it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { - callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); - await expect( - resolveCommandSecretRefsViaGateway({ + try { + const result = await resolveCommandSecretRefsViaGateway({ config: { talk: { apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, } as OpenClawConfig, commandName: "memory status", targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); + }); + + expect(result.resolvedConfig.talk?.apiKey).toBe("local-fallback-key"); + expect( + result.diagnostics.some((entry) => entry.includes("gateway secrets.resolve unavailable")), + ).toBe(true); + } finally { + if (priorValue === undefined) { + delete process.env.TALK_API_KEY; + } else { + process.env.TALK_API_KEY = priorValue; + } + } + }); + + it("returns a version-skew hint when gateway does not support secrets.resolve", async () => { + const envKey = "TALK_API_KEY_UNSUPPORTED"; + const priorValue = process.env[envKey]; + delete process.env[envKey]; + callGateway.mockRejectedValueOnce(new Error("unknown method: secrets.resolve")); + try { + await expect( + resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "memory status", + targetIds: new Set(["talk.apiKey"]), + }), + ).rejects.toThrow(/does not support secrets\.resolve/i); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } }); it("returns a version-skew hint when required-method capability check fails", async () => { + const envKey = "TALK_API_KEY_REQUIRED_METHOD"; + const priorValue = process.env[envKey]; + delete process.env[envKey]; callGateway.mockRejectedValueOnce( new Error( 'active gateway does not support required method "secrets.resolve" for "secrets.resolve".', ), ); - await expect( - resolveCommandSecretRefsViaGateway({ - config: { - talk: { - apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" }, - }, - } as OpenClawConfig, - commandName: "memory status", - targetIds: new Set(["talk.apiKey"]), - }), - ).rejects.toThrow(/does not support secrets\.resolve/i); + try { + await expect( + resolveCommandSecretRefsViaGateway({ + config: { + talk: { + apiKey: { source: "env", provider: "default", id: envKey }, + }, + } as OpenClawConfig, + commandName: "memory status", + targetIds: new Set(["talk.apiKey"]), + }), + ).rejects.toThrow(/does not support secrets\.resolve/i); + } finally { + if (priorValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = priorValue; + } + } }); it("fails when gateway returns an invalid secrets.resolve payload", async () => { diff --git a/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts index ee5ac2e13..bbfe3063b 100644 --- a/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts +++ b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { note } from "../terminal/note.js"; import { withEnvAsync } from "../test-utils/env.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; @@ -23,6 +23,10 @@ import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; const noteSpy = vi.mocked(note); describe("doctor missing default account binding warning", () => { + beforeEach(() => { + noteSpy.mockClear(); + }); + it("emits a doctor warning when named accounts have no valid account-scoped bindings", async () => { await withEnvAsync( { @@ -52,4 +56,67 @@ describe("doctor missing default account binding warning", () => { "Doctor warnings", ); }); + + it("emits a warning when multiple accounts have no explicit default", async () => { + await withEnvAsync( + { + TELEGRAM_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN_FILE: undefined, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + channels: { + telegram: { + accounts: { + alerts: {}, + work: {}, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining( + "channels.telegram: multiple accounts are configured but no explicit default is set", + ), + "Doctor warnings", + ); + }); + + it("emits a warning when defaultAccount does not match configured accounts", async () => { + await withEnvAsync( + { + TELEGRAM_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN_FILE: undefined, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + channels: { + telegram: { + defaultAccount: "missing", + accounts: { + alerts: {}, + work: {}, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'channels.telegram: defaultAccount is set to "missing" but does not match configured accounts', + ), + "Doctor warnings", + ); + }); }); diff --git a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts new file mode 100644 index 000000000..5ef4f7a6c --- /dev/null +++ b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { collectMissingExplicitDefaultAccountWarnings } from "./doctor-config-flow.js"; + +describe("collectMissingExplicitDefaultAccountWarnings", () => { + it("warns when multiple named accounts are configured without default selection", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toEqual([ + expect.stringContaining("channels.telegram: multiple accounts are configured"), + ]); + }); + + it("does not warn for a single named account without default", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("does not warn when accounts.default exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + default: { botToken: "d" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("does not warn when defaultAccount points to a configured account", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "work", + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("normalizes defaultAccount before validating configured account ids", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "Router D", + accounts: { + "router-d": { botToken: "r" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("warns when defaultAccount is invalid for configured accounts", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "missing", + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toEqual([ + expect.stringContaining('channels.telegram: defaultAccount is set to "missing"'), + ]); + }); + + it("warns across channels that support account maps", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + slack: { + accounts: { + a: { botToken: "x" }, + b: { botToken: "y" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toHaveLength(2); + expect(warnings.some((line) => line.includes("channels.telegram"))).toBe(true); + expect(warnings.some((line) => line.includes("channels.slack"))).toBe(true); + }); +}); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b61b7c069..9e95575dc 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -26,7 +26,16 @@ import { normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { + formatChannelAccountsDefaultPath, + formatSetExplicitDefaultInstruction, + formatSetExplicitDefaultToConfiguredInstruction, +} from "../routing/default-account-warnings.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -215,15 +224,21 @@ function normalizeBindingChannelKey(raw?: string | null): string { return (raw ?? "").trim().toLowerCase(); } -export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] { +type ChannelMissingDefaultAccountContext = { + channelKey: string; + channel: Record; + normalizedAccountIds: string[]; +}; + +function collectChannelsMissingDefaultAccount( + cfg: OpenClawConfig, +): ChannelMissingDefaultAccountContext[] { const channels = asObjectRecord(cfg.channels); if (!channels) { return []; } - const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : []; - const warnings: string[] = []; - + const contexts: ChannelMissingDefaultAccountContext[] = []; for (const [channelKey, rawChannel] of Object.entries(channels)) { const channel = asObjectRecord(rawChannel); if (!channel) { @@ -240,10 +255,20 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig) .map((accountId) => normalizeAccountId(accountId)) .filter(Boolean), ), - ); + ).toSorted((a, b) => a.localeCompare(b)); if (normalizedAccountIds.length === 0 || normalizedAccountIds.includes(DEFAULT_ACCOUNT_ID)) { continue; } + contexts.push({ channelKey, channel, normalizedAccountIds }); + } + return contexts; +} + +export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] { + const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : []; + const warnings: string[] = []; + + for (const { channelKey, normalizedAccountIds } of collectChannelsMissingDefaultAccount(cfg)) { const accountIdSet = new Set(normalizedAccountIds); const channelPattern = normalizeBindingChannelKey(channelKey); @@ -291,13 +316,43 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig) } if (coveredAccountIds.size > 0) { warnings.push( - `- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add channels.${channelKey}.accounts.default.`, + `- channels.${channelKey}: accounts.default is missing and account bindings only cover a subset of configured accounts. Uncovered accounts: ${uncoveredAccountIds.join(", ")}. Add bindings[].match.accountId for uncovered accounts (or "*"), or add ${formatChannelAccountsDefaultPath(channelKey)}.`, ); continue; } warnings.push( - `- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add channels.${channelKey}.accounts.default.`, + `- channels.${channelKey}: accounts.default is missing and no valid account-scoped binding exists for configured accounts (${normalizedAccountIds.join(", ")}). Channel-only bindings (no accountId) match only default. Add bindings[].match.accountId for one of these accounts (or "*"), or add ${formatChannelAccountsDefaultPath(channelKey)}.`, + ); + } + + return warnings; +} + +export function collectMissingExplicitDefaultAccountWarnings(cfg: OpenClawConfig): string[] { + const warnings: string[] = []; + for (const { channelKey, channel, normalizedAccountIds } of collectChannelsMissingDefaultAccount( + cfg, + )) { + if (normalizedAccountIds.length < 2) { + continue; + } + + const preferredDefault = normalizeOptionalAccountId( + typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, + ); + if (preferredDefault) { + if (normalizedAccountIds.includes(preferredDefault)) { + continue; + } + warnings.push( + `- channels.${channelKey}: defaultAccount is set to "${preferredDefault}" but does not match configured accounts (${normalizedAccountIds.join(", ")}). ${formatSetExplicitDefaultToConfiguredInstruction({ channelKey })} to avoid fallback routing.`, + ); + continue; + } + + warnings.push( + `- channels.${channelKey}: multiple accounts are configured but no explicit default is set. ${formatSetExplicitDefaultInstruction(channelKey)} to avoid fallback routing.`, ); } @@ -1812,6 +1867,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { if (missingDefaultAccountBindingWarnings.length > 0) { note(missingDefaultAccountBindingWarnings.join("\n"), "Doctor warnings"); } + const missingExplicitDefaultWarnings = collectMissingExplicitDefaultAccountWarnings(candidate); + if (missingExplicitDefaultWarnings.length > 0) { + note(missingExplicitDefaultWarnings.join("\n"), "Doctor warnings"); + } if (shouldRepair) { const repair = await maybeRepairTelegramAllowFromUsernames(candidate); diff --git a/src/routing/default-account-warnings.ts b/src/routing/default-account-warnings.ts new file mode 100644 index 000000000..8c15aff4e --- /dev/null +++ b/src/routing/default-account-warnings.ts @@ -0,0 +1,17 @@ +export function formatChannelDefaultAccountPath(channelKey: string): string { + return `channels.${channelKey}.defaultAccount`; +} + +export function formatChannelAccountsDefaultPath(channelKey: string): string { + return `channels.${channelKey}.accounts.default`; +} + +export function formatSetExplicitDefaultInstruction(channelKey: string): string { + return `Set ${formatChannelDefaultAccountPath(channelKey)} or add ${formatChannelAccountsDefaultPath(channelKey)}`; +} + +export function formatSetExplicitDefaultToConfiguredInstruction(params: { + channelKey: string; +}): string { + return `Set ${formatChannelDefaultAccountPath(params.channelKey)} to one of these accounts, or add ${formatChannelAccountsDefaultPath(params.channelKey)}`; +} diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index 33112386d..1c0807aaa 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnv } from "../test-utils/env.js"; import { listTelegramAccountIds, + resetMissingDefaultWarnFlag, resolveDefaultTelegramAccountId, resolveTelegramAccount, } from "./accounts.js"; @@ -11,6 +12,10 @@ const { warnMock } = vi.hoisted(() => ({ warnMock: vi.fn(), })); +function warningLines(): string[] { + return warnMock.mock.calls.map(([line]) => String(line)); +} + vi.mock("../logging/subsystem.js", () => ({ createSubsystemLogger: () => { const logger = { @@ -24,6 +29,7 @@ vi.mock("../logging/subsystem.js", () => ({ describe("resolveTelegramAccount", () => { afterEach(() => { warnMock.mockClear(); + resetMissingDefaultWarnFlag(); }); it("falls back to the first configured account when accountId is omitted", () => { @@ -105,6 +111,94 @@ describe("resolveTelegramAccount", () => { }); describe("resolveDefaultTelegramAccountId", () => { + beforeEach(() => { + resetMissingDefaultWarnFlag(); + }); + + afterEach(() => { + warnMock.mockClear(); + resetMissingDefaultWarnFlag(); + }); + + it("warns when accounts.default is missing in multi-account setup (#32137)", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } }, + }, + }, + }; + + const result = resolveDefaultTelegramAccountId(cfg); + expect(result).toBe("alerts"); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining("accounts.default is missing")); + }); + + it("does not warn when accounts.default exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { default: { botToken: "tok-default" }, work: { botToken: "tok-work" } }, + }, + }, + }; + + resolveDefaultTelegramAccountId(cfg); + expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe( + true, + ); + }); + + it("does not warn when defaultAccount is explicitly set", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "work", + accounts: { work: { botToken: "tok-work" } }, + }, + }, + }; + + resolveDefaultTelegramAccountId(cfg); + expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe( + true, + ); + }); + + it("does not warn when only one non-default account is configured", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { work: { botToken: "tok-work" } }, + }, + }, + }; + + resolveDefaultTelegramAccountId(cfg); + expect(warningLines().every((line) => !line.includes("accounts.default is missing"))).toBe( + true, + ); + }); + + it("warns only once per process lifetime", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } }, + }, + }, + }; + + resolveDefaultTelegramAccountId(cfg); + resolveDefaultTelegramAccountId(cfg); + resolveDefaultTelegramAccountId(cfg); + + const missingDefaultWarns = warningLines().filter((line) => + line.includes("accounts.default is missing"), + ); + expect(missingDefaultWarns).toHaveLength(1); + }); + it("prefers channels.telegram.defaultAccount when it matches a configured account", () => { const cfg: OpenClawConfig = { channels: { diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 54af9ba2a..81de42cd1 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -10,6 +10,7 @@ import { } from "../plugin-sdk/account-resolution.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; +import { formatSetExplicitDefaultInstruction } from "../routing/default-account-warnings.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, @@ -63,6 +64,13 @@ export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { return ids.toSorted((a, b) => a.localeCompare(b)); } +let emittedMissingDefaultWarn = false; + +/** @internal Reset the once-per-process warning flag. Exported for tests only. */ +export function resetMissingDefaultWarnFlag(): void { + emittedMissingDefaultWarn = false; +} + export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); if (boundDefault) { @@ -79,6 +87,13 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { if (ids.includes(DEFAULT_ACCOUNT_ID)) { return DEFAULT_ACCOUNT_ID; } + if (ids.length > 1 && !emittedMissingDefaultWarn) { + emittedMissingDefaultWarn = true; + log.warn( + `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, + ); + } return ids[0] ?? DEFAULT_ACCOUNT_ID; }