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

@@ -1,4 +1,4 @@
39c5c0620611f355f20d5e9d2ddd74e198c344c63d5551a987e4b7538833ceac config-baseline.json
1265c4249f2740b6786b295d5a88391ba7eb0c30bdf460c60dfb4dfcb4153685 config-baseline.json
805bd3f63ff7327da45c01b78dbc990ed53bd13b89e0cbf50f319aa99334ba92 config-baseline.core.json
323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json
0e38bad86bdc96c38573f6d51ac9e6fc5306cc20fb4a454399c57c105a61ba87 config-baseline.channel.json
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json

View File

@@ -1021,7 +1021,8 @@ Notes:
- `voice.model` overrides the LLM used for Discord voice channel responses only. Leave it unset to inherit the routed agent model.
- STT uses `tools.media.audio`; `voice.model` does not affect transcription.
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable voice runtime and the `GuildVoiceStates` gateway intent.
- `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow `voice.enabled`.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window.
@@ -1131,6 +1132,18 @@ openclaw logs --follow
</Accordion>
<Accordion title="Gateway metadata lookup timeout warnings">
OpenClaw fetches Discord `/gateway/bot` metadata before connecting. Transient failures fall back to Discord's default gateway URL and are rate-limited in logs.
Metadata timeout knobs:
- single-account: `channels.discord.gatewayInfoTimeoutMs`
- multi-account: `channels.discord.accounts.<accountId>.gatewayInfoTimeoutMs`
- env fallback when config is unset: `OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS`
- default: `30000` (30 seconds), max: `120000`
</Accordion>
<Accordion title="Permissions audit mismatches">
`channels status --probe` permission checks only work for numeric channel IDs.
@@ -1178,6 +1191,7 @@ Primary reference: [Configuration reference - Discord](/gateway/config-channels#
- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*`
- event queue: `eventQueue.listenerTimeout` (listener budget), `eventQueue.maxQueueSize`, `eventQueue.maxConcurrency`
- inbound worker: `inboundWorker.runTimeoutMs`
- gateway metadata: `gatewayInfoTimeoutMs`
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
- streaming: `streaming` (legacy alias: `streamMode`), `streaming.preview.toolProgress`, `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`

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();

View File

@@ -792,6 +792,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
proxy: {
type: "string",
},
gatewayInfoTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
allowBots: {
anyOf: [
{
@@ -1445,6 +1450,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
guildMembers: {
type: "boolean",
},
voiceStates: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -2147,6 +2155,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
proxy: {
type: "string",
},
gatewayInfoTimeoutMs: {
type: "integer",
exclusiveMinimum: 0,
maximum: 120000,
},
allowBots: {
anyOf: [
{
@@ -2800,6 +2813,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
guildMembers: {
type: "boolean",
},
voiceStates: {
type: "boolean",
},
},
additionalProperties: false,
},
@@ -3521,9 +3537,17 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
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

@@ -116,6 +116,8 @@ export type DiscordIntentsConfig = {
presence?: boolean;
/** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */
guildMembers?: boolean;
/** Enable Guild Voice States intent. Defaults to voice.enabled, unless explicitly set. */
voiceStates?: boolean;
};
export type DiscordVoiceAutoJoinConfig = {
@@ -241,6 +243,8 @@ export type DiscordAccountConfig = {
token?: SecretInput;
/** HTTP(S) proxy URL for Discord gateway WebSocket connections. */
proxy?: string;
/** Timeout for Discord /gateway/bot metadata lookup before falling back to the default gateway URL. Default: 30000. */
gatewayInfoTimeoutMs?: number;
/** Allow bot-authored messages to trigger replies (default: false). Set "mentions" to gate on mentions. */
allowBots?: boolean | "mentions";
/**

View File

@@ -528,6 +528,7 @@ export const DiscordAccountSchema = z
configWrites: z.boolean().optional(),
token: SecretInputSchema.optional().register(sensitive),
proxy: z.string().optional(),
gatewayInfoTimeoutMs: z.number().int().positive().max(120_000).optional(),
allowBots: z.union([z.boolean(), z.literal("mentions")]).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
@@ -613,6 +614,7 @@ export const DiscordAccountSchema = z
.object({
presence: z.boolean().optional(),
guildMembers: z.boolean().optional(),
voiceStates: z.boolean().optional(),
})
.strict()
.optional(),