Skip to content

Commit 45b74fb

Browse files
sircrumpetobviyus
andauthored
fix(telegram): move network fallback to resolver-scoped dispatchers (#40740)
Merged via squash. Prepared head SHA: a4456d4 Co-authored-by: sircrumpet <[email protected]> Co-authored-by: obviyus <[email protected]> Reviewed-by: @obviyus
1 parent d1a5955 commit 45b74fb

23 files changed

+1602
-351
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
5151
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
5252
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
53+
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
5354

5455
## 2026.3.8
5556

extensions/telegram/src/channel.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,38 @@ function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: strin
5757
const probeTelegram = vi.fn(async () =>
5858
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
5959
);
60+
const collectUnmentionedGroupIds = vi.fn(() => ({
61+
groupIds: [] as string[],
62+
unresolvedGroups: 0,
63+
hasWildcardUnmentionedGroups: false,
64+
}));
65+
const auditGroupMembership = vi.fn(async () => ({
66+
ok: true,
67+
checkedGroups: 0,
68+
unresolvedGroups: 0,
69+
hasWildcardUnmentionedGroups: false,
70+
groups: [],
71+
elapsedMs: 0,
72+
}));
6073
setTelegramRuntime({
6174
channel: {
6275
telegram: {
6376
monitorTelegramProvider,
6477
probeTelegram,
78+
collectUnmentionedGroupIds,
79+
auditGroupMembership,
6580
},
6681
},
6782
logging: {
6883
shouldLogVerbose: () => false,
6984
},
7085
} as unknown as PluginRuntime);
71-
return { monitorTelegramProvider, probeTelegram };
86+
return {
87+
monitorTelegramProvider,
88+
probeTelegram,
89+
collectUnmentionedGroupIds,
90+
auditGroupMembership,
91+
};
7292
}
7393

