diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca36dd3caf..9d4106f9c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Docs: https://docs.openclaw.ai - Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest. - Channels/Telegram: honor `ALL_PROXY` / `all_proxy` and service-level `OPENCLAW_PROXY_URL` when constructing the HTTP/1-only Telegram Bot API transport, so Windows and service installs that rely on those proxy settings no longer fall back to direct egress. Fixes #74014; refs #74086. Thanks @SymbolStar. +- Channels/Telegram: continue polling when `deleteWebhook` hits a transient network failure but `getWebhookInfo` confirms no webhook is configured, so startup does not retry cleanup forever after the webhook was already removed. Refs #74086; carries forward #47384. Thanks @clovericbot. +- Channels/Telegram: apply strict safe-send retry to inbound final replies when grammY wraps a pre-connect failure, while leaving ambiguous plain network envelopes single-shot to avoid duplicate visible messages. Fixes #74203. Thanks @nanli2000cn. - ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc. - TUI/status: clear stale `streaming` footer state when a final event arrives after the active run was already cleared and no tracked runs remain, while preserving concurrent-run ownership and inactive local `/btw` terminal handling. Fixes #64825; carries forward #64842, #64843, #64847, and #64862. Thanks @briandevans and @Yanhu007. - Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index ac94b573a42..9ab130b2fa0 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -734,7 +734,7 @@ curl "https://api.telegram.org/bot/getUpdates" - DM history controls: - `channels.telegram.dmHistoryLimit` - `channels.telegram.dms[""].historyLimit` - - `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors. + - `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors. Inbound final-reply delivery also uses a bounded safe-send retry for Telegram pre-connect failures, but it does not retry ambiguous post-send network envelopes that could duplicate visible messages. CLI send target can be numeric chat ID or username: @@ -857,6 +857,7 @@ 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. diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 218ee108c43..ccc884e73e0 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -1,8 +1,10 @@ import { type Bot, GrammyError } from "grammy"; +import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { withTelegramApiErrorLogging } from "../api-logging.js"; import { markdownToTelegramHtml } from "../format.js"; +import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js"; import { buildTelegramSendParams, getTelegramNativeQuoteReplyMessageId, @@ -51,6 +53,13 @@ function removeMessageThreadIdParam( return rest; } +function createTelegramDeliverySendRetry() { + return createTelegramRetryRunner({ + shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err), + strictShouldRetry: true, + }); +} + export async function sendTelegramWithThreadFallback(params: { operation: string; runtime: RuntimeEnv; @@ -68,14 +77,21 @@ export async function sendTelegramWithThreadFallback(params: { const mergedShouldLog = params.shouldLog ? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err) : (err: unknown) => !shouldSuppressFirstErrorLog(err); + const requestWithRetry = createTelegramDeliverySendRetry(); + const runLoggedSend = ( + operation: string, + requestParams: Record, + shouldLog?: (err: unknown) => boolean, + ) => + withTelegramApiErrorLogging({ + operation, + runtime: params.runtime, + ...(shouldLog ? { shouldLog } : {}), + fn: () => requestWithRetry(() => params.send(requestParams), operation), + }); try { - return await withTelegramApiErrorLogging({ - operation: params.operation, - runtime: params.runtime, - shouldLog: mergedShouldLog, - fn: () => params.send(params.requestParams), - }); + return await runLoggedSend(params.operation, params.requestParams, mergedShouldLog); } catch (err) { if (hasNativeQuote && isTelegramQuoteParamError(err)) { params.runtime.log?.( @@ -94,11 +110,7 @@ export async function sendTelegramWithThreadFallback(params: { params.runtime.log?.( `telegram ${params.operation}: message thread not found; retrying without message_thread_id`, ); - return await withTelegramApiErrorLogging({ - operation: `${params.operation} (threadless retry)`, - runtime: params.runtime, - fn: () => params.send(retryParams), - }); + return await runLoggedSend(`${params.operation} (threadless retry)`, retryParams); } } diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 02c25c6cdd1..f6bf59ce2a5 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -130,6 +130,24 @@ function createQuoteNotFoundError(operation = "sendMessage") { ); } +function createWrappedPreConnectHttpError(operation = "sendMessage") { + const root = Object.assign(new Error("getaddrinfo ENOTFOUND api.telegram.org"), { + code: "ENOTFOUND", + }); + const fetchError = Object.assign(new TypeError("fetch failed"), { cause: root }); + return Object.assign(new Error(`Network request for '${operation}' failed!`), { + name: "HttpError", + error: fetchError, + }); +} + +function createPlainHttpError(operation = "sendMessage") { + return Object.assign(new Error(`Network request for '${operation}' failed!`), { + name: "HttpError", + error: new TypeError("fetch failed"), + }); +} + function createVoiceFailureHarness(params: { voiceError: Error; sendMessageResult?: { message_id: number; chat: { id: string } }; @@ -671,6 +689,48 @@ describe("deliverReplies", () => { expect(runtime.error).toHaveBeenCalledTimes(1); }); + it("retries final text sends for wrapped pre-connect grammY HttpError envelopes", async () => { + vi.useFakeTimers(); + const runtime = createRuntime(); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(createWrappedPreConnectHttpError("sendMessage")) + .mockResolvedValueOnce({ + message_id: 12, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + const delivered = deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + }); + await vi.runAllTimersAsync(); + await delivered; + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(runtime.error).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("does not retry final text sends for plain grammY envelopes without a safe cause", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockRejectedValue(createPlainHttpError("sendMessage")); + const bot = createBot({ sendMessage }); + + await expect( + deliverWith({ + replies: [{ text: "hello" }], + runtime, + bot, + }), + ).rejects.toThrow(/Network request for 'sendMessage' failed!/); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledTimes(1); + }); + it("retries media sends without message_thread_id for DM topics", async () => { const runtime = createRuntime(); const sendPhoto = vi diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index d78f328491e..fdd4ff33812 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -25,6 +25,7 @@ const api = { sendDocument: vi.fn(), setWebhook: vi.fn(), deleteWebhook: vi.fn(), + getWebhookInfo: vi.fn(async () => ({ url: "" })), getUpdates: vi.fn(async () => []), config: { use: vi.fn(), @@ -463,12 +464,45 @@ describe("monitorTelegramProvider (grammY)", () => { const abort = new AbortController(); const cleanupError = makeRecoverableFetchError(); api.deleteWebhook.mockReset(); + api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "https://example.test/hook" }); api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true); mockRunOnceAndAbort(abort); await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); expect(api.deleteWebhook).toHaveBeenCalledTimes(2); + expect(api.getWebhookInfo).toHaveBeenCalledTimes(1); + expectRecoverableRetryState(1); + }); + + it("continues polling when deleteWebhook transiently fails but webhook is already absent", async () => { + const abort = new AbortController(); + const cleanupError = makeRecoverableFetchError(); + api.deleteWebhook.mockReset(); + api.getWebhookInfo.mockReset().mockResolvedValueOnce({ url: "" }); + api.deleteWebhook.mockRejectedValueOnce(cleanupError); + mockRunOnceAndAbort(abort); + + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + + expect(api.deleteWebhook).toHaveBeenCalledTimes(1); + expect(api.getWebhookInfo).toHaveBeenCalledTimes(1); + expect(runSpy).toHaveBeenCalledTimes(1); + expect(sleepWithAbort).not.toHaveBeenCalled(); + }); + + it("retries cleanup when deleteWebhook and webhook confirmation both transiently fail", async () => { + const abort = new AbortController(); + const cleanupError = makeRecoverableFetchError(); + api.deleteWebhook.mockReset(); + api.getWebhookInfo.mockReset().mockRejectedValueOnce(makeRecoverableFetchError()); + api.deleteWebhook.mockRejectedValueOnce(cleanupError).mockResolvedValueOnce(true); + mockRunOnceAndAbort(abort); + + await monitorTelegramProvider({ token: "tok", abortSignal: abort.signal }); + + expect(api.deleteWebhook).toHaveBeenCalledTimes(2); + expect(api.getWebhookInfo).toHaveBeenCalledTimes(1); expectRecoverableRetryState(1); }); diff --git a/extensions/telegram/src/network-errors.test.ts b/extensions/telegram/src/network-errors.test.ts index b932ec72360..3e9a56d3b50 100644 --- a/extensions/telegram/src/network-errors.test.ts +++ b/extensions/telegram/src/network-errors.test.ts @@ -202,6 +202,14 @@ describe("isSafeToRetrySendError", () => { const wrapped = new MockHttpError("Network request for 'sendMessage' failed!", fetchError); expect(isSafeToRetrySendError(wrapped)).toBe(true); }); + + it("does not infer safe send retry from a plain grammY network envelope", () => { + const wrapped = new MockHttpError( + "Network request for 'sendMessage' failed!", + new TypeError("fetch failed"), + ); + expect(isSafeToRetrySendError(wrapped)).toBe(false); + }); }); describe("isTelegramServerError", () => { diff --git a/extensions/telegram/src/polling-session.ts b/extensions/telegram/src/polling-session.ts index 11cc14f755a..bc79f316561 100644 --- a/extensions/telegram/src/polling-session.ts +++ b/extensions/telegram/src/polling-session.ts @@ -212,6 +212,13 @@ export class TelegramPollingSession { this.#webhookCleared = true; return "ready"; } catch (err) { + if (await this.#confirmWebhookAlreadyAbsent(bot, err)) { + this.#webhookCleared = true; + this.opts.log( + "[telegram] deleteWebhook failed, but getWebhookInfo confirmed no webhook is set; continuing with polling.", + ); + return "ready"; + } const shouldRetry = await this.#waitBeforeRetryOnRecoverableSetupError( err, "Telegram webhook cleanup failed", @@ -220,6 +227,29 @@ 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),