mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix(discord): harden voice DAVE receive reliability (#25861)
Reimplements and consolidates related work: - #24339 stale disconnect/destroyed session guards - #25312 voice listener cleanup on stop - #23036 restore @snazzah/davey runtime dependency Adds Discord voice DAVE config passthrough, repeated decrypt failure rejoin recovery, regression tests, docs, and changelog updates. Co-authored-by: Frank Yang <frank.ekn@gmail.com> Co-authored-by: Do Cao Hieu <admin@docaohieu.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
151
pnpm-lock.yaml
generated
151
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -1364,6 +1364,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -677,6 +677,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
268
src/discord/voice/manager.test.ts
Normal file
268
src/discord/voice/manager.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
receiver: {
|
||||
speaking: {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
handlers: Map<string, EventHandler>;
|
||||
};
|
||||
|
||||
const createConnectionMock = (): MockConnection => {
|
||||
const handlers = new Map<string, EventHandler>();
|
||||
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<string, unknown> }).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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
processingQueue: Promise<void>;
|
||||
activeSpeakers: Set<string>;
|
||||
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<void>) | 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<string | undefined> {
|
||||
try {
|
||||
const member = await this.params.client.fetchMember(guildId, userId);
|
||||
|
||||
Reference in New Issue
Block a user