From 1bd3f01c17fefb19c2e6ef35038be5092a663b63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 15:40:38 +0100 Subject: [PATCH] fix(telegram): guard duplicate bot token accounts --- CHANGELOG.md | 1 + extensions/telegram/src/channel.test.ts | 125 ++++++++++++++++++++++++ extensions/telegram/src/channel.ts | 86 +++++++++++++++- 3 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 extensions/telegram/src/channel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01542c3bc2e..1e388478228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Agents: cap embedded Pi runner outer retry loop to 24 attempts and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). +- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. - Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. - Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses. - Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409) diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts new file mode 100644 index 00000000000..60ceec6d98b --- /dev/null +++ b/extensions/telegram/src/channel.test.ts @@ -0,0 +1,125 @@ +import type { + ChannelAccountSnapshot, + ChannelGatewayContext, + OpenClawConfig, + PluginRuntime, + ResolvedTelegramAccount, + RuntimeEnv, +} from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { telegramPlugin } from "./channel.js"; +import { setTelegramRuntime } from "./runtime.js"; + +function createCfg(): OpenClawConfig { + return { + channels: { + telegram: { + enabled: true, + accounts: { + alerts: { botToken: "token-shared" }, + work: { botToken: "token-shared" }, + ops: { botToken: "token-ops" }, + }, + }, + }, + } as OpenClawConfig; +} + +function createRuntimeEnv(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number): never => { + throw new Error(`exit ${code}`); + }), + }; +} + +function createStartAccountCtx(params: { + cfg: OpenClawConfig; + accountId: string; + runtime: RuntimeEnv; +}): ChannelGatewayContext { + const account = telegramPlugin.config.resolveAccount( + params.cfg, + params.accountId, + ) as ResolvedTelegramAccount; + const snapshot: ChannelAccountSnapshot = { + accountId: params.accountId, + configured: true, + enabled: true, + running: false, + }; + return { + accountId: params.accountId, + account, + cfg: params.cfg, + runtime: params.runtime, + abortSignal: new AbortController().signal, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getStatus: () => snapshot, + setStatus: vi.fn(), + }; +} + +describe("telegramPlugin duplicate token guard", () => { + it("marks secondary account as not configured when token is shared", async () => { + const cfg = createCfg(); + const alertsAccount = telegramPlugin.config.resolveAccount(cfg, "alerts"); + const workAccount = telegramPlugin.config.resolveAccount(cfg, "work"); + const opsAccount = telegramPlugin.config.resolveAccount(cfg, "ops"); + + expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true); + expect(await telegramPlugin.config.isConfigured!(workAccount, cfg)).toBe(false); + expect(await telegramPlugin.config.isConfigured!(opsAccount, cfg)).toBe(true); + + expect(telegramPlugin.config.unconfiguredReason?.(workAccount, cfg)).toContain( + 'account "alerts"', + ); + }); + + it("surfaces duplicate-token reason in status snapshot", async () => { + const cfg = createCfg(); + const workAccount = telegramPlugin.config.resolveAccount(cfg, "work"); + const snapshot = await telegramPlugin.status!.buildAccountSnapshot!({ + account: workAccount, + cfg, + runtime: undefined, + probe: undefined, + audit: undefined, + }); + + expect(snapshot.configured).toBe(false); + expect(snapshot.lastError).toContain('account "alerts"'); + }); + + it("blocks startup for duplicate token accounts before polling starts", async () => { + const monitorTelegramProvider = vi.fn(async () => undefined); + const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "bot" } })); + const runtime = { + channel: { + telegram: { + monitorTelegramProvider, + probeTelegram, + }, + }, + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime; + setTelegramRuntime(runtime); + + await expect( + telegramPlugin.gateway!.startAccount!( + createStartAccountCtx({ + cfg: createCfg(), + accountId: "work", + runtime: createRuntimeEnv(), + }), + ), + ).rejects.toThrow("Duplicate Telegram bot token"); + + expect(probeTelegram).not.toHaveBeenCalled(); + expect(monitorTelegramProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 9cc203fd59c..a26dd956a6a 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -33,6 +33,40 @@ import { getTelegramRuntime } from "./runtime.js"; const meta = getChatChannelMeta("telegram"); +function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = account.token.trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + const telegramMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], @@ -101,12 +135,32 @@ export const telegramPlugin: ChannelPlugin Boolean(account.token?.trim()), - describeAccount: (account) => ({ + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: Boolean(account.token?.trim()), + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), tokenSource: account.tokenSource, }), resolveAllowFrom: ({ cfg, accountId }) => @@ -350,7 +404,17 @@ export const telegramPlugin: ChannelPlugin { - const configured = Boolean(account.token?.trim()); + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + const duplicateTokenReason = ownerAccountId + ? formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }) + : null; + const configured = Boolean(account.token?.trim()) && !ownerAccountId; const groups = cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? cfg.channels?.telegram?.groups; @@ -368,7 +432,7 @@ export const telegramPlugin: ChannelPlugin { const account = ctx.account; + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg: ctx.cfg, + accountId: account.accountId, + }); + if (ownerAccountId) { + const reason = formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + ctx.log?.error?.(`[${account.accountId}] ${reason}`); + throw new Error(reason); + } const token = account.token.trim(); let telegramBotLabel = ""; try {