From b861a0bd73e6c8dbf7c62a2cb99b400f40e2e4ea Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 19:24:13 -0500 Subject: [PATCH] Telegram: harden network retries and config Co-authored-by: techboss --- CHANGELOG.md | 1 + README.md | 29 ++--- docs/channels/telegram.md | 1 + docs/gateway/configuration.md | 3 + src/config/schema.ts | 3 + src/config/types.telegram.ts | 7 ++ src/config/zod-schema.providers-core.ts | 6 + src/infra/retry-policy.test.ts | 27 +++++ src/infra/retry-policy.ts | 7 +- ...patterns-match-without-botusername.test.ts | 1 + ...topic-skill-filters-system-prompts.test.ts | 1 + ...-all-group-messages-grouppolicy-is.test.ts | 1 + ...e-callback-query-updates-by-update.test.ts | 1 + ...gram-bot.installs-grammy-throttler.test.ts | 1 + ...lowfrom-entries-case-insensitively.test.ts | 1 + ...-case-insensitively-grouppolicy-is.test.ts | 1 + ...-dms-by-telegram-accountid-binding.test.ts | 1 + ...ies-without-native-reply-threading.test.ts | 1 + ...s-media-file-path-no-file-download.test.ts | 1 + ...udes-location-text-ctx-fields-pins.test.ts | 1 + src/telegram/bot.test.ts | 1 + src/telegram/bot.ts | 8 +- src/telegram/fetch.test.ts | 43 ++++++- src/telegram/fetch.ts | 37 ++++-- src/telegram/monitor.test.ts | 46 ++++++- src/telegram/monitor.ts | 29 +---- src/telegram/network-config.test.ts | 48 ++++++++ src/telegram/network-config.ts | 39 ++++++ src/telegram/network-errors.test.ts | 31 +++++ src/telegram/network-errors.ts | 112 ++++++++++++++++++ src/telegram/send.caption-split.test.ts | 1 + ...-thread-params-plain-text-fallback.test.ts | 1 + src/telegram/send.proxy.test.ts | 7 +- ...send.returns-undefined-empty-input.test.ts | 1 + src/telegram/send.ts | 8 +- src/telegram/webhook-set.ts | 11 +- 36 files changed, 457 insertions(+), 61 deletions(-) create mode 100644 src/infra/retry-policy.test.ts create mode 100644 src/telegram/network-config.test.ts create mode 100644 src/telegram/network-config.ts create mode 100644 src/telegram/network-errors.test.ts create mode 100644 src/telegram/network-errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4667e60d5..a83519fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Status: unreleased. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. diff --git a/README.md b/README.md index 3f8853b93..e72fe7e16 100644 --- a/README.md +++ b/README.md @@ -485,12 +485,12 @@ Thanks to all clawtributors: julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 - danielz1z emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams - sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby - buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 - roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer - pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons - austinm911 blacksmith-sh[bot] damoahdominic dan-dr dwfinkelstein HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + danielz1z AdeboyeDN emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 + CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc + travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status + gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer Josh Phillips + YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 + antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh @@ -500,12 +500,13 @@ Thanks to all clawtributors: itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin kitze - levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch - Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim - Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke Suksham-sharma - testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 - Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev - latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + Clawdbot Maintainers conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim + grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin + kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke + Suksham-sharma testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index e708e2e64..39f3a2ec3 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -529,6 +529,7 @@ Provider options: - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. - `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). - `channels.telegram.webhookUrl`: enable webhook mode. - `channels.telegram.webhookSecret`: webhook secret (optional). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 31dd1602b..9c850e070 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1029,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w maxDelayMs: 30000, jitter: 0.1 }, + network: { // transport overrides + autoSelectFamily: false + }, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", webhookSecret: "secret", diff --git a/src/config/schema.ts b/src/config/schema.ts index 9627d64f3..3261b5170 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -310,6 +310,7 @@ const FIELD_LABELS: Record = { "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", @@ -643,6 +644,8 @@ const FIELD_HELP: Record = { "channels.telegram.retry.maxDelayMs": "Maximum retry delay cap in ms for Telegram outbound calls.", "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.whatsapp.dmPolicy": diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index f6a7c3db8..fa9e2890a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -18,6 +18,11 @@ export type TelegramActionConfig = { editMessage?: boolean; }; +export type TelegramNetworkConfig = { + /** Override Node's autoSelectFamily behavior (true = enable, false = disable). */ + autoSelectFamily?: boolean; +}; + export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; export type TelegramCapabilitiesConfig = @@ -96,6 +101,8 @@ export type TelegramAccountConfig = { timeoutSeconds?: number; /** Retry policy for outbound Telegram API calls. */ retry?: OutboundRetryConfig; + /** Network transport overrides for Telegram. */ + network?: TelegramNetworkConfig; proxy?: string; webhookUrl?: string; webhookSecret?: string; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 374e6e8aa..26e279faf 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z mediaMaxMb: z.number().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), retry: RetryConfigSchema, + network: z + .object({ + autoSelectFamily: z.boolean().optional(), + }) + .strict() + .optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts new file mode 100644 index 000000000..02aedb087 --- /dev/null +++ b/src/infra/retry-policy.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTelegramRetryRunner } from "./retry-policy.js"; + +describe("createTelegramRetryRunner", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries when custom shouldRetry matches non-telegram error", async () => { + vi.useFakeTimers(); + const runner = createTelegramRetryRunner({ + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + shouldRetry: (err) => err instanceof Error && err.message === "boom", + }); + const fn = vi + .fn<[], Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue("ok"); + + const promise = runner(fn, "request"); + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts index f5a3c4b33..6d647aa5e 100644 --- a/src/infra/retry-policy.ts +++ b/src/infra/retry-policy.ts @@ -72,16 +72,21 @@ export function createTelegramRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; verbose?: boolean; + shouldRetry?: (err: unknown) => boolean; }): RetryRunner { const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, { ...params.configRetry, ...params.retry, }); + const shouldRetry = params.shouldRetry + ? (err: unknown) => params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err)) + : (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)); + return (fn: () => Promise, label?: string) => retryAsync(fn, { ...retryConfig, label, - shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)), + shouldRetry, retryAfterMs: getTelegramRetryAfterMs, onRetry: params.verbose ? (info) => { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 66e60ecca..0b2f9c9af 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -89,6 +89,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 1a7a9d40c..b5d154c42 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 0aa431d1b..d6c22256b 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 8ed8e189f..6e04be767 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index c30b5e33a..4c7a93529 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -90,6 +90,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index 805aa34da..4ddb83c02 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index ec81283bb..ba3d802e2 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 63ddd9bec..514ff1452 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index dffe8ee88..1aff63ed3 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -93,6 +93,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 2ea914874..b6c1ca419 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -32,6 +32,7 @@ vi.mock("grammy", () => ({ on = onSpy; command = vi.fn(); stop = stopSpy; + catch = vi.fn(); constructor(public token: string) {} }, InputFile: class {}, diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts index 2242941ce..f5ac0a268 100644 --- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts +++ b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts @@ -30,6 +30,7 @@ vi.mock("grammy", () => ({ on = onSpy; command = vi.fn(); stop = stopSpy; + catch = vi.fn(); constructor(public token: string) {} }, InputFile: class {}, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 8dc52ab57..274f7c6a9 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -126,6 +126,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index d1996bade..6705d359f 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -21,6 +21,7 @@ import { import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { formatUncaughtError } from "../infra/errors.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; @@ -118,7 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); const telegramCfg = account.config; - const fetchImpl = resolveTelegramFetch(opts.proxyFetch); + const fetchImpl = resolveTelegramFetch(opts.proxyFetch, { + network: telegramCfg.network, + }); const shouldProvideFetch = Boolean(fetchImpl); const timeoutSeconds = typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) @@ -137,6 +140,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { const bot = new Bot(opts.token, client ? { client } : undefined); bot.api.config.use(apiThrottler()); bot.use(sequentialize(getTelegramSequentialKey)); + bot.catch((err) => { + runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`)); + }); // Catch all errors from bot middleware to prevent unhandled rejections bot.catch((err) => { diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 4042be60d..17cda1d00 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -1,11 +1,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveTelegramFetch } from "./fetch.js"; - describe("resolveTelegramFetch", () => { const originalFetch = globalThis.fetch; + const loadModule = async () => { + const setDefaultAutoSelectFamily = vi.fn(); + vi.resetModules(); + vi.doMock("node:net", () => ({ + setDefaultAutoSelectFamily, + })); + const mod = await import("./fetch.js"); + return { resolveTelegramFetch: mod.resolveTelegramFetch, setDefaultAutoSelectFamily }; + }; + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); if (originalFetch) { globalThis.fetch = originalFetch; } else { @@ -13,16 +23,41 @@ describe("resolveTelegramFetch", () => { } }); - it("returns wrapped global fetch when available", () => { + it("returns wrapped global fetch when available", async () => { const fetchMock = vi.fn(async () => ({})); globalThis.fetch = fetchMock as unknown as typeof fetch; + const { resolveTelegramFetch } = await loadModule(); const resolved = resolveTelegramFetch(); expect(resolved).toBeTypeOf("function"); }); - it("prefers proxy fetch when provided", () => { + it("prefers proxy fetch when provided", async () => { const fetchMock = vi.fn(async () => ({})); + const { resolveTelegramFetch } = await loadModule(); const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch); expect(resolved).toBeTypeOf("function"); }); + + it("honors env enable override", async () => { + vi.stubEnv("CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1"); + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); + }); + + it("uses config override when provided", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); + }); + + it("env disable override wins over config", async () => { + vi.stubEnv("CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1"); + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 00a21be9b..ebed468c9 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,19 +1,36 @@ -import { setDefaultAutoSelectFamily } from "net"; +import * as net from "node:net"; import { resolveFetch } from "../infra/fetch.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js"; -// Workaround for Node.js 22 "Happy Eyeballs" (autoSelectFamily) bug -// that causes intermittent ETIMEDOUT errors when connecting to Telegram's -// dual-stack servers. Disabling autoSelectFamily forces sequential IPv4/IPv6 -// attempts which works reliably. +let appliedAutoSelectFamily: boolean | null = null; +const log = createSubsystemLogger("telegram/network"); + +// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts. // See: https://github.com/nodejs/node/issues/54359 -try { - setDefaultAutoSelectFamily(false); -} catch { - // Ignore if not available (older Node versions) +function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void { + const decision = resolveTelegramAutoSelectFamilyDecision({ network }); + if (decision.value === null || decision.value === appliedAutoSelectFamily) return; + appliedAutoSelectFamily = decision.value; + + if (typeof net.setDefaultAutoSelectFamily === "function") { + try { + net.setDefaultAutoSelectFamily(decision.value); + const label = decision.source ? ` (${decision.source})` : ""; + log.info(`telegram: autoSelectFamily=${decision.value}${label}`); + } catch { + // ignore if unsupported by the runtime + } + } } // Prefer wrapped fetch when available to normalize AbortSignal across runtimes. -export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { +export function resolveTelegramFetch( + proxyFetch?: typeof fetch, + options?: { network?: TelegramNetworkConfig }, +): typeof fetch | undefined { + applyTelegramNetworkWorkarounds(options?.network); if (proxyFetch) return resolveFetch(proxyFetch); const fetchImpl = resolveFetch(); if (!fetchImpl) { diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index bfd8c83ac..2fc46827b 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -35,6 +35,11 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({ })), })); +const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({ + computeBackoff: vi.fn(() => 0), + sleepWithAbort: vi.fn(async () => undefined), +})); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -70,6 +75,11 @@ vi.mock("@grammyjs/runner", () => ({ run: runSpy, })); +vi.mock("../infra/backoff.js", () => ({ + computeBackoff, + sleepWithAbort, +})); + vi.mock("../auto-reply/reply.js", () => ({ getReplyFromConfig: async (ctx: { Body?: string }) => ({ text: `echo:${ctx.Body}`, @@ -84,6 +94,8 @@ describe("monitorTelegramProvider (grammY)", () => { }); initSpy.mockClear(); runSpy.mockClear(); + computeBackoff.mockClear(); + sleepWithAbort.mockClear(); }); it("processes a DM and sends reply", async () => { @@ -119,7 +131,11 @@ describe("monitorTelegramProvider (grammY)", () => { expect.anything(), expect.objectContaining({ sink: { concurrency: 3 }, - runner: expect.objectContaining({ silent: true }), + runner: expect.objectContaining({ + silent: true, + maxRetryTime: 5 * 60 * 1000, + retryInterval: "exponential", + }), }), ); }); @@ -140,4 +156,32 @@ describe("monitorTelegramProvider (grammY)", () => { }); expect(api.sendMessage).not.toHaveBeenCalled(); }); + + it("retries on recoverable network errors", async () => { + const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + runSpy + .mockImplementationOnce(() => ({ + task: () => Promise.reject(networkError), + stop: vi.fn(), + })) + .mockImplementationOnce(() => ({ + task: () => Promise.resolve(), + stop: vi.fn(), + })); + + await monitorTelegramProvider({ token: "tok" }); + + expect(computeBackoff).toHaveBeenCalled(); + expect(sleepWithAbort).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalledTimes(2); + }); + + it("surfaces non-recoverable errors", async () => { + runSpy.mockImplementationOnce(() => ({ + task: () => Promise.reject(new Error("bad token")), + stop: vi.fn(), + })); + + await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token"); + }); }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index aeb5aae7c..5247c2af3 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -3,11 +3,13 @@ import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { formatDurationMs } from "../infra/format-duration.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; import { startTelegramWebhook } from "./webhook.js"; @@ -40,9 +42,8 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { return haystack.includes("getupdates"); }; -const isRecoverableNetworkError = (err: unknown): boolean => { - if (!err) return false; - const message = err instanceof Error ? err.message : String(err); - const lowerMessage = message.toLowerCase(); - // Recoverable network errors that should trigger retry, not crash - return ( - lowerMessage.includes("fetch failed") || - lowerMessage.includes("network request") || - lowerMessage.includes("econnrefused") || - lowerMessage.includes("econnreset") || - lowerMessage.includes("etimedout") || - lowerMessage.includes("socket hang up") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("abort") - ); -}; - export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveTelegramAccount({ @@ -154,7 +138,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } // Use grammyjs/runner for concurrent update processing - const log = opts.runtime?.log ?? console.log; let restartAttempts = 0; while (!opts.abortSignal?.aborted) { @@ -174,14 +157,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { throw err; } const isConflict = isGetUpdatesConflict(err); - const isNetworkError = isRecoverableNetworkError(err); - if (!isConflict && !isNetworkError) { + const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); + if (!isConflict && !isRecoverable) { throw err; } restartAttempts += 1; const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); const reason = isConflict ? "getUpdates conflict" : "network error"; - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = formatErrorMessage(err); (opts.runtime?.error ?? console.error)( `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, ); diff --git a/src/telegram/network-config.test.ts b/src/telegram/network-config.test.ts new file mode 100644 index 000000000..cb4bc4c6e --- /dev/null +++ b/src/telegram/network-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js"; + +describe("resolveTelegramAutoSelectFamilyDecision", () => { + it("prefers env enable over env disable", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + env: { + CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY: "1", + CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1", + }, + nodeMajor: 22, + }); + expect(decision).toEqual({ + value: true, + source: "env:CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", + }); + }); + + it("uses env disable when set", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + env: { CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1" }, + nodeMajor: 22, + }); + expect(decision).toEqual({ + value: false, + source: "env:CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", + }); + }); + + it("uses config override when provided", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + network: { autoSelectFamily: true }, + nodeMajor: 22, + }); + expect(decision).toEqual({ value: true, source: "config" }); + }); + + it("defaults to disable on Node 22", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 22 }); + expect(decision).toEqual({ value: false, source: "default-node22" }); + }); + + it("returns null when no decision applies", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 20 }); + expect(decision).toEqual({ value: null }); + }); +}); diff --git a/src/telegram/network-config.ts b/src/telegram/network-config.ts new file mode 100644 index 000000000..ac5dd05a7 --- /dev/null +++ b/src/telegram/network-config.ts @@ -0,0 +1,39 @@ +import process from "node:process"; + +import { isTruthyEnvValue } from "../infra/env.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; + +export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV = + "CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY"; +export const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY"; + +export type TelegramAutoSelectFamilyDecision = { + value: boolean | null; + source?: string; +}; + +export function resolveTelegramAutoSelectFamilyDecision(params?: { + network?: TelegramNetworkConfig; + env?: NodeJS.ProcessEnv; + nodeMajor?: number; +}): TelegramAutoSelectFamilyDecision { + const env = params?.env ?? process.env; + const nodeMajor = + typeof params?.nodeMajor === "number" + ? params.nodeMajor + : Number(process.versions.node.split(".")[0]); + + if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) { + return { value: true, source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}` }; + } + if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) { + return { value: false, source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}` }; + } + if (typeof params?.network?.autoSelectFamily === "boolean") { + return { value: params.network.autoSelectFamily, source: "config" }; + } + if (Number.isFinite(nodeMajor) && nodeMajor >= 22) { + return { value: false, source: "default-node22" }; + } + return { value: null }; +} diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts new file mode 100644 index 000000000..ae42cbb97 --- /dev/null +++ b/src/telegram/network-errors.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; + +describe("isRecoverableTelegramNetworkError", () => { + it("detects recoverable error codes", () => { + const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("detects AbortError names", () => { + const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("detects nested causes", () => { + const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + const err = Object.assign(new TypeError("fetch failed"), { cause }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("skips message matches for send context", () => { + const err = new TypeError("fetch failed"); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); + }); +}); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts new file mode 100644 index 000000000..70cd81994 --- /dev/null +++ b/src/telegram/network-errors.ts @@ -0,0 +1,112 @@ +import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; + +const RECOVERABLE_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_ABORTED", +]); + +const RECOVERABLE_ERROR_NAMES = new Set([ + "AbortError", + "TimeoutError", + "ConnectTimeoutError", + "HeadersTimeoutError", + "BodyTimeoutError", +]); + +const RECOVERABLE_MESSAGE_SNIPPETS = [ + "fetch failed", + "network error", + "network request", + "client network socket disconnected", + "socket hang up", + "getaddrinfo", +]; + +function normalizeCode(code?: string): string { + return code?.trim().toUpperCase() ?? ""; +} + +function getErrorName(err: unknown): string { + if (!err || typeof err !== "object") return ""; + return "name" in err ? String(err.name) : ""; +} + +function getErrorCode(err: unknown): string | undefined { + const direct = extractErrorCode(err); + if (direct) return direct; + if (!err || typeof err !== "object") return undefined; + const errno = (err as { errno?: unknown }).errno; + if (typeof errno === "string") return errno; + if (typeof errno === "number") return String(errno); + return undefined; +} + +function collectErrorCandidates(err: unknown): unknown[] { + const queue = [err]; + const seen = new Set(); + const candidates: unknown[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (current == null || seen.has(current)) continue; + seen.add(current); + candidates.push(current); + + if (typeof current === "object") { + const cause = (current as { cause?: unknown }).cause; + if (cause && !seen.has(cause)) queue.push(cause); + const reason = (current as { reason?: unknown }).reason; + if (reason && !seen.has(reason)) queue.push(reason); + const errors = (current as { errors?: unknown }).errors; + if (Array.isArray(errors)) { + for (const nested of errors) { + if (nested && !seen.has(nested)) queue.push(nested); + } + } + } + } + + return candidates; +} + +export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown"; + +export function isRecoverableTelegramNetworkError( + err: unknown, + options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {}, +): boolean { + if (!err) return false; + const allowMessageMatch = + typeof options.allowMessageMatch === "boolean" + ? options.allowMessageMatch + : options.context !== "send"; + + for (const candidate of collectErrorCandidates(err)) { + const code = normalizeCode(getErrorCode(candidate)); + if (code && RECOVERABLE_ERROR_CODES.has(code)) return true; + + const name = getErrorName(candidate); + if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true; + + if (allowMessageMatch) { + const message = formatErrorMessage(candidate).toLowerCase(); + if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { + return true; + } + } + } + + return false; +} diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts index 58e0a921a..7911e2890 100644 --- a/src/telegram/send.caption-split.test.ts +++ b/src/telegram/send.caption-split.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts index 18176d259..2f9e7d057 100644 --- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts +++ b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts index b395662e4..39ef9e2d0 100644 --- a/src/telegram/send.proxy.test.ts +++ b/src/telegram/send.proxy.test.ts @@ -40,6 +40,7 @@ vi.mock("./fetch.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } }, @@ -76,7 +77,7 @@ describe("telegram proxy client", () => { await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ @@ -94,7 +95,7 @@ describe("telegram proxy client", () => { await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ @@ -112,7 +113,7 @@ describe("telegram proxy client", () => { await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index 6e2ea85d0..d086fe2a3 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 43a3a5e8c..d28cff55e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -22,6 +22,7 @@ import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; import { renderTelegramHtmlText } from "./format.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -84,7 +85,9 @@ function resolveTelegramClientOptions( ): ApiClientOptions | undefined { const proxyUrl = account.config.proxy?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch); + const fetchImpl = resolveTelegramFetch(proxyFetch, { + network: account.config.network, + }); const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) @@ -203,6 +206,7 @@ export async function sendMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => @@ -434,6 +438,7 @@ export async function reactMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => @@ -483,6 +488,7 @@ export async function deleteMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts index eced660e6..2880c8254 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -1,4 +1,5 @@ import { type ApiClientOptions, Bot } from "grammy"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveTelegramFetch } from "./fetch.js"; export async function setTelegramWebhook(opts: { @@ -6,8 +7,9 @@ export async function setTelegramWebhook(opts: { url: string; secret?: string; dropPendingUpdates?: boolean; + network?: TelegramNetworkConfig; }) { - const fetchImpl = resolveTelegramFetch(); + const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network }); const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; @@ -18,8 +20,11 @@ export async function setTelegramWebhook(opts: { }); } -export async function deleteTelegramWebhook(opts: { token: string }) { - const fetchImpl = resolveTelegramFetch(); +export async function deleteTelegramWebhook(opts: { + token: string; + network?: TelegramNetworkConfig; +}) { + const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network }); const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined;