From e997545d4bc292262e4555b5ee321f13d321fcd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=B9=E5=87=AF?= Date: Mon, 16 Feb 2026 18:23:49 +0800 Subject: [PATCH] fix(discord): apply proxy to app-id and allowlist REST lookups --- src/config/schema.help.ts | 2 +- .../monitor/provider.rest-proxy.test.ts | 63 +++++++++++++++++++ src/discord/monitor/provider.ts | 29 ++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/discord/monitor/provider.rest-proxy.test.ts diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 46e43d3761..b8c7df6cf8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -318,7 +318,7 @@ export const FIELD_HELP: Record = { "channels.discord.configWrites": "Allow Discord to write config in response to channel events/commands (default: true).", "channels.discord.proxy": - "Proxy URL for Discord gateway WebSocket connections. Set per account via channels.discord.accounts..proxy.", + "Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy.", "channels.whatsapp.configWrites": "Allow WhatsApp to write config in response to channel events/commands (default: true).", "channels.signal.configWrites": diff --git a/src/discord/monitor/provider.rest-proxy.test.ts b/src/discord/monitor/provider.rest-proxy.test.ts new file mode 100644 index 0000000000..ec5e9ead93 --- /dev/null +++ b/src/discord/monitor/provider.rest-proxy.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; + +const { undiciFetchMock, proxyAgentSpy } = vi.hoisted(() => ({ + undiciFetchMock: vi.fn(), + proxyAgentSpy: vi.fn(), +})); + +vi.mock("undici", () => { + class ProxyAgent { + proxyUrl: string; + constructor(proxyUrl: string) { + if (proxyUrl === "bad-proxy") { + throw new Error("bad proxy"); + } + this.proxyUrl = proxyUrl; + proxyAgentSpy(proxyUrl); + } + } + return { + ProxyAgent, + fetch: undiciFetchMock, + }; +}); + +describe("resolveDiscordRestFetch", () => { + it("uses undici proxy fetch when a proxy URL is configured", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + } as const; + undiciFetchMock.mockReset().mockResolvedValue(new Response("ok", { status: 200 })); + proxyAgentSpy.mockReset(); + + const { __testing } = await import("./provider.js"); + const fetcher = __testing.resolveDiscordRestFetch("http://proxy.test:8080", runtime); + + await fetcher("https://discord.com/api/v10/oauth2/applications/@me"); + + expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/oauth2/applications/@me", + expect.objectContaining({ + dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + }), + ); + expect(runtime.log).toHaveBeenCalledWith("discord: rest proxy enabled"); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("falls back to global fetch when proxy URL is invalid", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + } as const; + const { __testing } = await import("./provider.js"); + + const fetcher = __testing.resolveDiscordRestFetch("bad-proxy", runtime); + + expect(fetcher).toBe(fetch); + expect(runtime.error).toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalled(); + }); +}); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index d3893f9917..e48396da1a 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -7,6 +7,7 @@ import { } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { inspect } from "node:util"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import type { HistoryEntry } from "../../auto-reply/reply/history.js"; import type { OpenClawConfig, ReplyToMode } from "../../config/config.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; @@ -31,6 +32,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { createDiscordRetryRunner } from "../../infra/retry-policy.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; +import { wrapFetchWithAbortSignal } from "../../infra/fetch.js"; import { resolveDiscordAccount } from "../accounts.js"; import { attachDiscordGatewayLogging } from "../gateway-logging.js"; import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js"; @@ -116,6 +118,26 @@ function dedupeSkillCommandsForDiscord( return deduped; } +function resolveDiscordRestFetch(proxyUrl: string | undefined, runtime: RuntimeEnv): typeof fetch { + const proxy = proxyUrl?.trim(); + if (!proxy) { + return fetch; + } + try { + const agent = new ProxyAgent(proxy); + const fetcher = ((input: RequestInfo | URL, init?: RequestInit) => + undiciFetch(input as string | URL, { + ...(init as Record), + dispatcher: agent, + }) as unknown as Promise) as typeof fetch; + runtime.log?.("discord: rest proxy enabled"); + return wrapFetchWithAbortSignal(fetcher); + } catch (err) { + runtime.error?.(danger(`discord: invalid rest proxy: ${String(err)}`)); + return fetch; + } +} + async function deployDiscordCommands(params: { client: Client; runtime: RuntimeEnv; @@ -182,6 +204,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); const discordCfg = account.config; + const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime); const dmConfig = discordCfg.dm; let guildEntries = discordCfg.guilds; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; @@ -257,6 +280,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const resolved = await resolveDiscordChannelAllowlist({ token, entries: entries.map((entry) => entry.input), + fetcher: discordRestFetch, }); const nextGuilds = { ...guildEntries }; const mapping: string[] = []; @@ -313,6 +337,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const resolvedUsers = await resolveDiscordUserAllowlist({ token, entries: allowEntries.map((entry) => String(entry)), + fetcher: discordRestFetch, }); const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers); allowFrom = mergeAllowlist({ existing: allowFrom, additions }); @@ -342,6 +367,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const resolvedUsers = await resolveDiscordUserAllowlist({ token, entries: Array.from(userEntries), + fetcher: discordRestFetch, }); const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers); @@ -383,7 +409,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { ); } - const applicationId = await fetchDiscordApplicationId(token, 4000); + const applicationId = await fetchDiscordApplicationId(token, 4000, discordRestFetch); if (!applicationId) { throw new Error("Failed to resolve Discord application id"); } @@ -689,4 +715,5 @@ async function clearDiscordNativeCommands(params: { export const __testing = { createDiscordGatewayPlugin, dedupeSkillCommandsForDiscord, + resolveDiscordRestFetch, };