From ac00065727613fffc605cb3cfd3fd55aa634ae84 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 21:58:42 +0000 Subject: [PATCH] fix: normalize telegram fetch for long-polling --- CHANGELOG.md | 1 + ...gram-bot.installs-grammy-throttler.test.ts | 31 +----------- src/telegram/bot.test.ts | 32 +------------ src/telegram/bot.ts | 3 +- src/telegram/fetch.test.ts | 47 ++++++++----------- src/telegram/fetch.ts | 4 +- 6 files changed, 24 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdaa23031..88de44537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot ### Fixes - Web UI: hide internal `message_id` hints in chat bubbles. - Heartbeat: normalize target identifiers for consistent routing. +- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. 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 ebbd3b092..c30b5e33a 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 @@ -177,13 +177,11 @@ describe("createTelegramBot", () => { expect(throttlerSpy).toHaveBeenCalledTimes(1); expect(useSpy).toHaveBeenCalledWith("throttler"); }); - it("forces native fetch only under Bun", () => { + it("uses wrapped fetch when global fetch is available", () => { const originalFetch = globalThis.fetch; - const originalBun = (globalThis as { Bun?: unknown }).Bun; const fetchSpy = vi.fn() as unknown as typeof fetch; globalThis.fetch = fetchSpy; try { - (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); expect(fetchImpl).toBeTypeOf("function"); @@ -194,33 +192,6 @@ describe("createTelegramBot", () => { expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; - if (originalBun === undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } else { - (globalThis as { Bun?: unknown }).Bun = originalBun; - } - } - }); - it("does not force native fetch on Node", () => { - const originalFetch = globalThis.fetch; - const originalBun = (globalThis as { Bun?: unknown }).Bun; - const fetchSpy = vi.fn() as unknown as typeof fetch; - globalThis.fetch = fetchSpy; - try { - if (originalBun !== undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } - createTelegramBot({ token: "tok" }); - const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBeUndefined(); - expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined); - } finally { - globalThis.fetch = originalFetch; - if (originalBun === undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } else { - (globalThis as { Bun?: unknown }).Bun = originalBun; - } } }); it("passes timeoutSeconds even without a custom fetch", () => { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index cb1ee3381..ab2b55505 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -309,13 +309,11 @@ describe("createTelegramBot", () => { expect(registered.some((command) => reserved.includes(command.command))).toBe(false); }); - it("forces native fetch only under Bun", () => { + it("uses wrapped fetch when global fetch is available", () => { const originalFetch = globalThis.fetch; - const originalBun = (globalThis as { Bun?: unknown }).Bun; const fetchSpy = vi.fn() as unknown as typeof fetch; globalThis.fetch = fetchSpy; try { - (globalThis as { Bun?: unknown }).Bun = {}; createTelegramBot({ token: "tok" }); const fetchImpl = resolveTelegramFetch(); expect(fetchImpl).toBeTypeOf("function"); @@ -326,34 +324,6 @@ describe("createTelegramBot", () => { expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; - if (originalBun === undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } else { - (globalThis as { Bun?: unknown }).Bun = originalBun; - } - } - }); - - it("does not force native fetch on Node", () => { - const originalFetch = globalThis.fetch; - const originalBun = (globalThis as { Bun?: unknown }).Bun; - const fetchSpy = vi.fn() as unknown as typeof fetch; - globalThis.fetch = fetchSpy; - try { - if (originalBun !== undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } - createTelegramBot({ token: "tok" }); - const fetchImpl = resolveTelegramFetch(); - expect(fetchImpl).toBeUndefined(); - expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined); - } finally { - globalThis.fetch = originalFetch; - if (originalBun === undefined) { - delete (globalThis as { Bun?: unknown }).Bun; - } else { - (globalThis as { Bun?: unknown }).Bun = originalBun; - } } }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 230684d85..de7d715ab 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -117,8 +117,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const telegramCfg = account.config; const fetchImpl = resolveTelegramFetch(opts.proxyFetch); - const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); - const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun; + const shouldProvideFetch = Boolean(fetchImpl); const timeoutSeconds = typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) ? Math.max(1, Math.floor(telegramCfg.timeoutSeconds)) diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index f1a2353c2..4042be60d 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -1,37 +1,28 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveTelegramFetch } from "./fetch.js"; describe("resolveTelegramFetch", () => { - it("wraps proxy fetch to normalize foreign abort signals", async () => { - let seenSignal: AbortSignal | undefined; - const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - seenSignal = init?.signal as AbortSignal | undefined; - return {} as Response; - }); + const originalFetch = globalThis.fetch; - const fetcher = resolveTelegramFetch(proxyFetch); - expect(fetcher).toBeTypeOf("function"); + afterEach(() => { + if (originalFetch) { + globalThis.fetch = originalFetch; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + }); - let abortHandler: (() => void) | null = null; - const fakeSignal = { - aborted: false, - addEventListener: (event: string, handler: () => void) => { - if (event === "abort") abortHandler = handler; - }, - removeEventListener: (event: string, handler: () => void) => { - if (event === "abort" && abortHandler === handler) abortHandler = null; - }, - } as AbortSignal; + it("returns wrapped global fetch when available", () => { + const fetchMock = vi.fn(async () => ({})); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const resolved = resolveTelegramFetch(); + expect(resolved).toBeTypeOf("function"); + }); - const promise = fetcher!("https://example.com", { signal: fakeSignal }); - expect(proxyFetch).toHaveBeenCalledOnce(); - expect(seenSignal).toBeInstanceOf(AbortSignal); - expect(seenSignal).not.toBe(fakeSignal); - - abortHandler?.(); - expect(seenSignal?.aborted).toBe(true); - - await promise; + it("prefers proxy fetch when provided", () => { + const fetchMock = vi.fn(async () => ({})); + const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch); + expect(resolved).toBeTypeOf("function"); }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index ee1c6780c..7fdaef301 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,10 +1,8 @@ import { resolveFetch } from "../infra/fetch.js"; -// Bun-only: force native fetch to avoid grammY's Node shim under Bun. +// Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { if (proxyFetch) return resolveFetch(proxyFetch); - const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); - if (!isBun) return undefined; const fetchImpl = resolveFetch(); if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config");