feat: implement full session recording with muxing support

- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts.
- Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts.
- Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop.
- Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts.
- Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
This commit is contained in:
MythEclipse
2026-05-16 17:59:17 +07:00
parent 8b33af8286
commit 2744e7035b
9 changed files with 1338 additions and 40 deletions

View File

@@ -1,18 +1,102 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const speaking = new EventEmitter();
const subscribe = vi.fn();
const joinVoiceChannel = vi.fn(() => ({
receiver: {
speaking,
subscriptions: new Map(),
subscribe,
// Use vi.hoisted so mocks are available at module evaluation time (when vi.mock hoists)
const mocks = vi.hoisted(() => {
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const speaker = {
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
return speaker;
}),
emit: vi.fn((event: string, ...args: unknown[]) => {
for (const listener of listeners.get(event) ?? []) listener(...args);
return true;
}),
removeAllListeners: vi.fn(() => {
listeners.clear();
return speaker;
}),
};
return {
mockSpeaker: speaker,
mockSubscribe: vi.fn(() => {
const oggPacketStream = {
pipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
unpipe: vi.fn(),
};
return {
pipe: vi.fn(() => oggPacketStream),
on: vi.fn(),
};
}),
mockDestroy: vi.fn(),
mockWriteFileSync: vi.fn(),
mockMkdirSync: vi.fn(),
mockOggPipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
mockCreateWriteStream: vi.fn(() => ({
on: vi.fn(),
})),
mockFsExistsSync: vi.fn(() => true),
};
});
vi.mock("node:fs", () => ({
default: {
createWriteStream: mocks.mockCreateWriteStream,
existsSync: mocks.mockFsExistsSync,
mkdirSync: mocks.mockMkdirSync,
writeFileSync: mocks.mockWriteFileSync,
},
on: vi.fn(),
destroy: vi.fn(),
createWriteStream: mocks.mockCreateWriteStream,
existsSync: mocks.mockFsExistsSync,
mkdirSync: mocks.mockMkdirSync,
writeFileSync: mocks.mockWriteFileSync,
}));
vi.mock("prism-media", () => ({
opus: {
OggLogicalBitstream: vi.fn(function OggLogicalBitstream() {
return {
pipe: mocks.mockOggPipe,
end: vi.fn(),
};
}),
OpusHead: vi.fn(function OpusHead() {}),
Decoder: vi.fn(function Decoder() {}),
},
}));
vi.mock("@discordjs/voice", async () => {
const actual =
await vi.importActual<typeof import("@discordjs/voice")>(
"@discordjs/voice",
);
return {
...actual,
joinVoiceChannel: vi.fn(() => ({
receiver: {
speaking: mocks.mockSpeaker,
subscriptions: new Map(),
subscribe: mocks.mockSubscribe,
},
on: vi.fn(),
destroy: mocks.mockDestroy,
})),
entersState: vi.fn().mockResolvedValue(undefined),
getVoiceConnection: vi.fn(() => ({
destroy: mocks.mockDestroy,
})),
};
});
vi.mock("../src/retry", () => ({
retryWithBackoff: vi.fn((fn: () => Promise<unknown>) => fn()),
}));
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
function createChannel() {
return {
id: "voice-channel",
@@ -28,40 +112,36 @@ function createChannel() {
};
}
vi.mock("@discordjs/voice", async () => {
const actual =
await vi.importActual<typeof import("@discordjs/voice")>(
"@discordjs/voice",
);
return {
...actual,
joinVoiceChannel,
entersState: vi.fn(async () => undefined),
};
});
describe("startRecording", () => {
beforeEach(() => {
subscribe.mockClear();
speaking.removeAllListeners();
mocks.mockSubscribe.mockClear();
mocks.mockSpeaker.removeAllListeners();
mocks.mockDestroy.mockClear();
mocks.mockWriteFileSync.mockClear();
mocks.mockMkdirSync.mockClear();
mocks.mockOggPipe.mockClear();
});
it("does not subscribe to the bot user's own audio", async () => {
const { startRecording } = await import("../src/recorder");
const client = {
user: { id: "bot-user" },
};
const { startRecording, resetActiveSessions } = await import(
"../src/recorder"
);
resetActiveSessions();
const client = { user: { id: "bot-user" } };
const channel = createChannel();
await startRecording(client as never, channel as never);
speaking.emit("start", "bot-user");
await new Promise((resolve) => setImmediate(resolve));
mocks.mockSpeaker.emit("start", "bot-user");
await flushMicrotasks();
expect(subscribe).not.toHaveBeenCalled();
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
});
it("does not subscribe to other bot users", async () => {
const { startRecording } = await import("../src/recorder");
const { startRecording, resetActiveSessions } = await import(
"../src/recorder"
);
resetActiveSessions();
const client = {
user: { id: "self-user" },
users: {
@@ -82,9 +162,80 @@ describe("startRecording", () => {
};
await startRecording(client as never, createChannel() as never);
speaking.emit("start", "music-bot");
await new Promise((resolve) => setImmediate(resolve));
mocks.mockSpeaker.emit("start", "music-bot");
await flushMicrotasks();
expect(subscribe).not.toHaveBeenCalled();
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
});
it("subscribes to a non-bot human user", async () => {
const { startRecording, resetActiveSessions } = await import(
"../src/recorder"
);
resetActiveSessions();
const client = {
user: { id: "self-user" },
users: {
cache: new Map([
[
"human-user",
{
id: "human-user",
username: "Alice",
tag: "Alice#0001",
bot: false,
displayAvatarURL: vi.fn(() => "https://example.com/avatar.png"),
},
],
]),
fetch: vi.fn(async () => null),
},
};
await startRecording(client as never, createChannel() as never);
mocks.mockSpeaker.emit("start", "human-user");
await flushMicrotasks();
expect(mocks.mockSubscribe).toHaveBeenCalledTimes(2);
});
});
describe("stopRecording", () => {
beforeEach(() => {
mocks.mockSubscribe.mockClear();
mocks.mockSpeaker.removeAllListeners();
mocks.mockDestroy.mockClear();
mocks.mockWriteFileSync.mockClear();
mocks.mockMkdirSync.mockClear();
mocks.mockOggPipe.mockClear();
});
it("destroys the voice connection", async () => {
const { startRecording, stopRecording, resetActiveSessions } = await import(
"../src/recorder"
);
resetActiveSessions();
const client = { user: { id: "self-user" } };
await startRecording(client as never, createChannel() as never);
stopRecording("guild");
expect(mocks.mockDestroy).toHaveBeenCalled();
});
it("finalizes the active recording session", async () => {
const { startRecording, stopRecording, resetActiveSessions } = await import(
"../src/recorder"
);
resetActiveSessions();
const client = { user: { id: "self-user" } };
await startRecording(client as never, createChannel() as never);
stopRecording("guild");
await flushMicrotasks();
expect(mocks.mockMkdirSync).toHaveBeenCalled();
expect(mocks.mockWriteFileSync).toHaveBeenCalled();
});
});