2026-03-20 09:30:16 +01:00

357 lines
11 KiB
TypeScript

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<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" })),
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<typeof import("openclaw/plugin-sdk/agent-runtime")>();
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<typeof createClient>,
) =>
new managerModule.DiscordVoiceManager({
client: (clientOverride ?? createClient()) as never,
cfg: {},
discordConfig,
accountId: "default",
runtime: createRuntime(),
});
const expectConnectedStatus = (
manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
channelId: string,
) => {
expect(manager.status()).toEqual([
{
ok: true,
message: `connected: guild g1 channel ${channelId}`,
guildId: "g1",
channelId,
},
]);
};
const emitDecryptFailure = (manager: InstanceType<typeof managerModule.DiscordVoiceManager>) => {
const entry = (manager as unknown as { sessions: Map<string, unknown> }).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<void>;
};
const processVoiceSegment = async (
manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
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);
});
});