Discord: add gateway proxy docs and tests (#10400) (thanks @winter-loo)

This commit is contained in:
Shadow
2026-02-13 13:14:19 -06:00
committed by Shadow
parent e55431bf84
commit 5645f227f6
5 changed files with 144 additions and 4 deletions

View File

@@ -116,6 +116,7 @@ Docs: https://docs.openclaw.ai
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.
- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow.
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.

View File

@@ -330,6 +330,37 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
</Accordion>
<Accordion title="Gateway proxy">
Route Discord gateway WebSocket traffic through an HTTP(S) proxy with `channels.discord.proxy`.
```json5
{
channels: {
discord: {
proxy: "http://proxy.example:8080",
},
},
}
```
Per-account override:
```json5
{
channels: {
discord: {
accounts: {
primary: {
proxy: "http://proxy.example:8080",
},
},
},
},
}
```
</Accordion>
<Accordion title="PluralKit support">
Enable PluralKit resolution to map proxied messages to system member identity:

View File

@@ -293,6 +293,8 @@ export const FIELD_HELP: Record<string, string> = {
"Allow Mattermost to write config in response to channel events/commands (default: true).",
"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.<id>.proxy.",
"channels.whatsapp.configWrites":
"Allow WhatsApp to write config in response to channel events/commands (default: true).",
"channels.signal.configWrites":

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { HttpsProxyAgent, getLastAgent, proxyAgentSpy, resetLastAgent, webSocketSpy } = vi.hoisted(
() => {
const proxyAgentSpy = vi.fn();
const webSocketSpy = vi.fn();
class HttpsProxyAgent {
static lastCreated: HttpsProxyAgent | undefined;
proxyUrl: string;
constructor(proxyUrl: string) {
if (proxyUrl === "bad-proxy") {
throw new Error("bad proxy");
}
this.proxyUrl = proxyUrl;
HttpsProxyAgent.lastCreated = this;
proxyAgentSpy(proxyUrl);
}
}
return {
HttpsProxyAgent,
getLastAgent: () => HttpsProxyAgent.lastCreated,
proxyAgentSpy,
resetLastAgent: () => {
HttpsProxyAgent.lastCreated = undefined;
},
webSocketSpy,
};
},
);
vi.mock("https-proxy-agent", () => ({
HttpsProxyAgent,
}));
vi.mock("ws", () => ({
default: class MockWebSocket {
constructor(url: string, options?: { agent?: unknown }) {
webSocketSpy(url, options);
}
},
}));
describe("createDiscordGatewayPlugin", () => {
beforeEach(() => {
proxyAgentSpy.mockReset();
webSocketSpy.mockReset();
resetLastAgent();
});
it("uses proxy agent for gateway WebSocket when configured", async () => {
const { __testing } = await import("./provider.js");
const { GatewayPlugin } = await import("@buape/carbon/gateway");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const plugin = __testing.createDiscordGatewayPlugin({
discordConfig: { proxy: "http://proxy.test:8080" },
runtime,
});
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
.createWebSocket;
createWebSocket("wss://gateway.discord.gg");
expect(proxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
expect(webSocketSpy).toHaveBeenCalledWith(
"wss://gateway.discord.gg",
expect.objectContaining({ agent: getLastAgent() }),
);
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
expect(runtime.error).not.toHaveBeenCalled();
});
it("falls back to the default gateway plugin when proxy is invalid", async () => {
const { __testing } = await import("./provider.js");
const { GatewayPlugin } = await import("@buape/carbon/gateway");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const plugin = __testing.createDiscordGatewayPlugin({
discordConfig: { proxy: "bad-proxy" },
runtime,
});
expect(Object.getPrototypeOf(plugin)).toBe(GatewayPlugin.prototype);
expect(runtime.error).toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalled();
});
});

View File

@@ -85,10 +85,7 @@ function createDiscordGatewayPlugin(params: {
this.#proxyAgent = proxyAgent;
}
createWebSocket(url?: string) {
if (!url) {
throw new Error("Gateway URL is required");
}
createWebSocket(url: string) {
return new WebSocket(url, { agent: this.#proxyAgent });
}
}
@@ -753,3 +750,7 @@ async function clearDiscordNativeCommands(params: {
params.runtime.error?.(danger(`discord: failed to clear native commands: ${String(err)}`));
}
}
export const __testing = {
createDiscordGatewayPlugin,
};