diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts index dab4a330555..a1f500c90a1 100644 --- a/extensions/telegram/src/channel.test.ts +++ b/extensions/telegram/src/channel.test.ts @@ -476,6 +476,97 @@ describe("telegramPlugin duplicate token guard", () => { expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true); }); + // Regression: https://github.com/openclaw/openclaw/issues/53876 + // Single-bot setup with channel-level token should report configured. + it("reports configured for single-bot setup with channel-level token", async () => { + const cfg = { + channels: { + telegram: { + botToken: "single-bot-token", + enabled: true, + }, + }, + } as OpenClawConfig; + + const account = resolveAccount(cfg, "default"); + expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true); + }); + + // Regression: https://github.com/openclaw/openclaw/issues/53876 + // Binding-created non-default accountId in single-bot setup should report configured. + it("reports configured for binding-created accountId in single-bot setup", async () => { + const cfg = { + channels: { + telegram: { + botToken: "single-bot-token", + enabled: true, + }, + }, + } as OpenClawConfig; + + const account = resolveAccount(cfg, "bot-main"); + expect(account.token).toBe("single-bot-token"); + expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true); + }); + + // Regression: multi-bot guard — unknown binding-created accountId in multi-bot + // setup must NOT be reported as configured, matching resolveTelegramToken behaviour. + it("reports not configured for unknown binding-created accountId in multi-bot setup", async () => { + const cfg = { + channels: { + telegram: { + botToken: "channel-level-token", + enabled: true, + accounts: { + knownBot: { botToken: "known-bot-token" }, + }, + }, + }, + } as OpenClawConfig; + + const account = resolveAccount(cfg, "unknownBot"); + expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(false); + expect(telegramPlugin.config.unconfiguredReason?.(account, cfg)).toContain("unknown accountId"); + }); + + // Regression: multi-bot guard must use full normalization (same as resolveTelegramToken) + // so that account keys like "Carey Notifications" resolve to "carey-notifications". + it("multi-bot guard normalizes account keys with spaces and mixed case", async () => { + const cfg = { + channels: { + telegram: { + botToken: "channel-level-token", + enabled: true, + accounts: { + "Carey Notifications": { botToken: "carey-token" }, + }, + }, + }, + } as OpenClawConfig; + + // "carey-notifications" is the normalized form of "Carey Notifications" + const account = resolveAccount(cfg, "carey-notifications"); + expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(true); + }); + + // Regression: configured_unavailable token (e.g. unreadable tokenFile) should + // NOT be reported as configured — runtime would fail to authenticate. + it("reports not configured when token is configured_unavailable", async () => { + const cfg = { + channels: { + telegram: { + tokenFile: "/nonexistent/path/to/token", + enabled: true, + }, + }, + } as OpenClawConfig; + + const account = resolveAccount(cfg, "default"); + // tokenFile is configured but file doesn't exist → configured_unavailable + expect(await telegramPlugin.config.isConfigured!(account, cfg)).toBe(false); + expect(telegramPlugin.config.unconfiguredReason?.(account, cfg)).toContain("unavailable"); + }); + it("does not crash startup when a resolved account token is undefined", async () => { const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: false, diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 2e97b481cb5..ede80ef38ab 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -1,9 +1,11 @@ +import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution"; import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { buildChannelConfigSchema, getChatChannelMeta, @@ -56,6 +58,39 @@ export function formatDuplicateTelegramTokenReason(params: { ); } +/** + * Returns true when the runtime token resolver (`resolveTelegramToken`) would + * block channel-level fallthrough for the given accountId. This mirrors the + * guard in `token.ts` so that status-check functions (`isConfigured`, + * `unconfiguredReason`, `describeAccount`) stay consistent with the gateway + * runtime behaviour. + * + * The guard fires when: + * 1. The accountId is not the default account, AND + * 2. The config has an explicit `accounts` section with entries, AND + * 3. The accountId is not found in that `accounts` section. + * + * See: https://github.com/openclaw/openclaw/issues/53876 + */ +function isBlockedByMultiBotGuard(cfg: OpenClawConfig, accountId: string): boolean { + if (normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID) { + return false; + } + const accounts = cfg.channels?.telegram?.accounts; + const hasConfiguredAccounts = + !!accounts && + typeof accounts === "object" && + !Array.isArray(accounts) && + Object.keys(accounts).length > 0; + if (!hasConfiguredAccounts) { + return false; + } + // Use resolveNormalizedAccountEntry (same as resolveTelegramToken in token.ts) + // instead of resolveAccountEntry to handle keys that require full normalization + // (e.g. "Carey Notifications" → "carey-notifications"). + return !resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId); +} + export const telegramConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: TELEGRAM_CHANNEL, listAccountIds: listTelegramAccountIds, @@ -97,13 +132,32 @@ export function createTelegramPluginBase(params: { config: { ...telegramConfigAdapter, isConfigured: (account, cfg) => { - if (!account.token?.trim()) { + // Use inspectTelegramAccount for a complete token resolution that includes + // channel-level fallback paths not available in resolveTelegramAccount. + // This ensures binding-created accountIds that inherit the channel-level + // token are correctly detected as configured. + // See: https://github.com/openclaw/openclaw/issues/53876 + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return false; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + // Gate on actually available token, not just "configured" — the latter + // includes "configured_unavailable" (unreadable tokenFile, unresolved + // SecretRef) which would pass here but fail at runtime. + if (!inspected.token?.trim()) { return false; } return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); }, unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return `not configured: unknown accountId "${account.accountId}" in multi-bot setup`; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + if (!inspected.token?.trim()) { + if (inspected.tokenStatus === "configured_unavailable") { + return `not configured: token ${inspected.tokenSource} is configured but unavailable`; + } return "not configured"; } const ownerAccountId = findTelegramTokenOwnerAccountId({ @@ -118,15 +172,27 @@ export function createTelegramPluginBase(params: { ownerAccountId, }); }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), + describeAccount: (account, cfg) => { + if (isBlockedByMultiBotGuard(cfg, account.accountId)) { + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: false, + tokenSource: "none" as const, + }; + } + const inspected = inspectTelegramAccount({ cfg, accountId: account.accountId }); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + !!inspected.token?.trim() && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: inspected.tokenSource, + }; + }, }, setup: params.setup, }) as Pick< diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts index b7b3ebd2d20..93e825344e8 100644 --- a/extensions/telegram/src/token.test.ts +++ b/extensions/telegram/src/token.test.ts @@ -236,6 +236,42 @@ describe("resolveTelegramToken", () => { /channels\.telegram\.botToken: unresolved SecretRef/i, ); }); + + // Regression: https://github.com/openclaw/openclaw/issues/53876 + // Binding-created accountIds should inherit the channel-level token in + // single-bot setups (no accounts section). + it("falls through to channel-level token for binding-created accountId without accounts section", () => { + const cfg = { + channels: { + telegram: { + botToken: "channel-level-token", + enabled: true, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "bot-main" }); + expect(res.token).toBe("channel-level-token"); + expect(res.source).toBe("config"); + }); + + it("still blocks fallthrough for unknown accountId when accounts section exists", () => { + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const cfg = { + channels: { + telegram: { + botToken: "wrong-bot-token", + accounts: { + knownBot: { botToken: "known-bot-token" }, + }, + }, + }, + } as OpenClawConfig; + + const res = resolveTelegramToken(cfg, { accountId: "unknownBot" }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + }); }); describe("telegram update offset store", () => { diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index 29d3298c72f..011dab257b5 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -39,13 +39,28 @@ export function resolveTelegramToken( ); // When a non-default accountId is explicitly specified but not found in config, - // return empty immediately — do NOT fall through to channel-level defaults, - // which would silently route the message via the wrong bot's token. + // decide whether to fall through to channel-level defaults based on whether + // the config has an explicit accounts section (multi-bot setup). + // + // Multi-bot: accounts section exists with entries → block fallthrough to prevent + // routing via the wrong bot's token. + // + // Single-bot: no accounts section (or empty) → allow fallthrough so that + // binding-created accountIds inherit the channel-level token. + // See: https://github.com/openclaw/openclaw/issues/53876 if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) { - opts.logMissingFile?.( - `channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`, - ); - return { token: "", source: "none" }; + const accounts = telegramCfg?.accounts; + const hasConfiguredAccounts = + !!accounts && + typeof accounts === "object" && + !Array.isArray(accounts) && + Object.keys(accounts).length > 0; + if (hasConfiguredAccounts) { + opts.logMissingFile?.( + `channels.telegram.accounts: unknown accountId "${accountId}" — not found in config, refusing channel-level fallback`, + ); + return { token: "", source: "none" }; + } } const accountTokenFile = accountCfg?.tokenFile?.trim();