diff --git a/CHANGELOG.md b/CHANGELOG.md index c1a3b59b360..72cd7c319c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- 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. - Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep. - WhatsApp: stage `qrcode` with the WhatsApp plugin runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001. - Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 5b532b8ffcd..d434cae78d5 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -516de8f5049d2c8b7f326cfc1b665cf459609aa491c432d93b8ca8b9463d7243 config-baseline.json -b06e5cd6e7d3a26d99fd4d31d576c49958195451b0b1e9c2db45f038a3c16c44 config-baseline.core.json -da8e055ebba0730498703d209f9e2cfaa1484a83f3240e611dcdd7280e22a525 config-baseline.channel.json +2197c0110a367c9e2adba959ff8529edad7b4d526894eec602e47189d6930d2f config-baseline.json +ac7537ed5b5a2d9e7fa50977aa99f5e0babfbe1a93c7c14b93a184b36bb4f539 config-baseline.core.json +f3326cd9490169afefe93625f63699266b75db93855ed439c9692e3c286a990c config-baseline.channel.json 4d017161b4dc986fdc6cc68167fedbd1d415ddbcd66125a872e18aa1769cd182 config-baseline.plugin.json diff --git a/docs/channels/discord.md b/docs/channels/discord.md index f4b365ebcc3..ac78012866b 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1048,6 +1048,8 @@ Auto-join example: ], daveEncryption: true, decryptionFailureTolerance: 24, + connectTimeoutMs: 30000, + reconnectGraceMs: 15000, tts: { provider: "openai", openai: { voice: "onyx" }, @@ -1069,6 +1071,8 @@ Notes: - `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. +- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`. +- `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`. - OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window. - If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)` after updating, collect a dependency report and logs. The bundled `@discordjs/voice` line includes the upstream padding fix from discord.js PR #11449, which closed discord.js issue #11419. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 09248e4ce32..1865f112278 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -297,6 +297,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ], daveEncryption: true, decryptionFailureTolerance: 24, + connectTimeoutMs: 30000, + reconnectGraceMs: 15000, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -339,6 +341,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. - `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses. - `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options (`true` and `24` by default). +- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default). +- `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (`15000` by default). - OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides. diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index c06a2c72585..ce8c62b2517 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -147,6 +147,29 @@ describe("discord config schema", () => { expect(cfg.voice?.model).toBe("openai/gpt-5.4-mini"); }); + it("accepts Discord voice timing overrides", () => { + const cfg = expectValidDiscordConfig({ + voice: { + connectTimeoutMs: 45_000, + reconnectGraceMs: 20_000, + }, + }); + + expect(cfg.voice?.connectTimeoutMs).toBe(45_000); + expect(cfg.voice?.reconnectGraceMs).toBe(20_000); + }); + + it("rejects invalid Discord voice timing overrides", () => { + for (const voice of [ + { connectTimeoutMs: 0 }, + { connectTimeoutMs: 120_001 }, + { reconnectGraceMs: -1 }, + { reconnectGraceMs: 1.5 }, + ]) { + expectInvalidDiscordConfig({ voice }); + } + }); + it("coerces safe-integer numeric allowlist entries to strings", () => { const cfg = expectValidDiscordConfig({ allowFrom: [123], diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 48dd76dc98c..d40a1676240 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -161,6 +161,14 @@ export const discordChannelConfigUiHints = { label: "Discord Voice Decrypt Failure Tolerance", help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", }, + "voice.connectTimeoutMs": { + label: "Discord Voice Connect Timeout (ms)", + help: "Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000.", + }, + "voice.reconnectGraceMs": { + label: "Discord Voice Reconnect Grace (ms)", + help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.", + }, "voice.tts": { label: "Discord Voice Text-to-Speech", help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).", diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index 09079a2a33a..b886cb6c441 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -347,14 +347,63 @@ describe("DiscordVoiceManager", () => { ); }); - it("keeps the shorter timeout for initial voice connection readiness", async () => { + it("uses the default timeout for initial voice connection readiness", async () => { const connection = createConnectionMock(); joinVoiceChannelMock.mockReturnValueOnce(connection); const manager = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); - expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 15_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 30_000); + }); + + it("uses configured voice connection and reconnect timeouts", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager({ + voice: { + connectTimeoutMs: 45_000, + reconnectGraceMs: 20_000, + }, + }); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 45_000); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 20_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 20_000); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); + }); + + it("uses the default reconnect grace before destroying disconnected sessions", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = createManager(); + + await manager.join({ guildId: "g1", channelId: "1001" }); + + entersStateMock.mockClear(); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + entersStateMock.mockRejectedValueOnce(new Error("still disconnected")); + + const disconnected = connection.handlers.get("disconnected"); + expect(disconnected).toBeTypeOf("function"); + await disconnected?.(); + + expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 15_000); + expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 15_000); + expect(connection.destroy).toHaveBeenCalledTimes(1); + expect(manager.status()).toEqual([]); }); it("stores guild metadata on joined voice sessions", async () => { diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index 6bf56a7e5d3..7c9fa679a51 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -35,8 +35,10 @@ import { CAPTURE_FINALIZE_GRACE_MS, isVoiceChannel, logVoiceVerbose, + resolveVoiceTimeoutMs, MIN_SEGMENT_SECONDS, VOICE_CONNECT_READY_TIMEOUT_MS, + VOICE_RECONNECT_GRACE_MS, type VoiceOperationResult, type VoiceSessionEntry, } from "./session.js"; @@ -172,13 +174,22 @@ export class DiscordVoiceManager { return { ok: false, message: "Discord voice plugin is not available." }; } + const voiceConfig = this.params.discordConfig.voice; const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); - const daveEncryption = this.params.discordConfig.voice?.daveEncryption; - const decryptionFailureTolerance = this.params.discordConfig.voice?.decryptionFailureTolerance; + const daveEncryption = voiceConfig?.daveEncryption; + const decryptionFailureTolerance = voiceConfig?.decryptionFailureTolerance; + const connectReadyTimeoutMs = resolveVoiceTimeoutMs( + voiceConfig?.connectTimeoutMs, + VOICE_CONNECT_READY_TIMEOUT_MS, + ); + const reconnectGraceMs = resolveVoiceTimeoutMs( + voiceConfig?.reconnectGraceMs, + VOICE_RECONNECT_GRACE_MS, + ); logVoiceVerbose( `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${ decryptionFailureTolerance ?? "default" - }`, + } connectTimeout=${connectReadyTimeoutMs}ms reconnectGrace=${reconnectGraceMs}ms`, ); const voiceSdk = loadDiscordVoiceSdk(); const connection = voiceSdk.joinVoiceChannel({ @@ -195,10 +206,13 @@ export class DiscordVoiceManager { await voiceSdk.entersState( connection, voiceSdk.VoiceConnectionStatus.Ready, - VOICE_CONNECT_READY_TIMEOUT_MS, + connectReadyTimeoutMs, ); logVoiceVerbose(`join: connected to guild ${guildId} channel ${channelId}`); } catch (err) { + logger.warn( + `discord voice: join failed before ready: guild ${guildId} channel ${channelId} timeout=${connectReadyTimeoutMs}ms error=${formatErrorMessage(err)}`, + ); connection.destroy(); return { ok: false, message: `Failed to join voice channel: ${formatErrorMessage(err)}` }; } @@ -289,11 +303,26 @@ export class DiscordVoiceManager { disconnectedHandler = async () => { try { + logVoiceVerbose( + `disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`, + ); await Promise.race([ - voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Signalling, 5_000), - voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Connecting, 5_000), + voiceSdk.entersState( + connection, + voiceSdk.VoiceConnectionStatus.Signalling, + reconnectGraceMs, + ), + voiceSdk.entersState( + connection, + voiceSdk.VoiceConnectionStatus.Connecting, + reconnectGraceMs, + ), ]); - } catch { + logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`); + } catch (err) { + logger.warn( + `discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`, + ); clearSessionIfCurrent(); connection.destroy(); } diff --git a/extensions/discord/src/voice/session.ts b/extensions/discord/src/voice/session.ts index 5e2f18d5c96..4ed98b7f946 100644 --- a/extensions/discord/src/voice/session.ts +++ b/extensions/discord/src/voice/session.ts @@ -6,10 +6,18 @@ import type { VoiceReceiveRecoveryState } from "./receive-recovery.js"; export const MIN_SEGMENT_SECONDS = 0.35; export const CAPTURE_FINALIZE_GRACE_MS = 1_200; -export const VOICE_CONNECT_READY_TIMEOUT_MS = 15_000; +export const VOICE_CONNECT_READY_TIMEOUT_MS = 30_000; +export const VOICE_RECONNECT_GRACE_MS = 15_000; export const PLAYBACK_READY_TIMEOUT_MS = 60_000; export const SPEAKING_READY_TIMEOUT_MS = 60_000; +export function resolveVoiceTimeoutMs(value: number | undefined, fallbackMs: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallbackMs; + } + return Math.floor(value); +} + export type VoiceOperationResult = { ok: boolean; message: string; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 1e3b6b40077..956207fb7d1 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -278,6 +278,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ blockStreaming: { type: "boolean", }, + replyContextApiFallback: { + type: "boolean", + }, groups: { type: "object", properties: {}, @@ -595,6 +598,9 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ blockStreaming: { type: "boolean", }, + replyContextApiFallback: { + type: "boolean", + }, groups: { type: "object", properties: {}, @@ -1495,6 +1501,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ minimum: 0, maximum: 9007199254740991, }, + connectTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + reconnectGraceMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, tts: { type: "object", properties: { @@ -2861,6 +2877,16 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ minimum: 0, maximum: 9007199254740991, }, + connectTimeoutMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, + reconnectGraceMs: { + type: "integer", + exclusiveMinimum: 0, + maximum: 120000, + }, tts: { type: "object", properties: { @@ -3567,6 +3593,14 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ label: "Discord Voice Decrypt Failure Tolerance", help: "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", }, + "voice.connectTimeoutMs": { + label: "Discord Voice Connect Timeout (ms)", + help: "Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000.", + }, + "voice.reconnectGraceMs": { + label: "Discord Voice Reconnect Grace (ms)", + help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.", + }, "voice.tts": { label: "Discord Voice Text-to-Speech", help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 65446c8753f..9142ba43af0 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -138,6 +138,10 @@ export type DiscordVoiceConfig = { daveEncryption?: boolean; /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ decryptionFailureTolerance?: number; + /** Initial @discordjs/voice Ready wait in milliseconds (default: 30000). */ + connectTimeoutMs?: number; + /** Grace period for Discord voice reconnect signalling after a disconnect (default: 15000). */ + reconnectGraceMs?: number; /** Optional TTS overrides for Discord voice output. */ tts?: TtsConfig; }; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d8c1b879160..8f99b19b235 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -513,6 +513,8 @@ const DiscordVoiceSchema = z autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), daveEncryption: z.boolean().optional(), decryptionFailureTolerance: z.number().int().min(0).optional(), + connectTimeoutMs: z.number().int().positive().max(120_000).optional(), + reconnectGraceMs: z.number().int().positive().max(120_000).optional(), tts: TtsConfigSchema.optional(), }) .strict()