* refactor: move Discord channel implementation to extensions/discord/src/ Move all Discord source files from src/discord/ to extensions/discord/src/, following the extension migration pattern. Source files in src/discord/ are replaced with re-export shims. Channel-plugin files from src/channels/plugins/*/discord* are similarly moved and shimmed. - Copy all .ts source files preserving subdirectory structure (monitor/, voice/) - Move channel-plugin files (actions, normalize, onboarding, outbound, status-issues) - Fix all relative imports to use correct paths from new location - Create re-export shims at original locations for backward compatibility - Delete test files from shim locations (tests live in extension now) - Update tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to accommodate extension files outside src/ - Update write-plugin-sdk-entry-dts.ts to match new declaration output paths * fix: add importOriginal to thread-bindings session-meta mock for extensions test * style: fix formatting in thread-bindings lifecycle test
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
import { ChannelType } from "@buape/carbon";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
createConnectionMock,
|
|
joinVoiceChannelMock,
|
|
entersStateMock,
|
|
createAudioPlayerMock,
|
|
resolveAgentRouteMock,
|
|
agentCommandMock,
|
|
buildProviderRegistryMock,
|
|
createMediaAttachmentCacheMock,
|
|
normalizeMediaAttachmentsMock,
|
|
runCapabilityMock,
|
|
} = 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: [] })),
|
|
buildProviderRegistryMock: vi.fn(() => ({})),
|
|
createMediaAttachmentCacheMock: vi.fn(() => ({
|
|
cleanup: vi.fn(async () => undefined),
|
|
})),
|
|
normalizeMediaAttachmentsMock: vi.fn(() => [{ kind: "audio", path: "/tmp/test.wav" }]),
|
|
runCapabilityMock: vi.fn(async () => ({
|
|
outputs: [{ kind: "audio.transcription", 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("../../../../src/routing/resolve-route.js", () => ({
|
|
resolveAgentRoute: resolveAgentRouteMock,
|
|
}));
|
|
|
|
vi.mock("../../../../src/commands/agent.js", () => ({
|
|
agentCommandFromIngress: agentCommandMock,
|
|
}));
|
|
|
|
vi.mock("../../../../src/media-understanding/runner.js", () => ({
|
|
buildProviderRegistry: buildProviderRegistryMock,
|
|
createMediaAttachmentCache: createMediaAttachmentCacheMock,
|
|
normalizeMediaAttachments: normalizeMediaAttachmentsMock,
|
|
runCapability: runCapabilityMock,
|
|
}));
|
|
|
|
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: [] });
|
|
buildProviderRegistryMock.mockReset();
|
|
buildProviderRegistryMock.mockReturnValue({});
|
|
createMediaAttachmentCacheMock.mockClear();
|
|
normalizeMediaAttachmentsMock.mockReset();
|
|
normalizeMediaAttachmentsMock.mockReturnValue([{ kind: "audio", path: "/tmp/test.wav" }]);
|
|
runCapabilityMock.mockReset();
|
|
runCapabilityMock.mockResolvedValue({
|
|
outputs: [{ kind: "audio.transcription", 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);
|
|
});
|
|
});
|