From 7191f1a1eb7487f752dcecae8da27bbd53d74b2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 19:39:37 +0100 Subject: [PATCH] fix(discord): tune gateway intents and metadata timeout --- docs/.generated/config-baseline.sha256 | 6 +- docs/channels/discord.md | 16 +++- extensions/discord/src/config-ui-hints.ts | 10 ++- .../src/monitor/gateway-plugin.test.ts | 68 ++++++++++++++- .../discord/src/monitor/gateway-plugin.ts | 87 ++++++++++++++++--- .../src/monitor/provider.proxy.test.ts | 76 +++++++++++++++- ...ndled-channel-config-metadata.generated.ts | 26 +++++- src/config/types.discord.ts | 4 + src/config/zod-schema.providers-core.ts | 2 + 9 files changed, 276 insertions(+), 19 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index ed5f835a8d3..f10c8561ddf 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 6f7f9cc793e..9fff6ad6d69 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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 + + 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..gatewayInfoTimeoutMs` + - env fallback when config is unset: `OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS` + - default: `30000` (30 seconds), max: `120000` + + + `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` diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 51c4b9f6ac6..410478e1e36 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -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", diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index eaf8c1d5680..ad4bac437e4 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -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[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(); diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index fd3d2e7b07d..39089c3f507 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -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; type DiscordGatewayFetchInit = Record & { @@ -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>(); +const gatewayMetadataFallbackLogLastAt = new WeakMap(); type CarbonGatewayRegistrationState = { client?: Parameters[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 & { + 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 { - 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 | undefined; const timeoutPromise = new Promise((_, 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>; @@ -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; }; }): 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 diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index 08dc8479a28..e3f6cdc3943 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -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(); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index eda626c8fd9..3fc59e25f9f 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -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", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index f0423f3f72b..4ba03d9d192 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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"; /** diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 228cfe4f05f..7c145941139 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(),