Skip to content

Commit 1d47974

Browse files
committed
fix: default Discord voice to explicit opt-in
1 parent 2ea00e1 commit 1d47974

11 files changed

Lines changed: 96 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222

2323
- Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.
2424
- Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)
25+
- Discord/voice: leave Discord voice off for text-only configs unless `channels.discord.voice` is explicitly configured, avoiding default `GuildVoiceStates` traffic and idle gateway CPU pressure for bots that do not use `/vc`. Fixes #73753; refs #74044. Thanks @sanchezm86 and @SecureCloudProjO.
2526
- Discord/voice: rerun configured voice auto-join after Discord gateway RESUMED events and ignore already-destroyed stale voice connections during reconnect cleanup, so health-monitor account restarts can rejoin configured channels. Fixes #40665. Thanks @liz709.
2627
- Discord/voice: lengthen the default voice join Ready wait, add configurable `voice.connectTimeoutMs`/`voice.reconnectGraceMs`, and warn before destroying unrecovered disconnected sessions so slow Discord voice handshakes and reconnects no longer fail silently. Fixes #63098; refs #39825 and #65039. Thanks @darealgege, @kzicherman, and @ayochim.
2728
- Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423) Thanks @clawsweeper.

docs/channels/discord.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,8 +1067,8 @@ Notes:
10671067
- STT uses `tools.media.audio`; `voice.model` does not affect transcription.
10681068
- Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel.
10691069
- 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`).
1070-
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable voice runtime and the `GuildVoiceStates` gateway intent.
1071-
- `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow `voice.enabled`.
1070+
- Discord voice is opt-in for text-only configs; set `channels.discord.voice.enabled=true` (or keep an existing `channels.discord.voice` block) to enable `/vc` commands, the voice runtime, and the `GuildVoiceStates` gateway intent.
1071+
- `channels.discord.intents.voiceStates` can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement.
10721072
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
10731073
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
10741074
- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`.

docs/gateway/config-channels.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
338338
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
339339
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#channel-specific-settings).
340340
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
341-
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides.
341+
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in.
342342
- `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses.
343343
- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default).
344344
- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default).

extensions/discord/src/config-ui-hints.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,15 @@ export const discordChannelConfigUiHints = {
135135
},
136136
"intents.voiceStates": {
137137
label: "Discord Voice States Intent",
138-
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.",
138+
help: "Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations.",
139139
},
140140
gatewayInfoTimeoutMs: {
141141
label: "Discord Gateway Metadata Timeout (ms)",
142142
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.",
143143
},
144144
"voice.enabled": {
145145
label: "Discord Voice Enabled",
146-
help: "Enable Discord voice channel conversations (default: true). Set false for text-only gateway sessions.",
146+
help: "Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent.",
147147
},
148148
"voice.model": {
149149
label: "Discord Voice Model",

extensions/discord/src/monitor/gateway-plugin.test.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,14 @@ describe("createDiscordGatewayPlugin", () => {
102102
});
103103
}
104104

