fix(discord): tune gateway intents and metadata timeout

This commit is contained in:
Peter Steinberger
2026-04-28 19:39:37 +01:00
parent 065284deab
commit 7191f1a1eb
9 changed files with 276 additions and 19 deletions

View File

@@ -137,9 +137,17 @@ export const discordChannelConfigUiHints = {
label: "Discord Guild Members Intent",
help: "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
},
"intents.voiceStates": {
label: "Discord Voice States Intent",
help: "Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set false for text-only gateway sessions even when voice config is present.",
},
gatewayInfoTimeoutMs: {
label: "Discord Gateway Metadata Timeout (ms)",
help: "Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default is 30000; OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS can override when config is unset.",
},
"voice.enabled": {
label: "Discord Voice Enabled",
help: "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.",
help: "Enable Discord voice channel conversations (default: true). Set false for text-only gateway sessions.",
},
"voice.model": {
label: "Discord Voice Model",

View File

@@ -14,6 +14,7 @@ const { baseConnectSpy, GatewayIntents, GatewayPlugin } = vi.hoisted(() => {
DirectMessageReactions: 1 << 5,
GuildPresences: 1 << 6,
GuildMembers: 1 << 7,
GuildVoiceStates: 1 << 8,
} as const;
class TestEmitter {
@@ -75,15 +76,64 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
describe("SafeGatewayPlugin.connect()", () => {
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
let resolveDiscordGatewayIntents: typeof import("./gateway-plugin.js").resolveDiscordGatewayIntents;
let resolveDiscordGatewayInfoTimeoutMs: typeof import("./gateway-plugin.js").resolveDiscordGatewayInfoTimeoutMs;
beforeAll(async () => {
({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js"));
({
createDiscordGatewayPlugin,
resolveDiscordGatewayIntents,
resolveDiscordGatewayInfoTimeoutMs,
} = await import("./gateway-plugin.js"));
});
beforeEach(() => {
baseConnectSpy.mockClear();
});
it("includes GuildVoiceStates when voice is enabled by default", () => {
expect(resolveDiscordGatewayIntents() & GatewayIntents.GuildVoiceStates).toBe(
GatewayIntents.GuildVoiceStates,
);
});
it("omits GuildVoiceStates when voice is disabled", () => {
const intents = resolveDiscordGatewayIntents({ voiceEnabled: false });
expect(intents & GatewayIntents.GuildVoiceStates).toBe(0);
});
it("lets intents.voiceStates override voice enablement", () => {
const enabled = resolveDiscordGatewayIntents({
intentsConfig: { voiceStates: true },
voiceEnabled: false,
});
const disabled = resolveDiscordGatewayIntents({
intentsConfig: { voiceStates: false },
voiceEnabled: true,
});
expect(enabled & GatewayIntents.GuildVoiceStates).toBe(GatewayIntents.GuildVoiceStates);
expect(disabled & GatewayIntents.GuildVoiceStates).toBe(0);
});
it("keeps the legacy intents-config argument shape working", () => {
const intents = resolveDiscordGatewayIntents({ presence: true, guildMembers: true });
expect(intents & GatewayIntents.GuildPresences).toBe(GatewayIntents.GuildPresences);
expect(intents & GatewayIntents.GuildMembers).toBe(GatewayIntents.GuildMembers);
});
it("resolves gateway metadata timeout from config, env, then default", () => {
expect(resolveDiscordGatewayInfoTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000);
expect(
resolveDiscordGatewayInfoTimeoutMs({
env: { OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS: "25000" },
}),
).toBe(25_000);
expect(resolveDiscordGatewayInfoTimeoutMs({ env: {} })).toBe(30_000);
});
function createPlugin(
testing?: NonNullable<Parameters<typeof createDiscordGatewayPlugin>[0]["__testing"]>,
) {
@@ -125,6 +175,22 @@ describe("SafeGatewayPlugin.connect()", () => {
);
});
it("keeps OpenClaw metadata timeout out of Carbon gateway options", () => {
const plugin = createDiscordGatewayPlugin({
discordConfig: { gatewayInfoTimeoutMs: 5_000 },
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
});
expect(
(plugin as unknown as { options?: { gatewayInfoTimeoutMs?: number } }).options
?.gatewayInfoTimeoutMs,
).toBeUndefined();
});
it("clears stale firstHeartbeatTimeout before delegating to super when isConnecting=true", () => {
const plugin = createPlugin();

View File

@@ -21,7 +21,10 @@ import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
const DISCORD_API_HOST = "discord.com";
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
const DISCORD_GATEWAY_INFO_TIMEOUT_MS = 10_000;
const DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 30_000;
const MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS = 120_000;
const DISCORD_GATEWAY_INFO_TIMEOUT_ENV = "OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS";
const DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS = 60_000;
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
type DiscordGatewayFetchInit = Record<string, unknown> & {
@@ -35,6 +38,7 @@ type DiscordGatewayFetch = (
type DiscordGatewayMetadataError = Error & { transient?: boolean };
type DiscordGatewayWebSocketCtor = new (url: string, options?: { agent?: unknown }) => ws.WebSocket;
const registrationPromises = new WeakMap<carbonGateway.GatewayPlugin, Promise<void>>();
const gatewayMetadataFallbackLogLastAt = new WeakMap<RuntimeEnv, number>();
type CarbonGatewayRegistrationState = {
client?: Parameters<carbonGateway.GatewayPlugin["registerClient"]>[0];
ws?: unknown;
@@ -72,17 +76,36 @@ function hasCarbonGatewaySocketStarted(plugin: carbonGateway.GatewayPlugin): boo
return state.ws != null || state.isConnecting === true;
}
export function resolveDiscordGatewayIntents(
intentsConfig?: import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig,
): number {
type ResolveDiscordGatewayIntentsParams =
| import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig
| {
intentsConfig?: import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig;
voiceEnabled?: boolean;
};
function isGatewayIntentsResolverOptions(
value: ResolveDiscordGatewayIntentsParams | undefined,
): value is Exclude<ResolveDiscordGatewayIntentsParams, undefined> & {
intentsConfig?: import("openclaw/plugin-sdk/config-types").DiscordIntentsConfig;
voiceEnabled?: boolean;
} {
return Boolean(value && ("intentsConfig" in value || "voiceEnabled" in value));
}
export function resolveDiscordGatewayIntents(params?: ResolveDiscordGatewayIntentsParams): number {
const intentsConfig = isGatewayIntentsResolverOptions(params) ? params.intentsConfig : params;
const voiceEnabled = isGatewayIntentsResolverOptions(params) ? params.voiceEnabled : undefined;
const voiceStatesEnabled = intentsConfig?.voiceStates ?? voiceEnabled ?? true;
let intents =
carbonGateway.GatewayIntents.Guilds |
carbonGateway.GatewayIntents.GuildMessages |
carbonGateway.GatewayIntents.MessageContent |
carbonGateway.GatewayIntents.DirectMessages |
carbonGateway.GatewayIntents.GuildMessageReactions |
carbonGateway.GatewayIntents.DirectMessageReactions |
carbonGateway.GatewayIntents.GuildVoiceStates;
carbonGateway.GatewayIntents.DirectMessageReactions;
if (voiceStatesEnabled) {
intents |= carbonGateway.GatewayIntents.GuildVoiceStates;
}
if (intentsConfig?.presence) {
intents |= carbonGateway.GatewayIntents.GuildPresences;
}
@@ -92,6 +115,26 @@ export function resolveDiscordGatewayIntents(
return intents;
}
function normalizeGatewayInfoTimeoutMs(value: unknown): number | undefined {
const numeric =
typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined;
}
return Math.min(Math.floor(numeric), MAX_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
}
export function resolveDiscordGatewayInfoTimeoutMs(params?: {
configuredTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}): number {
return (
normalizeGatewayInfoTimeoutMs(params?.configuredTimeoutMs) ??
normalizeGatewayInfoTimeoutMs(params?.env?.[DISCORD_GATEWAY_INFO_TIMEOUT_ENV]) ??
DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS
);
}
function summarizeGatewayResponseBody(body: string): string {
const normalized = body.trim().replace(/\s+/g, " ");
if (!normalized) {
@@ -215,7 +258,7 @@ async function fetchDiscordGatewayInfoWithTimeout(params: {
fetchInit?: DiscordGatewayFetchInit;
timeoutMs?: number;
}): Promise<APIGatewayBotInfo> {
const timeoutMs = Math.max(1, params.timeoutMs ?? DISCORD_GATEWAY_INFO_TIMEOUT_MS);
const timeoutMs = Math.max(1, params.timeoutMs ?? DEFAULT_DISCORD_GATEWAY_INFO_TIMEOUT_MS);
const abortController = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
@@ -259,9 +302,19 @@ function resolveGatewayInfoWithFallback(params: { runtime?: RuntimeEnv; error: u
throw params.error;
}
const message = formatErrorMessage(params.error);
params.runtime?.log?.(
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
);
const now = Date.now();
if (params.runtime) {
const previous = gatewayMetadataFallbackLogLastAt.get(params.runtime);
if (
previous === undefined ||
now - previous >= DISCORD_GATEWAY_METADATA_FALLBACK_LOG_INTERVAL_MS
) {
params.runtime.log?.(
`discord: gateway metadata lookup failed transiently; using default gateway url (${message})`,
);
gatewayMetadataFallbackLogLastAt.set(params.runtime, now);
}
}
return {
info: createDefaultGatewayInfo(),
usedFallback: true,
@@ -274,6 +327,7 @@ function createGatewayPlugin(params: {
intents: number;
autoInteractions: boolean;
};
gatewayInfoTimeoutMs: number;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
wsAgent?: InstanceType<typeof httpsProxyAgent.HttpsProxyAgent<string>>;
@@ -334,6 +388,7 @@ function createGatewayPlugin(params: {
token: client.options.token,
fetchImpl: params.fetchImpl,
fetchInit: params.fetchInit,
timeoutMs: params.gatewayInfoTimeoutMs,
})
.then((info) => ({
info,
@@ -479,9 +534,16 @@ export function createDiscordGatewayPlugin(params: {
) => Promise<void>;
};
}): carbonGateway.GatewayPlugin {
const intents = resolveDiscordGatewayIntents(params.discordConfig?.intents);
const intents = resolveDiscordGatewayIntents({
intentsConfig: params.discordConfig?.intents,
voiceEnabled: params.discordConfig?.voice?.enabled !== false,
});
const proxy = resolveEffectiveDebugProxyUrl(params.discordConfig?.proxy);
const debugProxySettings = resolveDebugProxySettings();
const gatewayInfoTimeoutMs = resolveDiscordGatewayInfoTimeoutMs({
configuredTimeoutMs: params.discordConfig?.gatewayInfoTimeoutMs,
env: process.env,
});
const options = {
reconnect: { maxAttempts: 50 },
intents,
@@ -493,6 +555,7 @@ export function createDiscordGatewayPlugin(params: {
if (!proxy) {
return createGatewayPlugin({
options,
gatewayInfoTimeoutMs,
fetchImpl: async (input, init) => {
return await fetchDiscordGatewayMetadataDirect(
input,
@@ -525,6 +588,7 @@ export function createDiscordGatewayPlugin(params: {
return createGatewayPlugin({
options,
gatewayInfoTimeoutMs,
fetchImpl: async (input, init) => {
return await fetchDiscordGatewayMetadataDirect(
input,
@@ -550,6 +614,7 @@ export function createDiscordGatewayPlugin(params: {
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
return createGatewayPlugin({
options,
gatewayInfoTimeoutMs,
fetchImpl: (input, init) => fetchDiscordGatewayMetadataDirect(input, init, false),
runtime: params.runtime,
testing: params.__testing

View File

@@ -40,6 +40,7 @@ const {
DirectMessageReactions: 1 << 5,
GuildPresences: 1 << 6,
GuildMembers: 1 << 7,
GuildVoiceStates: 1 << 8,
} as const;
class GatewayPlugin {
@@ -509,7 +510,7 @@ describe("createDiscordGatewayPlugin", () => {
});
const registerPromise = registerGatewayClient(plugin);
await vi.advanceTimersByTimeAsync(10_000);
await vi.advanceTimersByTimeAsync(30_000);
await registerPromise;
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
@@ -521,6 +522,79 @@ describe("createDiscordGatewayPlugin", () => {
);
});
it("uses configured gateway metadata timeout before falling back", async () => {
vi.useFakeTimers();
const runtime = createRuntime();
globalFetchMock.mockImplementation(() => new Promise(() => {}));
const plugin = createDiscordGatewayPlugin({
discordConfig: { gatewayInfoTimeoutMs: 5_000 },
runtime,
});
const registerPromise = registerGatewayClient(plugin);
await vi.advanceTimersByTimeAsync(4_999);
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await registerPromise;
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
"wss://gateway.discord.gg/",
);
});
it("uses env gateway metadata timeout when config is unset", async () => {
vi.useFakeTimers();
vi.stubEnv("OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS", "6000");
const runtime = createRuntime();
globalFetchMock.mockImplementation(() => new Promise(() => {}));
const plugin = createDiscordGatewayPlugin({
discordConfig: {},
runtime,
});
const registerPromise = registerGatewayClient(plugin);
await vi.advanceTimersByTimeAsync(5_999);
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await registerPromise;
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
"wss://gateway.discord.gg/",
);
});
it("rate-limits repeated gateway metadata fallback logs", async () => {
vi.useFakeTimers();
const runtime = createRuntime();
globalFetchMock.mockResolvedValue({
ok: false,
status: 503,
text: async () => "upstream connect error",
} as Response);
const firstPlugin = createDiscordGatewayPlugin({
discordConfig: {},
runtime,
});
const secondPlugin = createDiscordGatewayPlugin({
discordConfig: {},
runtime,
});
await registerGatewayClient(firstPlugin);
await registerGatewayClient(secondPlugin);
expect(runtime.log).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(60_000);
await registerGatewayClient(
createDiscordGatewayPlugin({
discordConfig: {},
runtime,
}),
);
expect(runtime.log).toHaveBeenCalledTimes(2);
});
it("sets client reference before the async gateway-info fetch resolves (regression for #52372)", async () => {
vi.useFakeTimers();
const runtime = createRuntime();