diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 8676bce4e97..3867224fc7a 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -670,6 +670,25 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - If DNS/IPv6 selection is unstable, force Node family selection behavior explicitly: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment override (temporary): set `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1`. - Validate DNS answers: ```bash diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index 7c836e1b4ac..ff12faaa217 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -169,8 +169,12 @@ describe("monitorTelegramProvider (grammY)", () => { expect(api.sendMessage).not.toHaveBeenCalled(); }); - it("retries on recoverable network errors", async () => { - const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + it("retries on recoverable undici fetch errors", async () => { + const networkError = Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect timeout"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }); runSpy .mockImplementationOnce(() => ({ task: () => Promise.reject(networkError), diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts index c435320bd54..358899a80a6 100644 --- a/src/telegram/network-errors.test.ts +++ b/src/telegram/network-errors.test.ts @@ -30,8 +30,17 @@ describe("isRecoverableTelegramNetworkError", () => { expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true); }); - it("skips message matches for send context", () => { + it("treats undici fetch failed errors as recoverable in send context", () => { const err = new TypeError("fetch failed"); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(true); + expect( + isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"), { context: "send" }), + ).toBe(true); + expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); + }); + + it("skips broad message matches for send context", () => { + const err = new Error("Network request for 'sendMessage' failed!"); expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); }); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts index 75c22ea7fa5..1532d6a42ea 100644 --- a/src/telegram/network-errors.ts +++ b/src/telegram/network-errors.ts @@ -40,6 +40,13 @@ const RECOVERABLE_MESSAGE_SNIPPETS = [ "timed out", // grammY getUpdates returns "timed out after X seconds" (not matched by "timeout") ]; +// Undici surface errors as TypeError("fetch failed") with optional nested causes. +// Treat this exact shape as recoverable even when broad message matching is disabled. +function isUndiciFetchFailedError(err: unknown): boolean { + const message = formatErrorMessage(err).trim().toLowerCase(); + return message === "fetch failed" || message === "typeerror: fetch failed"; +} + function normalizeCode(code?: string): string { return code?.trim().toUpperCase() ?? ""; } @@ -128,6 +135,10 @@ export function isRecoverableTelegramNetworkError( : options.context !== "send"; for (const candidate of collectErrorCandidates(err)) { + if (isUndiciFetchFailedError(candidate)) { + return true; + } + const code = normalizeCode(getErrorCode(candidate)); if (code && RECOVERABLE_ERROR_CODES.has(code)) { return true;