From e4a6eac595abfef1c9b0e448182445470c9d1e63 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 3 May 2026 20:07:30 +0530 Subject: [PATCH] fix(telegram): start polling after webhook cleanup timeout --- CHANGELOG.md | 1 + docs/channels/telegram.md | 3 +- extensions/telegram/src/bot-core.ts | 6 +- .../bot.create-telegram-bot.test-harness.ts | 8 +- .../src/bot.create-telegram-bot.test.ts | 26 ++++++ extensions/telegram/src/bot.types.ts | 19 +++++ .../telegram/src/channel.gateway.test.ts | 39 +++++++++ extensions/telegram/src/channel.ts | 4 + extensions/telegram/src/monitor.ts | 1 + extensions/telegram/src/monitor.types.ts | 2 + extensions/telegram/src/polling-session.ts | 30 +------ extensions/telegram/src/probe.test.ts | 23 +++++- extensions/telegram/src/probe.ts | 82 ++++++++++++++----- 13 files changed, 193 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 728c4db72c4..884be1b1e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored. - Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar. +- Telegram: reuse the successful startup `getMe` probe for grammY polling startup and continue into `getUpdates` after recoverable `deleteWebhook` cleanup failures, reducing high-latency Bot API control-plane calls before long polling starts. Refs #76388. Thanks @jackiedepp. - Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier. - Control UI/Skills: fix skill detail modal silently failing to open in all browsers by deferring `showModal()` until the dialog element is connected to the DOM; the Lit `ref` callback fired before connection causing a `DOMException: HTMLDialogElement.showModal: Dialog element is not connected` on every skill click. Thanks @nickmopen. - Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 3030f91efff..a4f51e1b346 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -855,7 +855,8 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance - `getMe returned 401` is a Telegram authentication failure for the configured bot token. - Re-copy or regenerate the bot token in BotFather, then update `channels.telegram.botToken`, `channels.telegram.tokenFile`, `channels.telegram.accounts..botToken`, or `TELEGRAM_BOT_TOKEN` for the default account. - `deleteWebhook 401 Unauthorized` during startup is also an auth failure; treating it as "no webhook exists" would only defer the same bad-token failure to later API calls. - - If `deleteWebhook` fails with a transient network error during polling startup, OpenClaw checks `getWebhookInfo`; when Telegram reports an empty webhook URL, polling continues because cleanup is already satisfied. + - If `deleteWebhook` fails with a transient network error during polling startup, OpenClaw continues into long polling instead of making another pre-poll control-plane call. A still-active webhook surfaces as a `getUpdates` conflict; OpenClaw then rebuilds the Telegram transport and retries webhook cleanup. + - After a successful startup `getMe` probe, OpenClaw reuses that bot identity for grammY polling startup so the runner does not need a second `getMe` before the first `getUpdates`. diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index cbf88db7d55..06701855ee3 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -348,7 +348,11 @@ export function createTelegramBotCore( } : undefined; - const bot = new botRuntime.Bot(opts.token, client ? { client } : undefined); + const botConfig = + client || opts.botInfo + ? { ...(client ? { client } : {}), ...(opts.botInfo ? { botInfo: opts.botInfo } : {}) } + : undefined; + const bot = new botRuntime.Bot(opts.token, botConfig); bot.api.config.use(botRuntime.apiThrottler()); // Catch all errors from bot middleware to prevent unhandled rejections bot.catch((err) => { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 529eb19fc0b..d765f77659a 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -266,7 +266,9 @@ const grammySpies = vi.hoisted(() => ({ onSpy: vi.fn(), stopSpy: vi.fn(), commandSpy: vi.fn(), - botCtorSpy: vi.fn((_: string, __?: { client?: { fetch?: typeof fetch } }) => undefined), + botCtorSpy: vi.fn( + (_: string, __?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => undefined, + ), answerCallbackQuerySpy: vi.fn(async () => undefined) as AnyAsyncMock, sendChatActionSpy: vi.fn(), editMessageTextSpy: vi.fn(async () => ({ message_id: 88 })) as AnyAsyncMock, @@ -290,7 +292,7 @@ export const onSpy: AnyMock = grammySpies.onSpy; export const stopSpy: AnyMock = grammySpies.stopSpy; export const commandSpy: AnyMock = grammySpies.commandSpy; export const botCtorSpy: MockFn< - (token: string, options?: { client?: { fetch?: typeof fetch } }) => void + (token: string, options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }) => void > = grammySpies.botCtorSpy; export const answerCallbackQuerySpy: AnyAsyncMock = grammySpies.answerCallbackQuerySpy; export const sendChatActionSpy: AnyMock = grammySpies.sendChatActionSpy; @@ -341,7 +343,7 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { catch = vi.fn(); constructor( public token: string, - public options?: { client?: { fetch?: typeof fetch } }, + public options?: { client?: { fetch?: typeof fetch }; botInfo?: unknown }, ) { (grammySpies.botCtorSpy as unknown as (token: string, options?: unknown) => void)( token, diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 0c7d4969d55..2c8546e5088 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -278,6 +278,32 @@ describe("createTelegramBot", () => { ); }); + it("passes startup probe botInfo to grammY", () => { + const botInfo = { + id: 123456, + is_bot: true, + first_name: "OpenClaw", + username: "openclaw_bot", + can_join_groups: true, + can_read_all_group_messages: false, + can_manage_bots: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: false, + allows_users_to_create_topics: false, + } as const; + + createTelegramBot({ token: "tok", botInfo }); + + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + botInfo, + }), + ); + }); + it("normalizes full Telegram bot endpoint apiRoot before passing it to grammY", () => { loadConfig.mockReturnValue({ channels: { diff --git a/extensions/telegram/src/bot.types.ts b/extensions/telegram/src/bot.types.ts index 24ddcb2c6af..1499e8fbf8c 100644 --- a/extensions/telegram/src/bot.types.ts +++ b/extensions/telegram/src/bot.types.ts @@ -3,6 +3,23 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import type { TelegramBotDeps } from "./bot-deps.js"; import type { TelegramTransport } from "./fetch.js"; +export type TelegramBotInfo = { + id: number; + is_bot: true; + first_name: string; + last_name?: string; + username: string; + language_code?: string; + can_join_groups: boolean; + can_read_all_group_messages: boolean; + can_manage_bots: boolean; + supports_inline_queries: boolean; + can_connect_to_business: boolean; + has_main_web_app: boolean; + has_topics_enabled: boolean; + allows_users_to_create_topics: boolean; +}; + export type TelegramBotOptions = { token: string; accountId?: string; @@ -14,6 +31,8 @@ export type TelegramBotOptions = { replyToMode?: ReplyToMode; proxyFetch?: typeof fetch; config?: OpenClawConfig; + /** Bot identity returned by the startup getMe probe. Avoids a duplicate grammY init getMe before polling. */ + botInfo?: TelegramBotInfo; /** Signal to abort in-flight Telegram API fetch requests (e.g. getUpdates) on shutdown. */ fetchAbortSignal?: AbortSignal; /** Minimum grammY client timeout when timeoutSeconds is configured on long-polling bots. */ diff --git a/extensions/telegram/src/channel.gateway.test.ts b/extensions/telegram/src/channel.gateway.test.ts index bc64c63005a..3cb24dac36c 100644 --- a/extensions/telegram/src/channel.gateway.test.ts +++ b/extensions/telegram/src/channel.gateway.test.ts @@ -156,6 +156,45 @@ describe("telegramPlugin gateway startup", () => { ); }); + it("passes successful startup probe botInfo into the polling monitor", async () => { + installTelegramRuntime(); + const botInfo = { + id: 123456, + is_bot: true, + first_name: "OpenClaw", + username: "openclaw_bot", + can_join_groups: true, + can_read_all_group_messages: false, + can_manage_bots: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: false, + allows_users_to_create_topics: false, + } as const; + probeTelegram.mockResolvedValue({ + ok: true, + status: null, + error: null, + elapsedMs: 12, + bot: { + id: botInfo.id, + username: botInfo.username, + }, + botInfo, + }); + monitorTelegramProvider.mockResolvedValue(undefined); + + const { task } = startTelegramAccount(); + + await expect(task).resolves.toBeUndefined(); + expect(monitorTelegramProvider).toHaveBeenCalledWith( + expect.objectContaining({ + botInfo, + }), + ); + }); + it("honors higher per-account timeoutSeconds for startup probe", async () => { installTelegramRuntime(); probeTelegram.mockResolvedValue({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 3545f01f533..378a4d012dc 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -40,6 +40,7 @@ import { resolveTelegramAutoThreadId } from "./action-threading.js"; import { lookupTelegramChatId } from "./api-fetch.js"; import { telegramApprovalCapability } from "./approval-native.js"; import * as auditModule from "./audit.js"; +import type { TelegramBotInfo } from "./bot.types.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js"; import { @@ -897,6 +898,7 @@ export const telegramPlugin = createChatChannelPlugin({ const token = (account.token ?? "").trim(); let telegramBotLabel = ""; let unauthorizedTokenReason: string | null = null; + let botInfo: TelegramBotInfo | undefined; try { const probe = await resolveTelegramProbe()( token, @@ -913,6 +915,7 @@ export const telegramPlugin = createChatChannelPlugin({ if (username) { telegramBotLabel = ` (@${username})`; } + botInfo = probe.ok ? probe.botInfo : undefined; if (!probe.ok && probe.status === 401) { unauthorizedTokenReason = formatTelegramUnauthorizedTokenError(account); } @@ -944,6 +947,7 @@ export const telegramPlugin = createChatChannelPlugin({ webhookHost: account.config.webhookHost, webhookPort: account.config.webhookPort, webhookCertPath: account.config.webhookCertPath, + botInfo, setStatus, }); }, diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index baeff5ed50a..bbf60d5faff 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -248,6 +248,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { accountId: account.accountId, runtime: opts.runtime, proxyFetch, + botInfo: opts.botInfo, abortSignal: opts.abortSignal, runnerOptions: createTelegramRunnerOptions(cfg), getLastUpdateId: () => lastUpdateId, diff --git a/extensions/telegram/src/monitor.types.ts b/extensions/telegram/src/monitor.types.ts index 198bae18f09..6d8dd00f760 100644 --- a/extensions/telegram/src/monitor.types.ts +++ b/extensions/telegram/src/monitor.types.ts @@ -4,6 +4,7 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import type { TelegramBotInfo } from "./bot.types.js"; export type MonitorTelegramOpts = { token?: string; @@ -20,6 +21,7 @@ export type MonitorTelegramOpts = { proxyFetch?: typeof fetch; webhookUrl?: string; webhookCertPath?: string; + botInfo?: TelegramBotInfo; setStatus?: (patch: Omit) => void; }; diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index e1f6ea7afa9..a1d37c7ab7c 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -67,6 +67,7 @@ type TelegramPollingSessionOpts = { accountId: string; runtime: Parameters[0]["runtime"]; proxyFetch: Parameters[0]["proxyFetch"]; + botInfo?: Parameters[0]["botInfo"]; abortSignal?: AbortSignal; runnerOptions: RunOptions; getLastUpdateId: () => number | null; @@ -187,6 +188,7 @@ export class TelegramPollingSession { proxyFetch: this.opts.proxyFetch, config: this.opts.config, accountId: this.opts.accountId, + botInfo: this.opts.botInfo, fetchAbortSignal: fetchAbortController.signal, minimumClientTimeoutSeconds: TELEGRAM_POLLING_CLIENT_TIMEOUT_FLOOR_SECONDS, updateOffset: { @@ -217,10 +219,9 @@ export class TelegramPollingSession { this.#webhookCleared = true; return "ready"; } catch (err) { - if (await this.#confirmWebhookAlreadyAbsent(bot, err)) { - this.#webhookCleared = true; + if (isRecoverableTelegramNetworkError(err, { context: "unknown" })) { this.opts.log( - "[telegram] deleteWebhook failed, but getWebhookInfo confirmed no webhook is set; continuing with polling.", + `[telegram] deleteWebhook failed with a recoverable network error; continuing to polling so getUpdates can confirm webhook state: ${formatErrorMessage(err)}`, ); return "ready"; } @@ -232,29 +233,6 @@ export class TelegramPollingSession { } } - async #confirmWebhookAlreadyAbsent( - bot: TelegramBot, - deleteWebhookError: unknown, - ): Promise { - if (!isRecoverableTelegramNetworkError(deleteWebhookError, { context: "unknown" })) { - return false; - } - try { - const webhookInfo = await withTelegramApiErrorLogging({ - operation: "getWebhookInfo", - runtime: this.opts.runtime, - shouldLog: (err) => !isRecoverableTelegramNetworkError(err, { context: "unknown" }), - fn: () => bot.api.getWebhookInfo(), - }); - return typeof webhookInfo?.url === "string" && webhookInfo.url.trim().length === 0; - } catch (err) { - if (!isRecoverableTelegramNetworkError(err, { context: "unknown" })) { - throw err; - } - return false; - } - } - async #runPollingCycle(bot: TelegramBot): Promise<"continue" | "exit"> { const liveness = new TelegramPollingLivenessTracker({ onPollSuccess: (finishedAt) => this.#status.notePollSuccess(finishedAt), diff --git a/extensions/telegram/src/probe.test.ts b/extensions/telegram/src/probe.test.ts index d19e08f3368..0415f7f1c95 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -33,7 +33,20 @@ describe("probeTelegram retry logic", () => { ok: true, json: vi.fn().mockResolvedValue({ ok: true, - result: { id: 123, username: "test_bot" }, + result: { + id: 123, + is_bot: true, + first_name: "Test", + username: "test_bot", + can_join_groups: true, + can_read_all_group_messages: false, + can_manage_bots: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: false, + allows_users_to_create_topics: false, + }, }), }); } @@ -181,6 +194,14 @@ describe("probeTelegram retry logic", () => { expect(result.ok).toBe(true); expect(result.webhook).toBeUndefined(); + expect(result.botInfo).toEqual( + expect.objectContaining({ + id: 123, + is_bot: true, + first_name: "Test", + username: "test_bot", + }), + ); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.telegram.org/bottest-token/getMe"); }); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 444a2f67026..b835ba33d63 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -2,6 +2,7 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime"; +import type { TelegramBotInfo } from "./bot.types.js"; import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; @@ -10,11 +11,19 @@ export type TelegramProbe = BaseProbeResult & { elapsedMs: number; bot?: { id?: number | null; + isBot?: boolean | null; + firstName?: string | null; username?: string | null; canJoinGroups?: boolean | null; canReadAllGroupMessages?: boolean | null; + canManageBots?: boolean | null; supportsInlineQueries?: boolean | null; + canConnectToBusiness?: boolean | null; + hasMainWebApp?: boolean | null; + hasTopicsEnabled?: boolean | null; + allowsUsersToCreateTopics?: boolean | null; }; + botInfo?: TelegramBotInfo; webhook?: { url?: string | null; hasCustomCert?: boolean | null }; }; @@ -94,6 +103,41 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ return resolved; } +function normalizeBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function normalizeTelegramBotInfo(value: unknown): TelegramBotInfo | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const bot = value as Record; + if ( + typeof bot.id !== "number" || + bot.is_bot !== true || + typeof bot.first_name !== "string" || + typeof bot.username !== "string" + ) { + return undefined; + } + return { + id: bot.id, + is_bot: true, + first_name: bot.first_name, + username: bot.username, + ...(typeof bot.last_name === "string" ? { last_name: bot.last_name } : {}), + ...(typeof bot.language_code === "string" ? { language_code: bot.language_code } : {}), + can_join_groups: normalizeBoolean(bot.can_join_groups) ?? false, + can_read_all_group_messages: normalizeBoolean(bot.can_read_all_group_messages) ?? false, + can_manage_bots: normalizeBoolean(bot.can_manage_bots) ?? false, + supports_inline_queries: normalizeBoolean(bot.supports_inline_queries) ?? false, + can_connect_to_business: normalizeBoolean(bot.can_connect_to_business) ?? false, + has_main_web_app: normalizeBoolean(bot.has_main_web_app) ?? false, + has_topics_enabled: normalizeBoolean(bot.has_topics_enabled) ?? false, + allows_users_to_create_topics: normalizeBoolean(bot.allows_users_to_create_topics) ?? false, + }; +} + export async function probeTelegram( token: string, timeoutMs: number, @@ -157,13 +201,7 @@ export async function probeTelegram( const meJson = (await meRes.json()) as { ok?: boolean; description?: string; - result?: { - id?: number; - username?: string; - can_join_groups?: boolean; - can_read_all_group_messages?: boolean; - supports_inline_queries?: boolean; - }; + result?: unknown; }; if (!meRes.ok || !meJson?.ok) { result.status = meRes.status; @@ -171,19 +209,25 @@ export async function probeTelegram( return { ...result, elapsedMs: Date.now() - started }; } + const botInfo = normalizeTelegramBotInfo(meJson.result); + const rawBot = meJson.result && typeof meJson.result === "object" ? meJson.result : {}; + const bot = rawBot as Record; + if (botInfo) { + result.botInfo = botInfo; + } result.bot = { - id: meJson.result?.id ?? null, - username: meJson.result?.username ?? null, - canJoinGroups: - typeof meJson.result?.can_join_groups === "boolean" ? meJson.result?.can_join_groups : null, - canReadAllGroupMessages: - typeof meJson.result?.can_read_all_group_messages === "boolean" - ? meJson.result?.can_read_all_group_messages - : null, - supportsInlineQueries: - typeof meJson.result?.supports_inline_queries === "boolean" - ? meJson.result?.supports_inline_queries - : null, + id: typeof bot.id === "number" ? bot.id : null, + isBot: normalizeBoolean(bot.is_bot), + firstName: typeof bot.first_name === "string" ? bot.first_name : null, + username: typeof bot.username === "string" ? bot.username : null, + canJoinGroups: normalizeBoolean(bot.can_join_groups), + canReadAllGroupMessages: normalizeBoolean(bot.can_read_all_group_messages), + canManageBots: normalizeBoolean(bot.can_manage_bots), + supportsInlineQueries: normalizeBoolean(bot.supports_inline_queries), + canConnectToBusiness: normalizeBoolean(bot.can_connect_to_business), + hasMainWebApp: normalizeBoolean(bot.has_main_web_app), + hasTopicsEnabled: normalizeBoolean(bot.has_topics_enabled), + allowsUsersToCreateTopics: normalizeBoolean(bot.allows_users_to_create_topics), }; if (includeWebhookInfo) {