diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 7a3a796b5..d4f0891da 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -6,6 +6,7 @@ import { loadConfig } from "../config/config.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; import { formatDurationMs } from "../infra/format-duration.js"; +import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; @@ -78,133 +79,137 @@ const isGetUpdatesConflict = (err: unknown) => { return haystack.includes("getupdates"); }; -const NETWORK_ERROR_SNIPPETS = [ - "fetch failed", - "network", - "timeout", - "socket", - "econnreset", - "econnrefused", - "undici", -]; - -const isNetworkRelatedError = (err: unknown) => { - if (!err) { +/** Check if error is a Grammy HttpError (used to scope unhandled rejection handling) */ +const isGrammyHttpError = (err: unknown): boolean => { + if (!err || typeof err !== "object") { return false; } - const message = formatErrorMessage(err).toLowerCase(); - if (!message) { - return false; - } - return NETWORK_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); + return (err as { name?: string }).name === "HttpError"; }; export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { - const cfg = opts.config ?? loadConfig(); - const account = resolveTelegramAccount({ - cfg, - accountId: opts.accountId, - }); - const token = opts.token?.trim() || account.token; - if (!token) { - throw new Error( - `Telegram bot token missing for account "${account.accountId}" (set channels.telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`, - ); - } + const log = opts.runtime?.error ?? console.error; - const proxyFetch = - opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined); - - let lastUpdateId = await readTelegramUpdateOffset({ - accountId: account.accountId, - }); - const persistUpdateId = async (updateId: number) => { - if (lastUpdateId !== null && updateId <= lastUpdateId) { - return; + // Register handler for Grammy HttpError unhandled rejections. + // This catches network errors that escape the polling loop's try-catch + // (e.g., from setMyCommands during bot setup). + // We gate on isGrammyHttpError to avoid suppressing non-Telegram errors. + const unregisterHandler = registerUnhandledRejectionHandler((err) => { + if (isGrammyHttpError(err) && isRecoverableTelegramNetworkError(err, { context: "polling" })) { + log(`[telegram] Suppressed network error: ${formatErrorMessage(err)}`); + return true; // handled - don't crash } - lastUpdateId = updateId; - try { - await writeTelegramUpdateOffset({ - accountId: account.accountId, - updateId, - }); - } catch (err) { - (opts.runtime?.error ?? console.error)( - `telegram: failed to persist update offset: ${String(err)}`, + return false; + }); + + try { + const cfg = opts.config ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = opts.token?.trim() || account.token; + if (!token) { + throw new Error( + `Telegram bot token missing for account "${account.accountId}" (set channels.telegram.accounts.${account.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`, ); } - }; - const bot = createTelegramBot({ - token, - runtime: opts.runtime, - proxyFetch, - config: cfg, - accountId: account.accountId, - updateOffset: { - lastUpdateId, - onUpdateId: persistUpdateId, - }, - }); + const proxyFetch = + opts.proxyFetch ?? (account.config.proxy ? makeProxyFetch(account.config.proxy) : undefined); - if (opts.useWebhook) { - await startTelegramWebhook({ - token, + let lastUpdateId = await readTelegramUpdateOffset({ accountId: account.accountId, - config: cfg, - path: opts.webhookPath, - port: opts.webhookPort, - secret: opts.webhookSecret, - runtime: opts.runtime as RuntimeEnv, - fetch: proxyFetch, - abortSignal: opts.abortSignal, - publicUrl: opts.webhookUrl, }); - return; - } - - // Use grammyjs/runner for concurrent update processing - let restartAttempts = 0; - - while (!opts.abortSignal?.aborted) { - const runner = run(bot, createTelegramRunnerOptions(cfg)); - const stopOnAbort = () => { - if (opts.abortSignal?.aborted) { - void runner.stop(); + const persistUpdateId = async (updateId: number) => { + if (lastUpdateId !== null && updateId <= lastUpdateId) { + return; + } + lastUpdateId = updateId; + try { + await writeTelegramUpdateOffset({ + accountId: account.accountId, + updateId, + }); + } catch (err) { + (opts.runtime?.error ?? console.error)( + `telegram: failed to persist update offset: ${String(err)}`, + ); } }; - opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); - try { - // runner.task() returns a promise that resolves when the runner stops - await runner.task(); + + const bot = createTelegramBot({ + token, + runtime: opts.runtime, + proxyFetch, + config: cfg, + accountId: account.accountId, + updateOffset: { + lastUpdateId, + onUpdateId: persistUpdateId, + }, + }); + + if (opts.useWebhook) { + await startTelegramWebhook({ + token, + accountId: account.accountId, + config: cfg, + path: opts.webhookPath, + port: opts.webhookPort, + secret: opts.webhookSecret, + runtime: opts.runtime as RuntimeEnv, + fetch: proxyFetch, + abortSignal: opts.abortSignal, + publicUrl: opts.webhookUrl, + }); return; - } catch (err) { - if (opts.abortSignal?.aborted) { - throw err; - } - const isConflict = isGetUpdatesConflict(err); - const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); - const isNetworkError = isNetworkRelatedError(err); - if (!isConflict && !isRecoverable && !isNetworkError) { - throw err; - } - restartAttempts += 1; - const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); - const reason = isConflict ? "getUpdates conflict" : "network error"; - const errMsg = formatErrorMessage(err); - (opts.runtime?.error ?? console.error)( - `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, - ); - try { - await sleepWithAbort(delayMs, opts.abortSignal); - } catch (sleepErr) { - if (opts.abortSignal?.aborted) { - return; - } - throw sleepErr; - } - } finally { - opts.abortSignal?.removeEventListener("abort", stopOnAbort); } + + // Use grammyjs/runner for concurrent update processing + let restartAttempts = 0; + + while (!opts.abortSignal?.aborted) { + const runner = run(bot, createTelegramRunnerOptions(cfg)); + const stopOnAbort = () => { + if (opts.abortSignal?.aborted) { + void runner.stop(); + } + }; + opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); + try { + // runner.task() returns a promise that resolves when the runner stops + await runner.task(); + return; + } catch (err) { + if (opts.abortSignal?.aborted) { + throw err; + } + const isConflict = isGetUpdatesConflict(err); + 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 = formatErrorMessage(err); + (opts.runtime?.error ?? console.error)( + `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, + ); + try { + await sleepWithAbort(delayMs, opts.abortSignal); + } catch (sleepErr) { + if (opts.abortSignal?.aborted) { + return; + } + throw sleepErr; + } + } finally { + opts.abortSignal?.removeEventListener("abort", stopOnAbort); + } + } + } finally { + unregisterHandler(); } } diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index 462932bd2..bb4b0ef1b 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -39,4 +39,43 @@ describe("isRecoverableTelegramNetworkError", () => { it("returns false for unrelated errors", () => { expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); }); + + // Grammy HttpError tests (issue #3815) + // Grammy wraps fetch errors in .error property, not .cause + describe("Grammy HttpError", () => { + class MockHttpError extends Error { + constructor( + message: string, + public readonly error: unknown, + ) { + super(message); + this.name = "HttpError"; + } + } + + it("detects network error wrapped in HttpError", () => { + const fetchError = new TypeError("fetch failed"); + const httpError = new MockHttpError( + "Network request for 'setMyCommands' failed!", + fetchError, + ); + + expect(isRecoverableTelegramNetworkError(httpError)).toBe(true); + }); + + it("detects network error with cause wrapped in HttpError", () => { + const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + const fetchError = Object.assign(new TypeError("fetch failed"), { cause }); + const httpError = new MockHttpError("Network request for 'getUpdates' failed!", fetchError); + + expect(isRecoverableTelegramNetworkError(httpError)).toBe(true); + }); + + it("returns false for non-network errors wrapped in HttpError", () => { + const authError = new Error("Unauthorized: bot token is invalid"); + const httpError = new MockHttpError("Bad Request: invalid token", authError); + + expect(isRecoverableTelegramNetworkError(httpError)).toBe(false); + }); + }); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 0b658f0c9..392df9781 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -36,6 +36,7 @@ const RECOVERABLE_MESSAGE_SNIPPETS = [ "client network socket disconnected", "socket hang up", "getaddrinfo", + "timeout", // catch timeout messages not covered by error codes/names ]; function normalizeCode(code?: string): string { @@ -97,6 +98,14 @@ function collectErrorCandidates(err: unknown): unknown[] { } } } + // Grammy's HttpError wraps the underlying error in .error (not .cause) + // Only follow .error for HttpError to avoid widening the search graph + if (getErrorName(current) === "HttpError") { + const wrappedError = (current as { error?: unknown }).error; + if (wrappedError && !seen.has(wrappedError)) { + queue.push(wrappedError); + } + } } }