From 97e56cb73cd3a22e6399250c02b30efe39fb74c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:03:21 +0000 Subject: [PATCH] fix(discord): land proxy/media/reaction/model-picker regressions Reimplements core Discord fixes from #25277 #25523 #25575 #25588 #25731 with expanded tests. - thread proxy-aware fetch into inbound attachment/sticker downloads - fetch /gateway/bot via proxy dispatcher before ws connect - wire statusReactions emojis/timing overrides into controller - compact model-picker custom_id keys with backward-compatible parsing Co-authored-by: openperf Co-authored-by: chilu18 Co-authored-by: Yipsh Co-authored-by: lbo728 Co-authored-by: s1korrrr --- CHANGELOG.md | 1 + src/discord/monitor/gateway-plugin.ts | 29 ++++++- .../monitor/message-handler.preflight.ts | 1 + .../message-handler.preflight.types.ts | 2 + .../monitor/message-handler.process.test.ts | 29 +++++++ .../monitor/message-handler.process.ts | 11 ++- src/discord/monitor/message-utils.test.ts | 64 +++++++++++++++ src/discord/monitor/message-utils.ts | 12 ++- src/discord/monitor/model-picker.test.ts | 41 +++++++++- src/discord/monitor/model-picker.ts | 16 ++-- src/discord/monitor/provider.proxy.test.ts | 81 +++++++++++++++++-- src/discord/monitor/provider.ts | 1 + 12 files changed, 265 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2459d479e..f64fc5f29d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko. - Models/Bedrock auth: normalize additional Bedrock provider aliases (`bedrock`, `aws-bedrock`, `aws_bedrock`, `amazon bedrock`) to canonical `amazon-bedrock`, ensuring auth-mode resolution consistently selects AWS SDK fallback. (#25756) Thanks @fwhite13. - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. +- Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. diff --git a/src/discord/monitor/gateway-plugin.ts b/src/discord/monitor/gateway-plugin.ts index 74e1aad8630..c86b6259c5e 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/src/discord/monitor/gateway-plugin.ts @@ -1,5 +1,7 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import type { APIGatewayBotInfo } from "discord-api-types/v10"; import { HttpsProxyAgent } from "https-proxy-agent"; +import { ProxyAgent, fetch as undiciFetch } from "undici"; import WebSocket from "ws"; import type { DiscordAccountConfig } from "../../config/types.js"; import { danger } from "../../globals.js"; @@ -42,7 +44,8 @@ export function createDiscordGatewayPlugin(params: { } try { - const agent = new HttpsProxyAgent(proxy); + const wsAgent = new HttpsProxyAgent(proxy); + const fetchAgent = new ProxyAgent(proxy); params.runtime.log?.("discord: gateway proxy enabled"); @@ -51,8 +54,28 @@ export function createDiscordGatewayPlugin(params: { super(options); } - createWebSocket(url: string) { - return new WebSocket(url, { agent }); + override async registerClient(client: Parameters[0]) { + if (!this.gatewayInfo) { + try { + const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", { + headers: { + Authorization: `Bot ${client.options.token}`, + }, + dispatcher: fetchAgent, + } as Record); + this.gatewayInfo = (await response.json()) as APIGatewayBotInfo; + } catch (error) { + throw new Error( + `Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + return super.registerClient(client); + } + + override createWebSocket(url: string) { + return new WebSocket(url, { agent: wsAgent }); } } diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index e321c8ef86f..88871b00683 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -733,5 +733,6 @@ export async function preflightDiscordMessage( canDetectMention, historyEntry, threadBindings: params.threadBindings, + discordRestFetch: params.discordRestFetch, }; } diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index 86a32dbf7e8..91eff1ce264 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -84,6 +84,7 @@ export type DiscordMessagePreflightContext = { historyEntry?: HistoryEntry; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; }; export type DiscordMessagePreflightParams = { @@ -106,6 +107,7 @@ export type DiscordMessagePreflightParams = { ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"]; groupPolicy: DiscordMessagePreflightContext["groupPolicy"]; threadBindings: ThreadBindingManager; + discordRestFetch?: typeof fetch; data: DiscordMessageEvent; client: Client; }; diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 750eab43b74..a7333794cbb 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -257,6 +257,35 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.stallHard); expect(emojis).toContain(DEFAULT_EMOJIS.done); }); + + it("applies status reaction emoji/timing overrides from config", async () => { + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onReasoningStream?.(); + return { queuedFinal: false, counts: { final: 0, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { + ackReaction: "๐Ÿ‘€", + statusReactions: { + emojis: { queued: "๐ŸŸฆ", thinking: "๐Ÿงช", done: "๐Ÿ" }, + timing: { debounceMs: 0 }, + }, + }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + const emojis = ( + sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]> + ).map((call) => call[2]); + expect(emojis).toContain("๐ŸŸฆ"); + expect(emojis).toContain("๐Ÿ"); + }); }); describe("processDiscordMessage session routing", () => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 4dd357d656f..59b0ceaf649 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -101,10 +101,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) threadBindings, route, commandAuthorized, + discordRestFetch, } = ctx; - const mediaList = await resolveMediaList(message, mediaMaxBytes); - const forwardedMediaList = await resolveForwardedMediaList(message, mediaMaxBytes); + const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch); + const forwardedMediaList = await resolveForwardedMediaList( + message, + mediaMaxBytes, + discordRestFetch, + ); mediaList.push(...forwardedMediaList); const text = messageText; if (!text) { @@ -147,6 +152,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) enabled: statusReactionsEnabled, adapter: discordAdapter, initialEmoji: ackReaction, + emojis: cfg.messages?.statusReactions?.emojis, + timing: cfg.messages?.statusReactions?.timing, onError: (err) => { logAckFailure({ log: logVerbose, diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index 4c671ce01e2..de8976ce5d2 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -93,6 +93,7 @@ describe("resolveForwardedMediaList", () => { url: attachment.url, filePathHint: attachment.filename, maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -105,6 +106,38 @@ describe("resolveForwardedMediaList", () => { ]); }); + it("forwards fetchImpl to forwarded attachment downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const attachment = { + id: "att-proxy", + url: "https://cdn.discordapp.com/attachments/1/proxy.png", + filename: "proxy.png", + content_type: "image/png", + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("image"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/proxy.png", + contentType: "image/png", + }); + + await resolveForwardedMediaList( + asMessage({ + rawData: { + message_snapshots: [{ message: { attachments: [attachment] } }], + }, + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); + it("downloads forwarded stickers", async () => { const sticker = { id: "sticker-1", @@ -134,6 +167,7 @@ describe("resolveForwardedMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-1.png", filePathHint: "wave.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -201,6 +235,7 @@ describe("resolveMediaList", () => { url: "https://media.discordapp.net/stickers/sticker-2.png", filePathHint: "hello.png", maxBytes: 512, + fetchImpl: undefined, }); expect(saveMediaBuffer).toHaveBeenCalledTimes(1); expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); @@ -212,6 +247,35 @@ describe("resolveMediaList", () => { }, ]); }); + + it("forwards fetchImpl to sticker downloads", async () => { + const proxyFetch = vi.fn() as unknown as typeof fetch; + const sticker = { + id: "sticker-proxy", + name: "proxy-sticker", + format_type: StickerFormatType.PNG, + }; + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("sticker"), + contentType: "image/png", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/sticker-proxy.png", + contentType: "image/png", + }); + + await resolveMediaList( + asMessage({ + stickers: [sticker], + }), + 512, + proxyFetch, + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ fetchImpl: proxyFetch }), + ); + }); }); describe("resolveDiscordMessageText", () => { diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index 05aeab5dc76..3c523d277ef 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -2,7 +2,7 @@ import type { ChannelType, Client, Message } from "@buape/carbon"; import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; import { logVerbose } from "../../globals.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; +import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; export type DiscordMediaInfo = { @@ -161,6 +161,7 @@ export function hasDiscordMessageStickers(message: Message): boolean { export async function resolveMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const out: DiscordMediaInfo[] = []; await appendResolvedMediaFromAttachments({ @@ -168,12 +169,14 @@ export async function resolveMediaList( maxBytes, out, errorPrefix: "discord: failed to download attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: resolveDiscordMessageStickers(message), maxBytes, out, errorPrefix: "discord: failed to download sticker", + fetchImpl, }); return out; } @@ -181,6 +184,7 @@ export async function resolveMediaList( export async function resolveForwardedMediaList( message: Message, maxBytes: number, + fetchImpl?: FetchLike, ): Promise { const snapshots = resolveDiscordMessageSnapshots(message); if (snapshots.length === 0) { @@ -193,12 +197,14 @@ export async function resolveForwardedMediaList( maxBytes, out, errorPrefix: "discord: failed to download forwarded attachment", + fetchImpl, }); await appendResolvedMediaFromStickers({ stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], maxBytes, out, errorPrefix: "discord: failed to download forwarded sticker", + fetchImpl, }); } return out; @@ -209,6 +215,7 @@ async function appendResolvedMediaFromAttachments(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const attachments = params.attachments; if (!attachments || attachments.length === 0) { @@ -220,6 +227,7 @@ async function appendResolvedMediaFromAttachments(params: { url: attachment.url, filePathHint: attachment.filename ?? attachment.url, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, @@ -296,6 +304,7 @@ async function appendResolvedMediaFromStickers(params: { maxBytes: number; out: DiscordMediaInfo[]; errorPrefix: string; + fetchImpl?: FetchLike; }) { const stickers = params.stickers; if (!stickers || stickers.length === 0) { @@ -310,6 +319,7 @@ async function appendResolvedMediaFromStickers(params: { url: candidate.url, filePathHint: candidate.fileName, maxBytes: params.maxBytes, + fetchImpl: params.fetchImpl, }); const saved = await saveMediaBuffer( fetched.buffer, diff --git a/src/discord/monitor/model-picker.test.ts b/src/discord/monitor/model-picker.test.ts index 0ef048408bb..29365fb784b 100644 --- a/src/discord/monitor/model-picker.test.ts +++ b/src/discord/monitor/model-picker.test.ts @@ -117,6 +117,28 @@ describe("Discord model picker custom_id", () => { }); }); + it("parses compact custom_id aliases", () => { + const parsed = parseDiscordModelPickerData({ + c: "models", + a: "submit", + v: "models", + u: "42", + p: "openai", + g: "3", + mi: "2", + }); + + expect(parsed).toEqual({ + command: "models", + action: "submit", + view: "models", + userId: "42", + provider: "openai", + page: 3, + modelIndex: 2, + }); + }); + it("parses optional submit model index", () => { const parsed = parseDiscordModelPickerData({ cmd: "models", @@ -179,6 +201,21 @@ describe("Discord model picker custom_id", () => { }), ).toThrow(/custom_id exceeds/i); }); + + it("keeps typical submit ids under Discord max length", () => { + const customId = buildDiscordModelPickerCustomId({ + command: "models", + action: "submit", + view: "models", + provider: "azure-openai-responses", + page: 1, + providerPage: 1, + modelIndex: 10, + userId: "12345678901234567890", + }); + + expect(customId.length).toBeLessThanOrEqual(DISCORD_CUSTOM_ID_MAX_CHARS); + }); }); describe("provider paging", () => { @@ -325,7 +362,7 @@ describe("Discord model picker rendering", () => { return parsed?.action === "provider"; }); expect(providerButtons).toHaveLength(Object.keys(entries).length); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); @@ -352,7 +389,7 @@ describe("Discord model picker rendering", () => { expect(rows.length).toBeGreaterThan(0); const allButtons = rows.flatMap((row) => row.components ?? []); - expect(allButtons.some((component) => (component.custom_id ?? "").includes(":act=nav:"))).toBe( + expect(allButtons.some((component) => (component.custom_id ?? "").includes(";a=nav;"))).toBe( false, ); }); diff --git a/src/discord/monitor/model-picker.ts b/src/discord/monitor/model-picker.ts index ad3654ae81b..5c686face27 100644 --- a/src/discord/monitor/model-picker.ts +++ b/src/discord/monitor/model-picker.ts @@ -577,11 +577,11 @@ export function buildDiscordModelPickerCustomId(params: { : undefined; const parts = [ - `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:cmd=${encodeCustomIdValue(params.command)}`, - `act=${encodeCustomIdValue(params.action)}`, - `view=${encodeCustomIdValue(params.view)}`, + `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`, + `a=${encodeCustomIdValue(params.action)}`, + `v=${encodeCustomIdValue(params.view)}`, `u=${encodeCustomIdValue(userId)}`, - `pg=${String(page)}`, + `g=${String(page)}`, ]; if (normalizedProvider) { parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`); @@ -635,12 +635,12 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi return null; } - const command = decodeCustomIdValue(coerceString(data.cmd)); - const action = decodeCustomIdValue(coerceString(data.act)); - const view = decodeCustomIdValue(coerceString(data.view)); + const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd)); + const action = decodeCustomIdValue(coerceString(data.a ?? data.act)); + const view = decodeCustomIdValue(coerceString(data.v ?? data.view)); const userId = decodeCustomIdValue(coerceString(data.u)); const providerRaw = decodeCustomIdValue(coerceString(data.p)); - const page = parseRawPage(data.pg); + const page = parseRawPage(data.g ?? data.pg); const providerPage = parseRawPositiveInt(data.pp); const modelIndex = parseRawPositiveInt(data.mi); const recentSlot = parseRawPositiveInt(data.rs); diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index c703c856898..4d43469e2e4 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -2,14 +2,22 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { GatewayIntents, + baseRegisterClientSpy, GatewayPlugin, HttpsProxyAgent, getLastAgent, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent, webSocketSpy, + wsProxyAgentSpy, } = vi.hoisted(() => { - const proxyAgentSpy = vi.fn(); + const wsProxyAgentSpy = vi.fn(); + const undiciProxyAgentSpy = vi.fn(); + const restProxyAgentSpy = vi.fn(); + const undiciFetchMock = vi.fn(); + const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); const GatewayIntents = { @@ -23,7 +31,17 @@ const { GuildMembers: 1 << 7, } as const; - class GatewayPlugin {} + class GatewayPlugin { + options: unknown; + gatewayInfo: unknown; + constructor(options?: unknown, gatewayInfo?: unknown) { + this.options = options; + this.gatewayInfo = gatewayInfo; + } + async registerClient(client: unknown) { + baseRegisterClientSpy(client); + } + } class HttpsProxyAgent { static lastCreated: HttpsProxyAgent | undefined; @@ -34,20 +52,24 @@ const { } this.proxyUrl = proxyUrl; HttpsProxyAgent.lastCreated = this; - proxyAgentSpy(proxyUrl); + wsProxyAgentSpy(proxyUrl); } } return { + baseRegisterClientSpy, GatewayIntents, GatewayPlugin, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, - proxyAgentSpy, + restProxyAgentSpy, + undiciFetchMock, + undiciProxyAgentSpy, resetLastAgent: () => { HttpsProxyAgent.lastCreated = undefined; }, webSocketSpy, + wsProxyAgentSpy, }; }); @@ -61,6 +83,18 @@ vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); +vi.mock("undici", () => ({ + ProxyAgent: class { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + restProxyAgentSpy(proxyUrl); + } + }, + fetch: undiciFetchMock, +})); + vi.mock("ws", () => ({ default: class MockWebSocket { constructor(url: string, options?: { agent?: unknown }) { @@ -87,7 +121,11 @@ describe("createDiscordGatewayPlugin", () => { } beforeEach(() => { - proxyAgentSpy.mockClear(); + baseRegisterClientSpy.mockClear(); + restProxyAgentSpy.mockClear(); + undiciFetchMock.mockClear(); + undiciProxyAgentSpy.mockClear(); + wsProxyAgentSpy.mockClear(); webSocketSpy.mockClear(); resetLastAgent(); }); @@ -106,7 +144,7 @@ describe("createDiscordGatewayPlugin", () => { .createWebSocket; createWebSocket("wss://gateway.discord.gg"); - expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); expect(webSocketSpy).toHaveBeenCalledWith( "wss://gateway.discord.gg", expect.objectContaining({ agent: getLastAgent() }), @@ -127,4 +165,33 @@ describe("createDiscordGatewayPlugin", () => { expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); + + it("uses proxy fetch for gateway metadata lookup before registering", async () => { + const runtime = createRuntime(); + undiciFetchMock.mockResolvedValue({ + json: async () => ({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: { proxy: "http://proxy.test:8080" }, + runtime, + }); + + await ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }); + + expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); + expect(undiciFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/gateway/bot", + expect.objectContaining({ + headers: { Authorization: "Bot token-123" }, + dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }), + }), + ); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b31697189de..15c8e2aa7b4 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -550,6 +550,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { allowFrom, guildEntries, threadBindings, + discordRestFetch, }); registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));