From 0de6f93805d8ffa2e07c68df2d8b0f62e40be809 Mon Sep 17 00:00:00 2001 From: SymbolStar Date: Fri, 15 May 2026 00:21:08 +0800 Subject: [PATCH] fix(telegram): reuse sticky IPv4 dispatcher for getMe health check (#76852) (#76856) Fixes #76852. Co-authored-by: jindongfu Co-authored-by: Frank Yang --- CHANGELOG.md | 1 + extensions/telegram/src/probe.test.ts | 72 +++++++++++++++++++++++---- extensions/telegram/src/probe.ts | 56 +++++++++++++-------- 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d70fd25fa3d..5c4830475b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong. - Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe. - macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev. +- Telegram: reuse the sticky IPv4 Bot API transport for periodic getMe health checks, so IPv4-working hosts with broken IPv6 egress stop logging repeated probe timeouts. Fixes #76852. (#76856) Thanks @SymbolStar. - Telegram: ship the isolated polling worker at the root dist path used by the bundled worker loader, avoiding startup failures looking for `dist/telegram-ingress-worker.runtime.js`. - Telegram: skip unmentioned group media before download when `requireMention` is active, avoiding failed media-download replies for messages that should be ignored. Fixes #81181. (#81785) Thanks @joshavant. - Control UI/Gateway: stop stale token-mismatch reconnect loops when no trusted device-token retry is available, and cap rendered chat history by raw tool-output size so dashboard auth/history work cannot keep degrading channel sockets. Fixes #72139. Thanks @BunsDev. diff --git a/extensions/telegram/src/probe.test.ts b/extensions/telegram/src/probe.test.ts index 1ddcdc2aa1d..50e9735465d 100644 --- a/extensions/telegram/src/probe.test.ts +++ b/extensions/telegram/src/probe.test.ts @@ -2,11 +2,11 @@ import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import { probeTelegram, resetTelegramProbeFetcherCacheForTests } from "./probe.js"; -const resolveTelegramFetch = vi.hoisted(() => vi.fn()); +const resolveTelegramTransport = vi.hoisted(() => vi.fn()); const makeProxyFetch = vi.hoisted(() => vi.fn()); vi.mock("./fetch.js", () => ({ - resolveTelegramFetch, + resolveTelegramTransport, resolveTelegramApiBase: (apiRoot?: string) => apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org", })); @@ -19,11 +19,18 @@ describe("probeTelegram retry logic", () => { const token = "test-token"; const timeoutMs = 5000; const originalFetch = global.fetch; + let forceFallbackMock: Mock; const installFetchMock = (): Mock => { const fetchMock = vi.fn(); global.fetch = withFetchPreconnect(fetchMock); - resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + forceFallbackMock = vi.fn().mockReturnValue(true); + resolveTelegramTransport.mockImplementation((proxyFetch?: typeof fetch) => ({ + fetch: proxyFetch ?? fetch, + sourceFetch: proxyFetch ?? fetch, + forceFallback: forceFallbackMock, + close: async () => {}, + })); makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); return fetchMock; }; @@ -72,7 +79,7 @@ describe("probeTelegram retry logic", () => { afterEach(() => { resetTelegramProbeFetcherCacheForTests(); - resolveTelegramFetch.mockReset(); + resolveTelegramTransport.mockReset(); makeProxyFetch.mockReset(); vi.unstubAllEnvs(); vi.clearAllMocks(); @@ -151,7 +158,12 @@ describe("probeTelegram retry logic", () => { }); }); global.fetch = withFetchPreconnect(fetchMock as unknown as typeof fetch); - resolveTelegramFetch.mockImplementation((proxyFetch?: typeof fetch) => proxyFetch ?? fetch); + resolveTelegramTransport.mockImplementation((proxyFetch?: typeof fetch) => ({ + fetch: proxyFetch ?? fetch, + sourceFetch: proxyFetch ?? fetch, + forceFallback: vi.fn().mockReturnValue(true), + close: async () => {}, + })); makeProxyFetch.mockImplementation(() => fetchMock as unknown as typeof fetch); vi.useFakeTimers(); try { @@ -226,7 +238,7 @@ describe("probeTelegram retry logic", () => { }); expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8888"); - expect(resolveTelegramFetch).toHaveBeenCalledWith(fetchMock, { + expect(resolveTelegramTransport).toHaveBeenCalledWith(fetchMock, { network: { autoSelectFamily: false, dnsResultOrder: "ipv4first", @@ -257,7 +269,7 @@ describe("probeTelegram retry logic", () => { }, }); - expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + expect(resolveTelegramTransport).toHaveBeenCalledTimes(1); }); it("does not reuse probe fetcher cache when network settings differ", async () => { @@ -283,7 +295,7 @@ describe("probeTelegram retry logic", () => { }, }); - expect(resolveTelegramFetch).toHaveBeenCalledTimes(2); + expect(resolveTelegramTransport).toHaveBeenCalledTimes(2); }); it("reuses probe fetcher cache across token rotation when accountId is stable", async () => { @@ -311,6 +323,48 @@ describe("probeTelegram retry logic", () => { }, }); - expect(resolveTelegramFetch).toHaveBeenCalledTimes(1); + expect(resolveTelegramTransport).toHaveBeenCalledTimes(1); + }); + + it("calls forceFallback on the transport when getMe times out so subsequent probes use IPv4", async () => { + const fetchMock = vi.fn(); + const localForceFallback = vi.fn().mockReturnValue(true); + resolveTelegramTransport.mockImplementation(() => ({ + fetch: withFetchPreconnect(fetchMock), + sourceFetch: fetchMock, + forceFallback: localForceFallback, + close: async () => {}, + })); + + // First call: timeout (simulate IPv6 hang) + const timeoutError = new Error("request timed out"); + timeoutError.name = "TimeoutError"; + fetchMock.mockRejectedValueOnce(timeoutError); + // Second call (retry after forceFallback): success on IPv4 + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ + ok: true, + result: { id: 1, is_bot: true, first_name: "Bot", username: "bot" }, + }), + }); + // Webhook info + fetchMock.mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue({ ok: true, result: { url: "" } }), + }); + + vi.useFakeTimers(); + try { + const probePromise = probeTelegram(token, 30_000); + await vi.advanceTimersByTimeAsync(1000); + + const result = await probePromise; + expect(result.ok).toBe(true); + expect(localForceFallback).toHaveBeenCalledWith("probe timeout/network error"); + expect(fetchMock).toHaveBeenCalledTimes(3); // 1 failed + 1 getMe success + 1 webhook + } finally { + vi.useRealTimers(); + } }); }); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index 859cc464057..de81e64d20c 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -3,7 +3,11 @@ import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-contracts import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; import type { TelegramBotInfo } from "./bot-info.js"; -import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js"; +import { + resolveTelegramApiBase, + resolveTelegramTransport, + type TelegramTransport, +} from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; export type TelegramProbe = BaseProbeResult & { @@ -35,11 +39,11 @@ export type TelegramProbeOptions = { includeWebhookInfo?: boolean; }; -const probeFetcherCache = new Map(); -const MAX_PROBE_FETCHER_CACHE_SIZE = 64; +const probeTransportCache = new Map(); +const MAX_PROBE_TRANSPORT_CACHE_SIZE = 64; export function resetTelegramProbeFetcherCacheForTests(): void { - probeFetcherCache.clear(); + probeTransportCache.clear(); } function resolveProbeOptions( @@ -54,11 +58,11 @@ function resolveProbeOptions( return proxyOrOptions; } -function shouldUseProbeFetcherCache(): boolean { +function shouldUseProbeTransportCache(): boolean { return !process.env.VITEST && process.env.NODE_ENV !== "test"; } -function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions): string { +function buildProbeTransportCacheKey(token: string, options?: TelegramProbeOptions): string { const cacheIdentity = options?.accountId?.trim() || token; const cacheIdentityKind = options?.accountId?.trim() ? "account" : "token"; const proxyKey = options?.proxyUrl?.trim() ?? ""; @@ -70,37 +74,40 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`; } -function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch { - probeFetcherCache.set(cacheKey, fetcher); - if (probeFetcherCache.size > MAX_PROBE_FETCHER_CACHE_SIZE) { - const oldestKey = probeFetcherCache.keys().next().value; +function setCachedProbeTransport( + cacheKey: string, + transport: TelegramTransport, +): TelegramTransport { + probeTransportCache.set(cacheKey, transport); + if (probeTransportCache.size > MAX_PROBE_TRANSPORT_CACHE_SIZE) { + const oldestKey = probeTransportCache.keys().next().value; if (oldestKey !== undefined) { - probeFetcherCache.delete(oldestKey); + probeTransportCache.delete(oldestKey); } } - return fetcher; + return transport; } -function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typeof fetch { - const cacheEnabled = shouldUseProbeFetcherCache(); - const cacheKey = cacheEnabled ? buildProbeFetcherCacheKey(token, options) : null; +function resolveProbeTransport(token: string, options?: TelegramProbeOptions): TelegramTransport { + const cacheEnabled = shouldUseProbeTransportCache(); + const cacheKey = cacheEnabled ? buildProbeTransportCacheKey(token, options) : null; if (cacheKey) { - const cachedFetcher = probeFetcherCache.get(cacheKey); - if (cachedFetcher) { - return cachedFetcher; + const cached = probeTransportCache.get(cacheKey); + if (cached) { + return cached; } } const proxyUrl = options?.proxyUrl?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const resolved = resolveTelegramFetch(proxyFetch, { + const transport = resolveTelegramTransport(proxyFetch, { network: options?.network, }); if (cacheKey) { - return setCachedProbeFetcher(cacheKey, resolved); + return setCachedProbeTransport(cacheKey, transport); } - return resolved; + return transport; } function normalizeBoolean(value: unknown): boolean | null { @@ -148,7 +155,8 @@ export async function probeTelegram( const deadlineMs = started + timeoutBudgetMs; const options = resolveProbeOptions(proxyOrOptions); const includeWebhookInfo = options?.includeWebhookInfo !== false; - const fetcher = resolveProbeFetcher(token, options); + const transport = resolveProbeTransport(token, options); + const fetcher = transport.fetch; const apiBase = resolveTelegramApiBase(options?.apiRoot); const base = `${apiBase}/bot${token}`; const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5))); @@ -181,6 +189,10 @@ export async function probeTelegram( break; } catch (err) { fetchError = err; + // On timeout or network error, promote the transport to its IPv4 + // fallback dispatcher so the next retry (and all future probes + // sharing this cached transport) skip the stalled IPv6 path. + transport.forceFallback?.("probe timeout/network error"); if (i < 2) { const remainingAfterAttemptMs = resolveRemainingBudgetMs(); if (remainingAfterAttemptMs <= 0) {