105-
it("includes GuildVoiceStates when voice is enabled by default", () => {
106-
expect(resolveDiscordGatewayIntents() & GatewayIntents.GuildVoiceStates).toBe(
107-
GatewayIntents.GuildVoiceStates,
108-
);
105+
it("omits GuildVoiceStates by default for text-only Discord configs", () => {
106+
expect(resolveDiscordGatewayIntents() & GatewayIntents.GuildVoiceStates).toBe(0);
107+
});
108+
109+
it("includes GuildVoiceStates when voice is enabled", () => {
110+
const intents = resolveDiscordGatewayIntents({ voiceEnabled: true });
111+
112+
expect(intents & GatewayIntents.GuildVoiceStates).toBe(GatewayIntents.GuildVoiceStates);
109113
});
110114

111115
it("omits GuildVoiceStates when voice is disabled", () => {
@@ -197,6 +201,22 @@ describe("createDiscordGatewayPlugin", () => {
197201
expect((options?.intents ?? 0) & GatewayIntents.GuildVoiceStates).toBe(0);
198202
});
199203

204+
it("omits voice states when Discord voice config is absent", () => {
205+
const plugin = createPlugin(undefined, {});
206+
const options = (plugin as unknown as { options?: { intents?: number } }).options;
207+
208+
expect((options?.intents ?? 0) & GatewayIntents.GuildVoiceStates).toBe(0);
209+
});
210+
211+
it("keeps voice states for existing Discord voice config blocks", () => {
212+
const plugin = createPlugin(undefined, { voice: {} });
213+
const options = (plugin as unknown as { options?: { intents?: number } }).options;
214+
215+
expect((options?.intents ?? 0) & GatewayIntents.GuildVoiceStates).toBe(
216+
GatewayIntents.GuildVoiceStates,
217+
);
218+
});
219+
200220
it("leaves autoInteractions disabled so OpenClaw owns interaction handoff", () => {
201221
const plugin = createPlugin();
202222

extensions/discord/src/monitor/gateway-plugin.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
1111
import * as ws from "ws";
1212
import * as discordGateway from "../internal/gateway.js";
1313
import { validateDiscordProxyUrl } from "../proxy-fetch.js";
14+
import { resolveDiscordVoiceEnabled } from "../voice/config.js";
1415
import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js";
1516
import {
1617
fetchDiscordGatewayInfoWithTimeout,
@@ -70,7 +71,7 @@ type ResolveDiscordGatewayIntentsParams = {
7071
export function resolveDiscordGatewayIntents(params?: ResolveDiscordGatewayIntentsParams): number {
7172
const intentsConfig = params?.intentsConfig;
7273
const voiceEnabled = params?.voiceEnabled;
73-
const voiceStatesEnabled = intentsConfig?.voiceStates ?? voiceEnabled ?? true;
74+
const voiceStatesEnabled = intentsConfig?.voiceStates ?? voiceEnabled ?? false;
7475
let intents =
7576
discordGateway.GatewayIntents.Guilds |
7677
discordGateway.GatewayIntents.GuildMessages |
@@ -253,7 +254,7 @@ export function createDiscordGatewayPlugin(params: {
253254
}): discordGateway.GatewayPlugin {
254255
const intents = resolveDiscordGatewayIntents({
255256
intentsConfig: params.discordConfig?.intents,
256-
voiceEnabled: params.discordConfig?.voice?.enabled !== false,
257+
voiceEnabled: resolveDiscordVoiceEnabled(params.discordConfig?.voice),
257258
});
258259
const proxy = resolveEffectiveDebugProxyUrl(params.discordConfig?.proxy);
259260
const debugProxySettings = resolveDebugProxySettings();

extensions/discord/src/monitor/provider.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,25 @@ describe("monitorDiscordProvider", () => {
391391
expect(voiceRuntimeModuleLoadedMock).not.toHaveBeenCalled();
392392
});
393393

394+
it("does not load the Discord voice runtime for text-only default config", async () => {
395+
resolveDiscordAccountMock.mockReturnValue({
396+
accountId: "default",
397+
token: "MTIz.abc.def",
398+
config: {
399+
commands: { native: true, nativeSkills: false },
400+
agentComponents: { enabled: false },
401+
execApprovals: { enabled: false },
402+
},
403+
});
404+
405+
await monitorDiscordProvider({
406+
config: baseConfig(),
407+
runtime: baseRuntime(),
408+
});
409+
410+
expect(voiceRuntimeModuleLoadedMock).not.toHaveBeenCalled();
411+
});
412+
394413
it("loads the Discord voice runtime only when voice is enabled", async () => {
395414
resolveDiscordAccountMock.mockReturnValue({
396415
accountId: "default",
@@ -411,6 +430,26 @@ describe("monitorDiscordProvider", () => {
411430
expect(voiceRuntimeModuleLoadedMock).toHaveBeenCalledTimes(1);
412431
});
413432

433+
it("loads the Discord voice runtime for existing voice config blocks", async () => {
434+
resolveDiscordAccountMock.mockReturnValue({
435+
accountId: "default",
436+
token: "MTIz.abc.def",
437+
config: {
438+
commands: { native: true, nativeSkills: false },
439+
voice: {},
440+
agentComponents: { enabled: false },
441+
execApprovals: { enabled: false },
442+
},
443+
});
444+
445+
await monitorDiscordProvider({
446+
config: baseConfig(),
447+
runtime: baseRuntime(),
448+
});
449+
450+
expect(voiceRuntimeModuleLoadedMock).toHaveBeenCalledTimes(1);
451+
});
452+
414453
it("wires exec approval button context from the resolved Discord account config", async () => {
415454
const cfg = createConfigWithDiscordAccount();
416455
const execApprovalsConfig = { enabled: true, approvers: ["123"] };

extensions/discord/src/monitor/provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { GatewayCloseCodes } from "../internal/gateway.js";
3232
import { fetchDiscordApplicationId, parseApplicationIdFromToken } from "../probe.js";
3333
import { resolveDiscordProxyFetchForAccount } from "../proxy-fetch.js";
3434
import { normalizeDiscordToken } from "../token.js";
35+
import { resolveDiscordVoiceEnabled } from "../voice/config.js";
3536
import { createDiscordAutoPresenceController } from "./auto-presence.js";
3637
import { resolveDiscordSlashCommandConfig } from "./commands.js";
3738
import type { MutableDiscordGateway } from "./gateway-handle.js";
@@ -282,7 +283,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
282283
const slashCommand = resolveDiscordSlashCommandConfig(discordCfg.slashCommand);
283284
const sessionPrefix = "discord:slash";
284285
const ephemeralDefault = slashCommand.ephemeral;
285-
const voiceEnabled = discordCfg.voice?.enabled !== false;
286+
const voiceEnabled = resolveDiscordVoiceEnabled(discordCfg.voice);
286287

287288
const allowlistResolved = await resolveDiscordAllowlistConfig({
288289
token,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types";
2+
3+
export function resolveDiscordVoiceEnabled(voice: DiscordAccountConfig["voice"]): boolean {
4+
if (voice?.enabled !== undefined) {
5+
return voice.enabled;
6+
}
7+
return voice !== undefined;
8+
}

extensions/discord/src/voice/manager.e2e.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ describe("DiscordVoiceManager", () => {
213213
const createManager = (
214214
discordConfig: ConstructorParameters<
215215
typeof managerModule.DiscordVoiceManager
216-
>[0]["discordConfig"] = {},
216+
>[0]["discordConfig"] = { voice: { enabled: true } },
217217
clientOverride?: ReturnType<typeof createClient>,
218218
cfgOverride: ConstructorParameters<typeof managerModule.DiscordVoiceManager>[0]["cfg"] = {},
219219
) =>
@@ -250,6 +250,17 @@ describe("DiscordVoiceManager", () => {
250250
);
251251
};
252252

253+
it("rejects joins when Discord voice config is absent", async () => {
254+
const manager = createManager({});
255+
256+
await expect(manager.join({ guildId: "g1", channelId: "1001" })).resolves.toMatchObject({
257+
ok: false,
258+
message: "Discord voice is disabled (channels.discord.voice.enabled).",
259+
});
260+
261+
expect(joinVoiceChannelMock).not.toHaveBeenCalled();
262+
});
263+
253264
type ProcessSegmentInvoker = {
254265
processSegment: (params: {
255266
entry: unknown;

0 commit comments

Comments
 (0)