7494
describe("telegramPlugin duplicate token guard", () => {
@@ -149,6 +169,85 @@ describe("telegramPlugin duplicate token guard", () => {
149169
);
150170
});
151171

172+
it("passes account proxy and network settings into Telegram probes", async () => {
173+
const { probeTelegram } = installGatewayRuntime({
174+
probeOk: true,
175+
botUsername: "opsbot",
176+
});
177+
178+
const cfg = createCfg();
179+
cfg.channels!.telegram!.accounts!.ops = {
180+
...cfg.channels!.telegram!.accounts!.ops,
181+
proxy: "http://127.0.0.1:8888",
182+
network: {
183+
autoSelectFamily: false,
184+
dnsResultOrder: "ipv4first",
185+
},
186+
};
187+
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
188+
189+
await telegramPlugin.status!.probeAccount!({
190+
account,
191+
timeoutMs: 5000,
192+
cfg,
193+
});
194+
195+
expect(probeTelegram).toHaveBeenCalledWith("token-ops", 5000, {
196+
accountId: "ops",
197+
proxyUrl: "http://127.0.0.1:8888",
198+
network: {
199+
autoSelectFamily: false,
200+
dnsResultOrder: "ipv4first",
201+
},
202+
});
203+
});
204+
205+
it("passes account proxy and network settings into Telegram membership audits", async () => {
206+
const { collectUnmentionedGroupIds, auditGroupMembership } = installGatewayRuntime({
207+
probeOk: true,
208+
botUsername: "opsbot",
209+
});
210+
211+
collectUnmentionedGroupIds.mockReturnValue({
212+
groupIds: ["-100123"],
213+
unresolvedGroups: 0,
214+
hasWildcardUnmentionedGroups: false,
215+
});
216+
217+
const cfg = createCfg();
218+
cfg.channels!.telegram!.accounts!.ops = {
219+
...cfg.channels!.telegram!.accounts!.ops,
220+
proxy: "http://127.0.0.1:8888",
221+
network: {
222+
autoSelectFamily: false,
223+
dnsResultOrder: "ipv4first",
224+
},
225+
groups: {
226+
"-100123": { requireMention: false },
227+
},
228+
};
229+
const account = telegramPlugin.config.resolveAccount(cfg, "ops");
230+
231+
await telegramPlugin.status!.auditAccount!({
232+
account,
233+
timeoutMs: 5000,
234+
probe: { ok: true, bot: { id: 123 }, elapsedMs: 1 },
235+
cfg,
236+
});
237+
238+
expect(auditGroupMembership).toHaveBeenCalledWith({
239+
token: "token-ops",
240+
botId: 123,
241+
groupIds: ["-100123"],
242+
proxyUrl: "http://127.0.0.1:8888",
243+
network: {
244+
autoSelectFamily: false,
245+
dnsResultOrder: "ipv4first",
246+
},
247+
timeoutMs: 5000,
248+
});
249+
});
250+
152251
it("forwards mediaLocalRoots to sendMessageTelegram for outbound media sends", async () => {
153252
const sendMessageTelegram = vi.fn(async () => ({ messageId: "tg-1" }));
154253
setTelegramRuntime({

extensions/telegram/src/channel.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -438,11 +438,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
438438
collectStatusIssues: collectTelegramStatusIssues,
439439
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
440440
probeAccount: async ({ account, timeoutMs }) =>
441-
getTelegramRuntime().channel.telegram.probeTelegram(
442-
account.token,
443-
timeoutMs,
444-
account.config.proxy,
445-
),
441+
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, {
442+
accountId: account.accountId,
443+
proxyUrl: account.config.proxy,
444+
network: account.config.network,
445+
}),
446446
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
447447
const groups =
448448
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
@@ -468,6 +468,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
468468
botId,
469469
groupIds,
470470
proxyUrl: account.config.proxy,
471+
network: account.config.network,
471472
timeoutMs,
472473
});
473474
return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups };
@@ -531,11 +532,11 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
531532
const token = (account.token ?? "").trim();
532533
let telegramBotLabel = "";
533534
try {
534-
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(
535-
token,
536-
2500,
537-
account.config.proxy,
538-
);
535+
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
536+
accountId: account.accountId,
537+
proxyUrl: account.config.proxy,
538+
network: account.config.network,
539+
});
539540
const username = probe.ok ? probe.bot?.username?.trim() : null;
540541
if (username) {
541542
telegramBotLabel = ` (@${username})`;

src/infra/net/proxy-fetch.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ describe("makeProxyFetch", () => {
4848
undiciFetch.mockResolvedValue({ ok: true });
4949

5050
const proxyFetch = makeProxyFetch(proxyUrl);
51+
expect(proxyAgentSpy).not.toHaveBeenCalled();
5152
await proxyFetch("https://api.example.com/v1/audio");
5253

5354
expect(proxyAgentSpy).toHaveBeenCalledWith(proxyUrl);

src/infra/net/proxy-fetch.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,46 @@
11
import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici";
22
import { logWarn } from "../../logger.js";
33

4+
export const PROXY_FETCH_PROXY_URL = Symbol.for("openclaw.proxyFetch.proxyUrl");
5+
type ProxyFetchWithMetadata = typeof fetch & {
6+
[PROXY_FETCH_PROXY_URL]?: string;
7+
};
8+
49
/**
510
* Create a fetch function that routes requests through the given HTTP proxy.
611
* Uses undici's ProxyAgent under the hood.
712
*/
813
export function makeProxyFetch(proxyUrl: string): typeof fetch {
9-
const agent = new ProxyAgent(proxyUrl);
14+
let agent: ProxyAgent | null = null;
15+
const resolveAgent = (): ProxyAgent => {
16+
if (!agent) {
17+
agent = new ProxyAgent(proxyUrl);
18+
}
19+
return agent;
20+
};
1021
// undici's fetch is runtime-compatible with global fetch but the types diverge
1122
// on stream/body internals. Single cast at the boundary keeps the rest type-safe.
12-
return ((input: RequestInfo | URL, init?: RequestInit) =>
23+
const proxyFetch = ((input: RequestInfo | URL, init?: RequestInit) =>
1324
undiciFetch(input as string | URL, {
1425
...(init as Record<string, unknown>),
15-
dispatcher: agent,
16-
}) as unknown as Promise<Response>) as typeof fetch;
26+
dispatcher: resolveAgent(),
27+
}) as unknown as Promise<Response>) as ProxyFetchWithMetadata;
28+
Object.defineProperty(proxyFetch, PROXY_FETCH_PROXY_URL, {
29+
value: proxyUrl,
30+
enumerable: false,
31+
configurable: false,
32+
writable: false,
33+
});
34+
return proxyFetch;
35+
}
36+
37+
export function getProxyUrlFromFetch(fetchImpl?: typeof fetch): string | undefined {
38+
const proxyUrl = (fetchImpl as ProxyFetchWithMetadata | undefined)?.[PROXY_FETCH_PROXY_URL];
39+
if (typeof proxyUrl !== "string") {
40+
return undefined;
41+
}
42+
const trimmed = proxyUrl.trim();
43+
return trimmed ? trimmed : undefined;
1744
}
1845

1946
/**

src/telegram/audit-membership-runtime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
TelegramGroupMembershipAudit,
66
TelegramGroupMembershipAuditEntry,
77
} from "./audit.js";
8+
import { resolveTelegramFetch } from "./fetch.js";
89
import { makeProxyFetch } from "./proxy.js";
910

1011
const TELEGRAM_API_BASE = "https://api.telegram.org";
@@ -16,7 +17,8 @@ type TelegramGroupMembershipAuditData = Omit<TelegramGroupMembershipAudit, "elap
1617
export async function auditTelegramGroupMembershipImpl(
1718
params: AuditTelegramGroupMembershipParams,
1819
): Promise<TelegramGroupMembershipAuditData> {
19-
const fetcher = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : fetch;
20+
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
21+
const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
2022
const base = `${TELEGRAM_API_BASE}/bot${params.token}`;
2123
const groups: TelegramGroupMembershipAuditEntry[] = [];
2224

src/telegram/audit.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22

33
let collectTelegramUnmentionedGroupIds: typeof import("./audit.js").collectTelegramUnmentionedGroupIds;
44
let auditTelegramGroupMembership: typeof import("./audit.js").auditTelegramGroupMembership;
5+
const undiciFetch = vi.hoisted(() => vi.fn());
6+
7+
vi.mock("undici", async (importOriginal) => {
8+
const actual = await importOriginal<typeof import("undici")>();
9+
return {
10+
...actual,
11+
fetch: undiciFetch,
12+
};
13+
});
514

615
function mockGetChatMemberStatus(status: string) {
7-
vi.stubGlobal(
8-
"fetch",
9-
vi.fn().mockResolvedValueOnce(
10-
new Response(JSON.stringify({ ok: true, result: { status } }), {
11-
status: 200,
12-
headers: { "Content-Type": "application/json" },
13-
}),
14-
),
16+
undiciFetch.mockResolvedValueOnce(
17+
new Response(JSON.stringify({ ok: true, result: { status } }), {
18+
status: 200,
19+
headers: { "Content-Type": "application/json" },
20+
}),
1521
);
1622
}
1723

@@ -31,7 +37,7 @@ describe("telegram audit", () => {
3137
});
3238

3339
beforeEach(() => {
34-
vi.unstubAllGlobals();
40+
undiciFetch.mockReset();
3541
});
3642

3743
it("collects unmentioned numeric group ids and flags wildcard", async () => {

src/telegram/audit.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TelegramGroupConfig } from "../config/types.js";
2+
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
23

34
export type TelegramGroupMembershipAuditEntry = {
45
chatId: string;
@@ -64,6 +65,7 @@ export type AuditTelegramGroupMembershipParams = {
6465
botId: number;
6566
groupIds: string[];
6667
proxyUrl?: string;
68+
network?: TelegramNetworkConfig;
6769
timeoutMs: number;
6870
};
6971

src/telegram/bot-handlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export const registerTelegramHandlers = ({
123123
accountId,
124124
bot,
125125
opts,
126+
telegramFetchImpl,
126127
runtime,
127128
mediaMaxBytes,
128129
telegramCfg,
@@ -371,7 +372,7 @@ export const registerTelegramHandlers = ({
371372
for (const { ctx } of entry.messages) {
372373
let media;
373374
try {
374-
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
375+
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl);
375376
} catch (mediaErr) {
376377
if (!isRecoverableMediaGroupError(mediaErr)) {
377378
throw mediaErr;
@@ -475,7 +476,7 @@ export const registerTelegramHandlers = ({
475476
},
476477
mediaMaxBytes,
477478
opts.token,
478-
opts.proxyFetch,
479+
telegramFetchImpl,
479480
);
480481
if (!media) {
481482
return [];
@@ -986,7 +987,7 @@ export const registerTelegramHandlers = ({
986987

987988
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
988989
try {
989-
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, opts.proxyFetch);
990+
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramFetchImpl);
990991
} catch (mediaErr) {
991992
if (isMediaSizeLimitError(mediaErr)) {
992993
if (sendOversizeWarning) {

src/telegram/bot-native-commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export type RegisterTelegramHandlerParams = {
9494
bot: Bot;
9595
mediaMaxBytes: number;
9696
opts: TelegramBotOptions;
97+
telegramFetchImpl?: typeof fetch;
9798
runtime: RuntimeEnv;
9899
telegramCfg: TelegramAccountConfig;
99100
allowFrom?: Array<string | number>;

0 commit comments

Comments
 (0)