From dc9f1b8525b18fc2ec2db6c2d6d90be68e4495f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 12:18:39 +0100 Subject: [PATCH] fix(telegram): honor managed proxy env --- CHANGELOG.md | 1 + docs/channels/telegram.md | 2 + docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-subpaths.md | 2 +- extensions/telegram/src/fetch.test.ts | 109 ++++++++++++++++++++++---- extensions/telegram/src/fetch.ts | 25 ++++-- src/plugin-sdk/fetch-runtime.ts | 2 + 7 files changed, 120 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da106cf4d1..6239e0d5071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest. +- Channels/Telegram: honor `ALL_PROXY` / `all_proxy` and service-level `OPENCLAW_PROXY_URL` when constructing the HTTP/1-only Telegram Bot API transport, so Windows and service installs that rely on those proxy settings no longer fall back to direct egress. Fixes #74014; refs #74086. Thanks @SymbolStar. - ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc. - TUI/status: clear stale `streaming` footer state when a final event arrives after the active run was already cleared and no tracked runs remain, while preserving concurrent-run ownership and inactive local `/btw` terminal handling. Fixes #64825; carries forward #64842, #64843, #64847, and #64862. Thanks @briandevans and @Yanhu007. - Channels/Discord: fail startup closed when Discord cannot resolve the bot's own identity and keep mention gating active when only configured mention patterns can detect mentions, so the provider no longer continues with a missing bot id. Fixes #42219; carries forward #46856 and #49218. Thanks @education-01 and @BenediktSchackenberg. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 999ca4e22f9..ac94b573a42 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -867,6 +867,8 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. - If logs include `Polling stall detected`, OpenClaw restarts polling and rebuilds the Telegram transport after 120 seconds without completed long-poll liveness by default. - Increase `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues between the host and `api.telegram.org`. + - Telegram also honors process proxy env for Bot API transport, including `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants. `NO_PROXY` / `no_proxy` can still bypass `api.telegram.org`. + - If the OpenClaw managed proxy is configured through `OPENCLAW_PROXY_URL` for a service environment and no standard proxy env is present, Telegram uses that URL for Bot API transport too. - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: ```yaml diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 1033cdb7fb3..eb0e2439468 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -438,7 +438,7 @@ releases. | `plugin-sdk/collection-runtime` | Bounded cache helpers | `pruneMapToMaxSize` | | `plugin-sdk/diagnostic-runtime` | Diagnostic gating helpers | `isDiagnosticFlagEnabled`, `isDiagnosticsEnabled` | | `plugin-sdk/error-runtime` | Error formatting helpers | `formatUncaughtError`, `isApprovalNotFoundError`, error graph helpers | - | `plugin-sdk/fetch-runtime` | Wrapped fetch/proxy helpers | `resolveFetch`, proxy helpers | + | `plugin-sdk/fetch-runtime` | Wrapped fetch/proxy helpers | `resolveFetch`, proxy helpers, EnvHttpProxyAgent option helpers | | `plugin-sdk/host-runtime` | Host normalization helpers | `normalizeHostname`, `normalizeScpRemoteHost` | | `plugin-sdk/retry-runtime` | Retry helpers | `RetryConfig`, `retryAsync`, policy runners | | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index be679985d56..2049138699a 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -241,7 +241,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/collection-runtime` | Small bounded cache helpers | | `plugin-sdk/diagnostic-runtime` | Diagnostic flag, event, and trace-context helpers | | `plugin-sdk/error-runtime` | Error graph, formatting, shared error classification helpers, `isApprovalNotFoundError` | - | `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, and pinned lookup helpers | + | `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, EnvHttpProxyAgent option, and pinned lookup helpers | | `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports | | `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface | | `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores | diff --git a/extensions/telegram/src/fetch.test.ts b/extensions/telegram/src/fetch.test.ts index 4bf75327c8f..e4ac543e8a7 100644 --- a/extensions/telegram/src/fetch.test.ts +++ b/extensions/telegram/src/fetch.test.ts @@ -123,6 +123,7 @@ beforeEach(() => { "https_proxy", "NO_PROXY", "no_proxy", + "OPENCLAW_PROXY_URL", ]) { vi.stubEnv(key, ""); } @@ -386,6 +387,11 @@ describe("resolveTelegramFetch", () => { await resolved("https://api.telegram.org/botx/getMe"); expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + httpsProxy: "http://127.0.0.1:7890", + }), + ); expect(AgentCtor).not.toHaveBeenCalled(); const dispatcher = getDispatcherFromUndiciCall(1); @@ -421,6 +427,80 @@ describe("resolveTelegramFetch", () => { ); }); + it("uses OPENCLAW_PROXY_URL as a Telegram explicit proxy when proxy env is absent", async () => { + vi.stubEnv("OPENCLAW_PROXY_URL", "http://127.0.0.1:7788"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const transport = resolveTelegramTransport(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + await transport.fetch("https://api.telegram.org/botTOKEN/getMe"); + + expect(ProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(ProxyAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + allowH2: false, + uri: "http://127.0.0.1:7788", + requestTls: expect.objectContaining({ + autoSelectFamily: false, + }), + }), + ); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).not.toHaveBeenCalled(); + expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual( + expect.objectContaining({ + mode: "explicit-proxy", + proxyUrl: "http://127.0.0.1:7788", + }), + ); + }); + + it("preserves caller-provided custom fetch when OPENCLAW_PROXY_URL is present", async () => { + vi.stubEnv("OPENCLAW_PROXY_URL", "http://127.0.0.1:7788"); + const proxyFetch = vi.fn(async () => ({ ok: true }) as Response) as unknown as typeof fetch; + + const transport = resolveTelegramTransport(proxyFetch, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + await transport.fetch("https://api.telegram.org/botTOKEN/getMe"); + + expect(proxyFetch).toHaveBeenCalledTimes(1); + expect(undiciFetch).not.toHaveBeenCalled(); + expect(ProxyAgentCtor).not.toHaveBeenCalled(); + expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).not.toHaveBeenCalled(); + expect(transport.sourceFetch).not.toBe(undiciFetch); + expect(transport.dispatcherAttempts).toBeUndefined(); + }); + + it("prefers standard proxy env over OPENCLAW_PROXY_URL for Telegram", async () => { + vi.stubEnv("OPENCLAW_PROXY_URL", "http://127.0.0.1:7788"); + vi.stubEnv("https_proxy", "http://127.0.0.1:7890"); + undiciFetch.mockResolvedValue({ ok: true } as Response); + + const resolved = resolveTelegramFetchOrThrow(undefined, { + network: { + autoSelectFamily: false, + dnsResultOrder: "ipv4first", + }, + }); + + await resolved("https://api.telegram.org/botx/getMe"); + + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(ProxyAgentCtor).not.toHaveBeenCalled(); + expect(AgentCtor).not.toHaveBeenCalled(); + }); + it("pins env-proxy transport policy onto proxyTls for proxied HTTPS requests", async () => { vi.stubEnv("https_proxy", "http://127.0.0.1:7890"); undiciFetch.mockResolvedValue({ ok: true } as Response); @@ -572,12 +652,10 @@ describe("resolveTelegramFetch", () => { }); }); - it("treats ALL_PROXY-only env as direct transport and arms sticky IPv4 fallback", async () => { - vi.stubEnv("ALL_PROXY", "socks5://127.0.0.1:1080"); - undiciFetch - .mockRejectedValueOnce(buildFetchFallbackError("EHOSTUNREACH")) - .mockResolvedValueOnce({ ok: true } as Response) - .mockResolvedValueOnce({ ok: true } as Response); + it("uses ALL_PROXY env as EnvHttpProxyAgent transport", async () => { + vi.stubEnv("ALL_PROXY", "http://127.0.0.1:7891"); + vi.stubEnv("all_proxy", "http://127.0.0.1:7891"); + undiciFetch.mockResolvedValue({ ok: true } as Response); const transport = resolveTelegramTransport(undefined, { network: { @@ -588,19 +666,20 @@ describe("resolveTelegramFetch", () => { const resolved = transport.fetch; await resolved("https://api.telegram.org/botx/sendMessage"); - await resolved("https://api.telegram.org/botx/sendChatAction"); - expect(EnvHttpProxyAgentCtor).not.toHaveBeenCalled(); - expect(AgentCtor).toHaveBeenCalledTimes(2); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledTimes(1); + expect(EnvHttpProxyAgentCtor).toHaveBeenCalledWith( + expect.objectContaining({ + allowH2: false, + httpProxy: "http://127.0.0.1:7891", + httpsProxy: "http://127.0.0.1:7891", + }), + ); + expect(AgentCtor).not.toHaveBeenCalled(); - expectPinnedIpv4ConnectDispatcher({ - firstCall: 1, - pinnedCall: 2, - followupCall: 3, - }); expect(transport.dispatcherAttempts?.[0]?.dispatcherPolicy).toEqual( expect.objectContaining({ - mode: "direct", + mode: "env-proxy", }), ); }); diff --git a/extensions/telegram/src/fetch.ts b/extensions/telegram/src/fetch.ts index df6e65ab7fa..0fdb95f1dc9 100644 --- a/extensions/telegram/src/fetch.ts +++ b/extensions/telegram/src/fetch.ts @@ -4,7 +4,8 @@ import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createPinnedLookup, - hasEnvHttpProxyConfigured, + hasEnvHttpProxyAgentConfigured, + resolveEnvHttpProxyAgentOptions, resolveFetch, type PinnedDispatcherPolicy, } from "openclaw/plugin-sdk/fetch-runtime"; @@ -225,7 +226,14 @@ function shouldBypassEnvProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env } function hasEnvHttpProxyForTelegramApi(env: NodeJS.ProcessEnv = process.env): boolean { - return hasEnvHttpProxyConfigured("https", env); + return hasEnvHttpProxyAgentConfigured(env); +} + +function resolveOpenClawProxyUrlForTelegram( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const proxyUrl = env.OPENCLAW_PROXY_URL?.trim(); + return proxyUrl ? proxyUrl : undefined; } function resolveTelegramDispatcherPolicy(params: { @@ -325,6 +333,7 @@ function createTelegramDispatcher(policy: PinnedDispatcherPolicy): { const proxyTlsOptions = withPinnedLookup(policy.proxyTls, policy.pinnedHostname); const proxyOptions = { ...poolOptions, + ...resolveEnvHttpProxyAgentOptions(), ...(connectOptions ? { connect: connectOptions } : {}), ...(proxyTlsOptions ? { proxyTls: proxyTlsOptions } : {}), } satisfies ConstructorParameters[0]; @@ -580,8 +589,12 @@ export function resolveTelegramTransport( const explicitProxyUrl = effectiveProxyFetch ? getProxyUrlFromFetch(effectiveProxyFetch) : undefined; + const hasEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi(); + const managedProxyUrl = + !effectiveProxyFetch && !hasEnvProxy ? resolveOpenClawProxyUrlForTelegram() : undefined; + const resolvedExplicitProxyUrl = explicitProxyUrl ?? managedProxyUrl; const undiciSourceFetch = resolveWrappedFetch(undiciFetch as unknown as typeof fetch); - const sourceFetch = explicitProxyUrl + const sourceFetch = resolvedExplicitProxyUrl ? undiciSourceFetch : effectiveProxyFetch ? resolveWrappedFetch(effectiveProxyFetch) @@ -592,13 +605,13 @@ export function resolveTelegramTransport( return { fetch: sourceFetch, sourceFetch, close: async () => {} }; } - const useEnvProxy = !explicitProxyUrl && hasEnvHttpProxyForTelegramApi(); + const useEnvProxy = !resolvedExplicitProxyUrl && hasEnvProxy; const defaultDispatcherResolution = resolveTelegramDispatcherPolicy({ autoSelectFamily: autoSelectDecision.value, dnsResultOrder, useEnvProxy, forceIpv4: false, - proxyUrl: explicitProxyUrl, + proxyUrl: resolvedExplicitProxyUrl, }); const defaultDispatcher = createTelegramDispatcher(defaultDispatcherResolution.policy); const shouldBypassEnvProxy = shouldBypassEnvProxyForTelegramApi(); @@ -611,7 +624,7 @@ export function resolveTelegramTransport( dnsResultOrder: "ipv4first", useEnvProxy: defaultDispatcher.mode === "env-proxy", forceIpv4: true, - proxyUrl: explicitProxyUrl, + proxyUrl: resolvedExplicitProxyUrl, }).policy : undefined; const ownedDispatchers = new Set(); diff --git a/src/plugin-sdk/fetch-runtime.ts b/src/plugin-sdk/fetch-runtime.ts index ea991387eed..b954e8edf97 100644 --- a/src/plugin-sdk/fetch-runtime.ts +++ b/src/plugin-sdk/fetch-runtime.ts @@ -4,6 +4,8 @@ export { resolveFetch, wrapFetchWithAbortSignal } from "../infra/fetch.js"; export { withTrustedEnvProxyGuardedFetchMode } from "../infra/net/fetch-guard.ts"; export { hasEnvHttpProxyConfigured, + hasEnvHttpProxyAgentConfigured, + resolveEnvHttpProxyAgentOptions, resolveEnvHttpProxyUrl, shouldUseEnvHttpProxyForUrl, } from "../infra/net/proxy-env.js";