fix(telegram): warn when accounts.default is missing in multi-account setup (#32544)

Merged via squash.

Prepared head SHA: 7ebc3f65b2
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Sid
2026-03-03 16:27:19 +08:00
committed by GitHub
parent 2370ea5d1b
commit 4ffe15c6b2
13 changed files with 495 additions and 62 deletions

View File

@@ -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",
);
});
});

View File

@@ -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);
});
});

View File

@@ -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<string, unknown>;
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);