fix: make Discord voice reconnect timing resilient

This commit is contained in:
Peter Steinberger
2026-05-01 11:57:38 +01:00
parent 678ef019f3
commit 737fd808dd
12 changed files with 179 additions and 13 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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],

View File

@@ -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).",

View File

@@ -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 () => {

View File

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

View File

@@ -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;

View File

@@ -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).",

View File

@@ -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;
};

View File

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