fix(telegram): reuse sticky IPv4 dispatcher for getMe health check (#76852) (#76856)

Fixes #76852.

Co-authored-by: jindongfu <jindongfu@microsoft.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
SymbolStar
2026-05-15 00:21:08 +08:00
committed by GitHub
parent 85eb3cda65
commit 0de6f93805
3 changed files with 98 additions and 31 deletions

View File

@@ -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-<slug>.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.

View File

@@ -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();
}
});
});

View File

@@ -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<string, typeof fetch>();
const MAX_PROBE_FETCHER_CACHE_SIZE = 64;
const probeTransportCache = new Map<string, TelegramTransport>();
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) {