mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix: make Discord voice reconnect timing resilient
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user