diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9bddd6392..cd56a9a85ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky. - Discord/Proxy + reactions + model picker: thread channel proxy fetch into inbound media/sticker downloads, use proxy-aware gateway metadata fetch for WSL/corporate proxy setups, wire `messages.statusReactions.{emojis,timing}` into Discord reaction lifecycle control, and compact model-picker `custom_id` keys to stay under Discord's 100-char limit while keeping backward-compatible parsing. (#25232, #25507, #25564, #25695) Thanks @openperf, @chilu18, @Yipsh, @lbo728, and @s1korrrr. - Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin. +- Discord/Voice reliability: restore runtime DAVE dependency (`@snazzah/davey`), add configurable DAVE join options (`channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance`), clean up voice listeners/session teardown, guard against stale connection events, and trigger controlled rejoin recovery after repeated decrypt failures to improve inbound STT stability under DAVE receive errors. (#25861, #25372, #24883, #24825, #23890, #23105, #22961, #23421, #23278, #23032) - Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall. - Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility. - Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 108ef34d4ef..98a0db693f1 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -919,6 +919,8 @@ Auto-join example: channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -933,6 +935,8 @@ Notes: - `voice.tts` overrides `messages.tts` for voice playback only. - Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. +- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options. +- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)`, this may be the upstream `@discordjs/voice` receive bug tracked in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419). ## Voice messages diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 825acbaadf5..2aef7982198 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -255,6 +255,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat channelId: "234567890123456789", }, ], + daveEncryption: true, + decryptionFailureTolerance: 24, tts: { provider: "openai", openai: { voice: "alloy" }, @@ -282,6 +284,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding - `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers. - `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides. +- `channels.discord.voice.daveEncryption` and `channels.discord.voice.decryptionFailureTolerance` pass through to `@discordjs/voice` DAVE options. - `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. - `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). diff --git a/package.json b/package.json index 69657a04cf2..c2f69c7286f 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "@sinclair/typebox": "0.34.48", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.14.1", + "@snazzah/davey": "^0.1.9", "@whiskeysockets/baileys": "7.0.0-rc.9", "ajv": "^8.18.0", "chalk": "^5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd21887d7a8..46a7f41fcb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: '@slack/web-api': specifier: ^7.14.1 version: 7.14.1 + '@snazzah/davey': + specifier: ^0.1.9 + version: 0.1.9 '@whiskeysockets/baileys': specifier: 7.0.0-rc.9 version: 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) @@ -2722,6 +2725,93 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@snazzah/davey-android-arm-eabi@0.1.9': + resolution: {integrity: sha512-Dq0WyeVGBw+uQbisV/6PeCQV2ndJozfhZqiNIfQxu6ehIdXB7iHILv+oY+AQN2n+qxiFmLh/MOX9RF+pIWdPbA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@snazzah/davey-android-arm64@0.1.9': + resolution: {integrity: sha512-OE16OZjv7F/JrD7Mzw5eL2gY2vXRPC8S7ZrmkcMyz/sHHJsGHlT+L7X5s56Bec1YDTVmzAsH4UBuvVBoXuIWEQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@snazzah/davey-darwin-arm64@0.1.9': + resolution: {integrity: sha512-z7oORvAPExikFkH6tvHhbUdZd77MYZp9VqbCpKEiI+sisWFVXgHde7F7iH3G4Bz6gUYJfgvKhWXiDRc+0SC4dg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@snazzah/davey-darwin-x64@0.1.9': + resolution: {integrity: sha512-f1LzGyRGlM414KpXml3OgWVSd7CgylcdYaFj/zDBb8bvWjxyvsI9iMeuPfe/cduloxRj8dELde/yCDZtFR6PdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@snazzah/davey-freebsd-x64@0.1.9': + resolution: {integrity: sha512-k6p3JY2b8rD6j0V9Ql7kBUMR4eJdcpriNwiHltLzmtGuz/nK5RGQdkEP68gTLc+Uj3xs5Cy0jRKmv2xJQBR4sA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + resolution: {integrity: sha512-xDaAFUC/1+n/YayNwKsqKOBMuW0KI6F0SjgWU+krYTQTVmAKNjOM80IjemrVoqTpBOxBsT80zEtct2wj11CE3Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + resolution: {integrity: sha512-t1VxFBzWExPNpsNY/9oStdAAuHqFvwZvIO2YPYyVNstxfi2KmAbHMweHUW7xb2ppXuhVQZ4VGmmeXiXcXqhPBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-arm64-musl@0.1.9': + resolution: {integrity: sha512-Xvlr+nBPzuFV4PXHufddlt08JsEyu0p8mX2DpqdPxdpysYIH4I8V86yJiS4tk04a6pLBDd8IxTbBwvXJKqd/LQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@snazzah/davey-linux-x64-gnu@0.1.9': + resolution: {integrity: sha512-6Uunc/NxiEkg1reroAKZAGfOtjl1CGa7hfTTVClb2f+DiA8ZRQWBh+3lgkq/0IeL262B4F14X8QRv5Bsv128qw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-linux-x64-musl@0.1.9': + resolution: {integrity: sha512-fFQ/n3aWt1lXhxSdy+Ge3gi5bR3VETMVsWhH0gwBALUKrbo3ZzgSktm4lNrXE9i0ncMz/CDpZ5i0wt/N3XphEQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@snazzah/davey-wasm32-wasi@0.1.9': + resolution: {integrity: sha512-xWvzej8YCVlUvzlpmqJMIf0XmLlHqulKZ2e7WNe2TxQmsK+o0zTZqiQYs2MwaEbrNXBhYlHDkdpuwoXkJdscNQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + resolution: {integrity: sha512-sTqry/DfltX2OdW1CTLKa3dFYN5FloAEb2yhGsY1i5+Bms6OhwByXfALvyMHYVo61Th2+sD+9BJpQffHFKDA3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + resolution: {integrity: sha512-twD3LwlkGnSwphsCtpGb5ztpBIWEvGdc0iujoVkdzZ6nJiq5p8iaLjJMO4hBm9h3s28fc+1Qd7AMVnagiOasnA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@snazzah/davey-win32-x64-msvc@0.1.9': + resolution: {integrity: sha512-eMnXbv4GoTngWYY538i/qHz2BS+RgSXFsvKltPzKqnqzPzhQZIY7TemEJn3D5yWGfW4qHve9u23rz93FQqnQMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@snazzah/davey@0.1.9': + resolution: {integrity: sha512-vNZk5y+IsxjwzTAXikvzz5pqMLb35YytC64nVF2MAFVhjpXu9ITOKUriZ0JG/llwzCAi56jb5x0cXDRIyE2A2A==} + engines: {node: '>= 10'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -8254,6 +8344,67 @@ snapshots: dependencies: tslib: 2.8.1 + '@snazzah/davey-android-arm-eabi@0.1.9': + optional: true + + '@snazzah/davey-android-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-arm64@0.1.9': + optional: true + + '@snazzah/davey-darwin-x64@0.1.9': + optional: true + + '@snazzah/davey-freebsd-x64@0.1.9': + optional: true + + '@snazzah/davey-linux-arm-gnueabihf@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-arm64-musl@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-gnu@0.1.9': + optional: true + + '@snazzah/davey-linux-x64-musl@0.1.9': + optional: true + + '@snazzah/davey-wasm32-wasi@0.1.9': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@snazzah/davey-win32-arm64-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-ia32-msvc@0.1.9': + optional: true + + '@snazzah/davey-win32-x64-msvc@0.1.9': + optional: true + + '@snazzah/davey@0.1.9': + optionalDependencies: + '@snazzah/davey-android-arm-eabi': 0.1.9 + '@snazzah/davey-android-arm64': 0.1.9 + '@snazzah/davey-darwin-arm64': 0.1.9 + '@snazzah/davey-darwin-x64': 0.1.9 + '@snazzah/davey-freebsd-x64': 0.1.9 + '@snazzah/davey-linux-arm-gnueabihf': 0.1.9 + '@snazzah/davey-linux-arm64-gnu': 0.1.9 + '@snazzah/davey-linux-arm64-musl': 0.1.9 + '@snazzah/davey-linux-x64-gnu': 0.1.9 + '@snazzah/davey-linux-x64-musl': 0.1.9 + '@snazzah/davey-wasm32-wasi': 0.1.9 + '@snazzah/davey-win32-arm64-msvc': 0.1.9 + '@snazzah/davey-win32-ia32-msvc': 0.1.9 + '@snazzah/davey-win32-x64-msvc': 0.1.9 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.19': diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index bac2c2dcae1..e5fcb3aa6b7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1364,6 +1364,10 @@ export const FIELD_HELP: Record = { "Enable Discord voice channel conversations (default: true). Omit channels.discord.voice to keep voice support disabled for the account.", "channels.discord.voice.autoJoin": "Voice channels to auto-join on startup (list of guildId/channelId entries).", + "channels.discord.voice.daveEncryption": + "Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this).", + "channels.discord.voice.decryptionFailureTolerance": + "Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24).", "channels.discord.voice.tts": "Optional TTS overrides for Discord voice playback (merged with messages.tts).", "channels.discord.intents.presence": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index f1706d1af7d..7a12e9293ba 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -677,6 +677,8 @@ export const FIELD_LABELS: Record = { "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.discord.voice.enabled": "Discord Voice Enabled", "channels.discord.voice.autoJoin": "Discord Voice Auto-Join", + "channels.discord.voice.daveEncryption": "Discord Voice DAVE Encryption", + "channels.discord.voice.decryptionFailureTolerance": "Discord Voice Decrypt Failure Tolerance", "channels.discord.voice.tts": "Discord Voice Text-to-Speech", "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", "channels.discord.pluralkit.token": "Discord PluralKit Token", diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 0d795c94bb4..1b43ddeb48b 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -107,6 +107,10 @@ export type DiscordVoiceConfig = { enabled?: boolean; /** Voice channels to auto-join on startup. */ autoJoin?: DiscordVoiceAutoJoinConfig[]; + /** Enable/disable DAVE end-to-end encryption (default: true; Discord may require this). */ + daveEncryption?: boolean; + /** Consecutive decrypt failures before DAVE session reinitialization (default: 24). */ + decryptionFailureTolerance?: 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 bccbb5bdd35..806eb8f89ce 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -315,6 +315,8 @@ const DiscordVoiceSchema = z .object({ enabled: z.boolean().optional(), autoJoin: z.array(DiscordVoiceAutoJoinSchema).optional(), + daveEncryption: z.boolean().optional(), + decryptionFailureTolerance: z.number().int().min(0).optional(), tts: TtsConfigSchema.optional(), }) .strict() diff --git a/src/discord/voice/manager.test.ts b/src/discord/voice/manager.test.ts new file mode 100644 index 00000000000..70dbcf5170c --- /dev/null +++ b/src/discord/voice/manager.test.ts @@ -0,0 +1,268 @@ +import { ChannelType } from "@buape/carbon"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + createConnectionMock, + joinVoiceChannelMock, + entersStateMock, + createAudioPlayerMock, + resolveAgentRouteMock, +} = vi.hoisted(() => { + type EventHandler = (...args: unknown[]) => unknown; + type MockConnection = { + destroy: ReturnType; + subscribe: ReturnType; + on: ReturnType; + off: ReturnType; + receiver: { + speaking: { + on: ReturnType; + off: ReturnType; + }; + subscribe: ReturnType; + }; + handlers: Map; + }; + + const createConnectionMock = (): MockConnection => { + const handlers = new Map(); + const connection: MockConnection = { + destroy: vi.fn(), + subscribe: vi.fn(), + on: vi.fn((event: string, handler: EventHandler) => { + handlers.set(event, handler); + }), + off: vi.fn(), + receiver: { + speaking: { + on: vi.fn(), + off: vi.fn(), + }, + subscribe: vi.fn(() => ({ + on: vi.fn(), + [Symbol.asyncIterator]: async function* () {}, + })), + }, + handlers, + }; + return connection; + }; + + return { + createConnectionMock, + joinVoiceChannelMock: vi.fn(() => createConnectionMock()), + entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => { + return undefined; + }), + createAudioPlayerMock: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + stop: vi.fn(), + play: vi.fn(), + state: { status: "idle" }, + })), + resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })), + }; +}); + +vi.mock("@discordjs/voice", () => ({ + AudioPlayerStatus: { Playing: "playing", Idle: "idle" }, + EndBehaviorType: { AfterSilence: "AfterSilence" }, + VoiceConnectionStatus: { + Ready: "ready", + Disconnected: "disconnected", + Destroyed: "destroyed", + Signalling: "signalling", + Connecting: "connecting", + }, + createAudioPlayer: createAudioPlayerMock, + createAudioResource: vi.fn(), + entersState: entersStateMock, + joinVoiceChannel: joinVoiceChannelMock, +})); + +vi.mock("../../routing/resolve-route.js", () => ({ + resolveAgentRoute: resolveAgentRouteMock, +})); + +let managerModule: typeof import("./manager.js"); + +function createClient() { + return { + fetchChannel: vi.fn(async (channelId: string) => ({ + id: channelId, + guildId: "g1", + type: ChannelType.GuildVoice, + })), + getPlugin: vi.fn(() => ({ + getGatewayAdapterCreator: vi.fn(() => vi.fn()), + })), + fetchMember: vi.fn(), + fetchUser: vi.fn(), + }; +} + +function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; +} + +describe("DiscordVoiceManager", () => { + beforeAll(async () => { + managerModule = await import("./manager.js"); + }); + + beforeEach(() => { + joinVoiceChannelMock.mockReset(); + joinVoiceChannelMock.mockImplementation(() => createConnectionMock()); + entersStateMock.mockReset(); + entersStateMock.mockResolvedValue(undefined); + createAudioPlayerMock.mockClear(); + resolveAgentRouteMock.mockClear(); + }); + + it("keeps the new session when an old disconnected handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + entersStateMock.mockImplementation(async (target: unknown, status?: string) => { + if (target === oldConnection && (status === "signalling" || status === "connecting")) { + throw new Error("old disconnected"); + } + return undefined; + }); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDisconnected = oldConnection.handlers.get("disconnected"); + expect(oldDisconnected).toBeTypeOf("function"); + await oldDisconnected?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("keeps the new session when an old destroyed handler fires", async () => { + const oldConnection = createConnectionMock(); + const newConnection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection); + + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.join({ guildId: "g1", channelId: "c2" }); + + const oldDestroyed = oldConnection.handlers.get("destroyed"); + expect(oldDestroyed).toBeTypeOf("function"); + oldDestroyed?.(); + + expect(manager.status()).toEqual([ + { + ok: true, + message: "connected: guild g1 channel c2", + guildId: "g1", + channelId: "c2", + }, + ]); + }); + + it("removes voice listeners on leave", async () => { + const connection = createConnectionMock(); + joinVoiceChannelMock.mockReturnValueOnce(connection); + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + await manager.leave({ guildId: "g1" }); + + const player = createAudioPlayerMock.mock.results[0]?.value; + expect(connection.receiver.speaking.off).toHaveBeenCalledWith("start", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("disconnected", expect.any(Function)); + expect(connection.off).toHaveBeenCalledWith("destroyed", expect.any(Function)); + expect(player.off).toHaveBeenCalledWith("error", expect.any(Function)); + }); + + it("passes DAVE options to joinVoiceChannel", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: { + voice: { + daveEncryption: false, + decryptionFailureTolerance: 8, + }, + }, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + expect(joinVoiceChannelMock).toHaveBeenCalledWith( + expect.objectContaining({ + daveEncryption: false, + decryptionFailureTolerance: 8, + }), + ); + }); + + it("attempts rejoin after repeated decrypt failures", async () => { + const manager = new managerModule.DiscordVoiceManager({ + client: createClient() as never, + cfg: {}, + discordConfig: {}, + accountId: "default", + runtime: createRuntime(), + }); + + await manager.join({ guildId: "g1", channelId: "c1" }); + + const entry = (manager as { sessions: Map }).sessions.get("g1"); + expect(entry).toBeDefined(); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + (manager as { handleReceiveError: (e: unknown, err: unknown) => void }).handleReceiveError( + entry, + new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/discord/voice/manager.ts b/src/discord/voice/manager.ts index f9da749a74f..c246b280fb4 100644 --- a/src/discord/voice/manager.ts +++ b/src/discord/voice/manager.ts @@ -45,6 +45,9 @@ const MIN_SEGMENT_SECONDS = 0.35; const SILENCE_DURATION_MS = 1_000; const PLAYBACK_READY_TIMEOUT_MS = 15_000; const SPEAKING_READY_TIMEOUT_MS = 60_000; +const DECRYPT_FAILURE_WINDOW_MS = 30_000; +const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3; +const DECRYPT_FAILURE_PATTERN = /DecryptionFailed\(/; const logger = createSubsystemLogger("discord/voice"); @@ -69,6 +72,9 @@ type VoiceSessionEntry = { playbackQueue: Promise; processingQueue: Promise; activeSpeakers: Set; + decryptFailureCount: number; + lastDecryptFailureAt: number; + decryptRecoveryInFlight: boolean; stop: () => void; }; @@ -377,12 +383,21 @@ export class DiscordVoiceManager { } const adapterCreator = voicePlugin.getGatewayAdapterCreator(guildId); + const daveEncryption = this.params.discordConfig.voice?.daveEncryption; + const decryptionFailureTolerance = this.params.discordConfig.voice?.decryptionFailureTolerance; + logVoiceVerbose( + `join: DAVE settings encryption=${daveEncryption === false ? "off" : "on"} tolerance=${ + decryptionFailureTolerance ?? "default" + }`, + ); const connection = joinVoiceChannel({ channelId, guildId, adapterCreator, selfDeaf: false, selfMute: false, + daveEncryption, + decryptionFailureTolerance, }); try { @@ -412,6 +427,17 @@ export class DiscordVoiceManager { const player = createAudioPlayer(); connection.subscribe(player); + let speakingHandler: ((userId: string) => void) | undefined; + let disconnectedHandler: (() => Promise) | undefined; + let destroyedHandler: (() => void) | undefined; + let playerErrorHandler: ((err: Error) => void) | undefined; + const clearSessionIfCurrent = () => { + const active = this.sessions.get(guildId); + if (active?.connection === connection) { + this.sessions.delete(guildId); + } + }; + const entry: VoiceSessionEntry = { guildId, channelId, @@ -422,37 +448,55 @@ export class DiscordVoiceManager { playbackQueue: Promise.resolve(), processingQueue: Promise.resolve(), activeSpeakers: new Set(), + decryptFailureCount: 0, + lastDecryptFailureAt: 0, + decryptRecoveryInFlight: false, stop: () => { + if (speakingHandler) { + connection.receiver.speaking.off("start", speakingHandler); + } + if (disconnectedHandler) { + connection.off(VoiceConnectionStatus.Disconnected, disconnectedHandler); + } + if (destroyedHandler) { + connection.off(VoiceConnectionStatus.Destroyed, destroyedHandler); + } + if (playerErrorHandler) { + player.off("error", playerErrorHandler); + } player.stop(); connection.destroy(); }, }; - const speakingHandler = (userId: string) => { + speakingHandler = (userId: string) => { void this.handleSpeakingStart(entry, userId).catch((err) => { logger.warn(`discord voice: capture failed: ${formatErrorMessage(err)}`); }); }; - connection.receiver.speaking.on("start", speakingHandler); - connection.on(VoiceConnectionStatus.Disconnected, async () => { + disconnectedHandler = async () => { try { await Promise.race([ entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000), ]); } catch { - this.sessions.delete(guildId); + clearSessionIfCurrent(); connection.destroy(); } - }); - connection.on(VoiceConnectionStatus.Destroyed, () => { - this.sessions.delete(guildId); - }); - - player.on("error", (err) => { + }; + destroyedHandler = () => { + clearSessionIfCurrent(); + }; + playerErrorHandler = (err: Error) => { logger.warn(`discord voice: playback error: ${formatErrorMessage(err)}`); - }); + }; + + connection.receiver.speaking.on("start", speakingHandler); + connection.on(VoiceConnectionStatus.Disconnected, disconnectedHandler); + connection.on(VoiceConnectionStatus.Destroyed, destroyedHandler); + player.on("error", playerErrorHandler); this.sessions.set(guildId, entry); return { @@ -526,7 +570,7 @@ export class DiscordVoiceManager { }, }); stream.on("error", (err) => { - logger.warn(`discord voice: receive error: ${formatErrorMessage(err)}`); + this.handleReceiveError(entry, err); }); try { @@ -537,6 +581,7 @@ export class DiscordVoiceManager { ); return; } + this.resetDecryptFailureState(entry); const { path: wavPath, durationSeconds } = await writeWavFile(pcm); if (durationSeconds < MIN_SEGMENT_SECONDS) { logVoiceVerbose( @@ -654,6 +699,64 @@ export class DiscordVoiceManager { }); } + private handleReceiveError(entry: VoiceSessionEntry, err: unknown) { + const message = formatErrorMessage(err); + logger.warn(`discord voice: receive error: ${message}`); + if (!DECRYPT_FAILURE_PATTERN.test(message)) { + return; + } + const now = Date.now(); + if (now - entry.lastDecryptFailureAt > DECRYPT_FAILURE_WINDOW_MS) { + entry.decryptFailureCount = 0; + } + entry.lastDecryptFailureAt = now; + entry.decryptFailureCount += 1; + if (entry.decryptFailureCount === 1) { + logger.warn( + "discord voice: DAVE decrypt failures detected; voice receive may be unstable (upstream: discordjs/discord.js#11419)", + ); + } + if ( + entry.decryptFailureCount < DECRYPT_FAILURE_RECONNECT_THRESHOLD || + entry.decryptRecoveryInFlight + ) { + return; + } + entry.decryptRecoveryInFlight = true; + this.resetDecryptFailureState(entry); + void this.recoverFromDecryptFailures(entry) + .catch((recoverErr) => + logger.warn(`discord voice: decrypt recovery failed: ${formatErrorMessage(recoverErr)}`), + ) + .finally(() => { + entry.decryptRecoveryInFlight = false; + }); + } + + private resetDecryptFailureState(entry: VoiceSessionEntry) { + entry.decryptFailureCount = 0; + entry.lastDecryptFailureAt = 0; + } + + private async recoverFromDecryptFailures(entry: VoiceSessionEntry) { + const active = this.sessions.get(entry.guildId); + if (!active || active.connection !== entry.connection) { + return; + } + logger.warn( + `discord voice: repeated decrypt failures; attempting rejoin for guild ${entry.guildId} channel ${entry.channelId}`, + ); + const leaveResult = await this.leave({ guildId: entry.guildId }); + if (!leaveResult.ok) { + logger.warn(`discord voice: decrypt recovery leave failed: ${leaveResult.message}`); + return; + } + const result = await this.join({ guildId: entry.guildId, channelId: entry.channelId }); + if (!result.ok) { + logger.warn(`discord voice: rejoin after decrypt failures failed: ${result.message}`); + } + } + private async resolveSpeakerLabel(guildId: string, userId: string): Promise { try { const member = await this.params.client.fetchMember(guildId, userId);