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