import { ChannelType } from "@buape/carbon"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { createConnectionMock, joinVoiceChannelMock, entersStateMock, createAudioPlayerMock, resolveAgentRouteMock, agentCommandMock, transcribeAudioFileMock, } = 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" })), agentCommandMock: vi.fn(async (_opts?: unknown, _runtime?: unknown) => ({ payloads: [] })), transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })), }; }); 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("openclaw/plugin-sdk/routing", () => ({ resolveAgentRoute: resolveAgentRouteMock, })); vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, agentCommandFromIngress: agentCommandMock, }; }); vi.mock("openclaw/plugin-sdk/media-understanding-runtime", () => ({ transcribeAudioFile: transcribeAudioFileMock, })); 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(); agentCommandMock.mockReset(); agentCommandMock.mockResolvedValue({ payloads: [] }); transcribeAudioFileMock.mockReset(); transcribeAudioFileMock.mockResolvedValue({ text: "hello from voice" }); }); const createManager = ( discordConfig: ConstructorParameters< typeof managerModule.DiscordVoiceManager >[0]["discordConfig"] = {}, clientOverride?: ReturnType, ) => new managerModule.DiscordVoiceManager({ client: (clientOverride ?? createClient()) as never, cfg: {}, discordConfig, accountId: "default", runtime: createRuntime(), }); const expectConnectedStatus = ( manager: InstanceType, channelId: string, ) => { expect(manager.status()).toEqual([ { ok: true, message: `connected: guild g1 channel ${channelId}`, guildId: "g1", channelId, }, ]); }; const emitDecryptFailure = (manager: InstanceType) => { const entry = (manager as unknown as { sessions: Map }).sessions.get("g1"); expect(entry).toBeDefined(); ( manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void } ).handleReceiveError( entry, new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"), ); }; type ProcessSegmentInvoker = { processSegment: (params: { entry: unknown; wavPath: string; userId: string; durationSeconds: number; }) => Promise; }; const processVoiceSegment = async ( manager: InstanceType, userId: string, ) => await (manager as unknown as ProcessSegmentInvoker).processSegment({ entry: { guildId: "g1", channelId: "c1", route: { sessionKey: "discord:g1:c1", agentId: "agent-1" }, }, wavPath: "/tmp/test.wav", userId, durationSeconds: 1.2, }); 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 = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); await manager.join({ guildId: "g1", channelId: "1002" }); const oldDisconnected = oldConnection.handlers.get("disconnected"); expect(oldDisconnected).toBeTypeOf("function"); await oldDisconnected?.(); expectConnectedStatus(manager, "1002"); }); 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 = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); await manager.join({ guildId: "g1", channelId: "1002" }); const oldDestroyed = oldConnection.handlers.get("destroyed"); expect(oldDestroyed).toBeTypeOf("function"); oldDestroyed?.(); expectConnectedStatus(manager, "1002"); }); it("removes voice listeners on leave", async () => { const connection = createConnectionMock(); joinVoiceChannelMock.mockReturnValueOnce(connection); const manager = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); 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 = createManager({ voice: { daveEncryption: false, decryptionFailureTolerance: 8, }, }); await manager.join({ guildId: "g1", channelId: "1001" }); expect(joinVoiceChannelMock).toHaveBeenCalledWith( expect.objectContaining({ daveEncryption: false, decryptionFailureTolerance: 8, }), ); }); it("attempts rejoin after repeated decrypt failures", async () => { const manager = createManager(); await manager.join({ guildId: "g1", channelId: "1001" }); emitDecryptFailure(manager); emitDecryptFailure(manager); emitDecryptFailure(manager); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2); }); it("passes senderIsOwner=true for allowlisted voice speakers", async () => { const client = createClient(); client.fetchMember.mockResolvedValue({ nickname: "Owner Nick", user: { id: "u-owner", username: "owner", globalName: "Owner", discriminator: "1234", }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); await processVoiceSegment(manager, "u-owner"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } | undefined; expect(commandArgs?.senderIsOwner).toBe(true); }); it("passes senderIsOwner=false for non-owner voice speakers", async () => { const client = createClient(); client.fetchMember.mockResolvedValue({ nickname: "Guest Nick", user: { id: "u-guest", username: "guest", globalName: "Guest", discriminator: "4321", }, }); const manager = createManager({ allowFrom: ["discord:u-owner"] }, client); await processVoiceSegment(manager, "u-guest"); const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as | { senderIsOwner?: boolean } | undefined; expect(commandArgs?.senderIsOwner).toBe(false); }); it("reuses speaker context cache for repeated segments from the same speaker", async () => { const client = createClient(); client.fetchMember.mockResolvedValue({ nickname: "Cached Speaker", user: { id: "u-cache", username: "cache", globalName: "Cache", discriminator: "1111", }, }); const manager = createManager({ allowFrom: ["discord:u-cache"] }, client); const runSegment = async () => await processVoiceSegment(manager, "u-cache"); await runSegment(); await runSegment(); expect(client.fetchMember).toHaveBeenCalledTimes(1); }); });