refactor: remove voice, recording, and music/media features (gateway + backend)

- Gateway: delete voice-recording/, voice-pcm-ws/, voice/video/media handlers,
  vendor/discord-voice-fork/, voice DB repos, 2 voice migrations
- Gateway: cut voice wiring from bootstrap/shutdown/commandHandler/handler-registry,
  eventBroadcaster/eventTypes, redis-channels, moderation-types, message-capture,
  config keys, and deps (@discordjs/voice, opus, libsodium, prism-media, davey)
- Backend: delete voice/, recordings/, media/ modules + @discordjs/voice dep
- Backend: cut voice/media/recordings oRPC routers, WS gateway-PCM auth + voice
  handlers, Redis bridge voice aggregation, redis-channels voice/media constants,
  config keys, moderation-types voice items, e2e voice/recordings suites
- Keep voice DB tables (destructive migration avoided); chatbot voiceRecordings
  tool + dashboard count remain as read-only historical data access
This commit is contained in:
asepharyana committed 2026-09-23 19:31:06 +07:00
1 parent b5b370e6eb
commit 33013697e0
79 files changed
+27 -20185

No files matched your search

@@ -1,133 +0,0 @@
// ═══════════════════════════════════════════════════════════════════════════════
// resolveMediaUrl (music playback) resolution tests
//
// Guards the yt-dlp stdout-vs-stderr contract that broke music playback:
// - with `-o -` yt-dlp streams media on STDOUT and emits `--print`
// title/duration headers on STDERR
// - resolveMediaUrl must read headers from stderr and return the stdout
// media stream UNTOUCHED (the old code stripped binary "lines" from the
// WebM container, corrupting it → silent playback)
//
// yt-dlp is faked via a PATH shim; no network or real binaries needed.
// ═══════════════════════════════════════════════════════════════════════════════
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveMediaUrl } from "../src/modules/voice-recording/mediaSource.js";
// ─── fake bin dir ──────────────────────────────────────────────────────────────
let fakeBinDir: string | null = null;
const realPath = process.env.PATH;
/** WebM-ish payload: EBML magic + a few bytes that must survive untouched. */
const MEDIA_PAYLOAD = Buffer.concat([
Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f]), // EBML magic
Buffer.from("fake-webm-cluster-data\nwith-newlines\n", "utf8"),
Buffer.from([0x00, 0x11, 0x22, 0x33, 0x0a, 0xff, 0xee]),
]);
beforeAll(() => {
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-media-resolve-"));
// Fake yt-dlp: reads GMW_FAKE_STDERR (what to write on stderr) and
// GMW_FAKE_STDOUT (what to write on stdout, default = MEDIA_PAYLOAD).
// Written with string concat so bash `${...}` isn't TS-interpolated.
const ytShim =
"#!/usr/bin/env bash\n" +
'if [ -f "$GMW_FAKE_STDERR" ]; then cat "$GMW_FAKE_STDERR" >&2; fi\n' +
'if [ -f "$GMW_FAKE_STDOUT" ]; then cat "$GMW_FAKE_STDOUT"; fi\n' +
"exit " +
(process.env.GMW_FAKE_EXIT ?? "0") +
"\n";
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
});
afterAll(() => {
if (fakeBinDir) {
rmSync(fakeBinDir, { recursive: true, force: true });
}
process.env.PATH = realPath;
});
// ─── helpers ───────────────────────────────────────────────────────────────────
function writeTemp(name: string, content: Buffer | string): string {
const p = join(
tmpdir(),
`gmw-resolve-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`,
);
writeFileSync(p, content);
return p;
}
function collect(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (c: Buffer) => chunks.push(c));
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
// ─── tests ─────────────────────────────────────────────────────────────────────
describe("resolveMediaUrl", () => {
it("reads title+duration from STDERR and returns the untouched stdout media", async () => {
const stderrPath = writeTemp("stderr", "【Test】Song Title\n184.5\n");
const stdoutPath = writeTemp("stdout", MEDIA_PAYLOAD);
process.env.GMW_FAKE_STDERR = stderrPath;
process.env.GMW_FAKE_STDOUT = stdoutPath;
process.env.GMW_FAKE_EXIT = "0";
const resolution = await resolveMediaUrl("https://youtu.be/abc");
expect(resolution.title).toBe("【Test】Song Title");
expect(resolution.duration).toBeCloseTo(184.5, 1);
const bytes = await collect(resolution.stream);
expect(bytes).toEqual(MEDIA_PAYLOAD); // byte-for-byte intact
});
it("skips [info] prefix lines before the title header", async () => {
const stderrPath = writeTemp(
"stderr",
"[info] Downloading webpage\nTitle After Info\n123\n",
);
const stdoutPath = writeTemp("stdout", MEDIA_PAYLOAD);
process.env.GMW_FAKE_STDERR = stderrPath;
process.env.GMW_FAKE_STDOUT = stdoutPath;
process.env.GMW_FAKE_EXIT = "0";
const resolution = await resolveMediaUrl("https://youtu.be/abc");
expect(resolution.title).toBe("Title After Info");
expect(resolution.duration).toBe(123);
});
it("rejects when yt-dlp reports an ERROR on stderr", async () => {
const stderrPath = writeTemp(
"stderr",
"ERROR: [youtube] xyz: This video is unavailable\n",
);
process.env.GMW_FAKE_STDERR = stderrPath;
delete process.env.GMW_FAKE_STDOUT;
process.env.GMW_FAKE_EXIT = "1";
await expect(resolveMediaUrl("https://youtu.be/abc")).rejects.toThrow(
/This video is unavailable/,
);
});
it("rejects when yt-dlp is not installed", async () => {
const oldPath = process.env.PATH;
process.env.PATH = "/usr/bin:/bin"; // no fake yt-dlp
await expect(resolveMediaUrl("https://youtu.be/abc")).rejects.toThrow(
/yt-dlp is not installed/,
);
process.env.PATH = oldPath;
});
});
@@ -1,446 +0,0 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError Hierarchy
// ═══════════════════════════════════════════════════════════════════════════════
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AppError,
ConfigError,
DatabaseError,
NotFoundError,
UnauthorizedError,
ValidationError,
} from "../src/shared/errors/index.js";
describe("AppError subclasses", () => {
it("AppError carries code, statusCode, and details", () => {
const err = new AppError("err", "X", 500, { info: "test" });
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("X");
expect(err.statusCode).toBe(500);
expect(err.details).toEqual({ info: "test" });
});
it("NotFoundError sets 404 status", () => {
expect(new NotFoundError("R").statusCode).toBe(404);
expect(new NotFoundError("R").code).toBe("NOT_FOUND");
});
it("ValidationError sets 400 status", () => {
expect(new ValidationError("V").statusCode).toBe(400);
expect(new ValidationError("V").code).toBe("VALIDATION_ERROR");
});
it("UnauthorizedError sets 401 status", () => {
expect(new UnauthorizedError().statusCode).toBe(401);
});
it("DatabaseError sets 500 status", () => {
expect(new DatabaseError("X").statusCode).toBe(500);
});
it("ConfigError sets 500 status", () => {
expect(new ConfigError("X").statusCode).toBe(500);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 2. Shared Utilities
// ═══════════════════════════════════════════════════════════════════════════════
import {
decodeCursor,
delay,
encodeCursor,
pageResult,
retryWithBackoff,
} from "../src/shared/utils/index.js";
describe("delay", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves after specified time with fake timers", async () => {
vi.useFakeTimers();
const p = delay(250);
vi.advanceTimersByTime(250);
await expect(p).resolves.toBeUndefined();
});
});
describe("retryWithBackoff", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves on first attempt", async () => {
const fn = vi.fn().mockResolvedValue(42);
await expect(retryWithBackoff(fn)).resolves.toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
it("throws after retries are exhausted", async () => {
const fn = vi.fn().mockRejectedValue(new Error("fail"));
await expect(
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
).rejects.toThrow("fail");
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("rejects immediately when already aborted", async () => {
const ac = new AbortController();
ac.abort();
await expect(
retryWithBackoff(() => Promise.resolve("ok"), { signal: ac.signal }),
).rejects.toThrow("Aborted");
});
});
describe("pagination utils", () => {
it("encode/decode round-trips correctly", () => {
const data = { created_at: 999, id: "id-1" };
expect(decodeCursor(encodeCursor(data))).toEqual(data);
});
it("decodeCursor rejects invalid input with null", () => {
expect(decodeCursor()).toBeNull();
expect(decodeCursor("")).toBeNull();
expect(decodeCursor("!!!")).toBeNull();
});
it("pageResult handles hasMore and no-more cases", () => {
const r1 = pageResult([{ id: "a", created_at: 1 }], 5);
expect(r1.nextCursor).toBeNull();
const r2 = pageResult(
[
{ id: "a", created_at: 1 },
{ id: "b", created_at: 2 },
],
1,
);
expect(r2.data).toHaveLength(1);
expect(r2.nextCursor).toBeTruthy();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 3. Redis Channel Constants
// ═══════════════════════════════════════════════════════════════════════════════
import {
BACKEND_COMMAND,
COMMAND_GUILDS_LIST,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
COMMAND_MODERATION_ACTION,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
DISCORD_ANALYSIS_QUEUE_STATUS,
DISCORD_ATTACHMENT_CREATED,
DISCORD_MESSAGE_ANALYZED,
DISCORD_MESSAGE_CREATED,
DISCORD_MESSAGE_DELETED,
DISCORD_MESSAGE_UPDATED,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STARTED,
MEDIA_STATUS_KEY,
VOICE_STATUS_KEY,
} from "../src/shared/redis-channels.js";
describe("Redis channel constants", () => {
it("define event channel names", () => {
expect(DISCORD_MESSAGE_CREATED).toBe("discord:message:created");
expect(DISCORD_MESSAGE_UPDATED).toBe("discord:message:updated");
expect(DISCORD_MESSAGE_DELETED).toBe("discord:message:deleted");
expect(DISCORD_MESSAGE_ANALYZED).toBe("discord:message:analyzed");
expect(DISCORD_ATTACHMENT_CREATED).toBe("discord:attachment:created");
expect(DISCORD_VOICE_STARTED).toBe("discord:voice:started");
expect(DISCORD_VOICE_PCM).toBe("discord:voice:pcm");
expect(DISCORD_ANALYSIS_QUEUE_STATUS).toBe("discord:analysis:queue_status");
});
it("define command channel and status keys", () => {
expect(BACKEND_COMMAND).toBe("backend:command");
expect(VOICE_STATUS_KEY).toBe("voice:status");
expect(MEDIA_STATUS_KEY).toBe("media:status");
});
it("define command type constants", () => {
expect(COMMAND_VOICE_CONNECT).toBe("voice:connect");
expect(COMMAND_VOICE_DISCONNECT).toBe("voice:disconnect");
expect(COMMAND_GUILDS_LIST).toBe("guilds:list");
expect(COMMAND_MEDIA_QUEUE).toBe("media:queue");
expect(COMMAND_MEDIA_SKIP).toBe("media:skip");
expect(COMMAND_MEDIA_STOP).toBe("media:stop");
expect(COMMAND_MEDIA_VOLUME).toBe("media:volume");
expect(COMMAND_MODERATION_ACTION).toBe("moderation:action");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 4. Config Validation
// ═══════════════════════════════════════════════════════════════════════════════
vi.hoisted(() => {
process.env.DISCORD_TOKEN = "test-discord-token-for-tests";
process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test";
});
import { configSchema, loadConfig } from "../src/shared/config/index.js";
describe("Config validation", () => {
it("loadConfig succeeds with minimal valid env", () => {
const cfg = loadConfig({
DISCORD_TOKEN: "abc",
DATABASE_URL: "postgres://localhost/db",
});
expect(cfg.DISCORD_TOKEN).toBe("abc");
// Defaults
expect(cfg.RECORDINGS_DIR).toBe("./recordings");
expect(cfg.RECORDING_SEGMENT_MS).toBe(5000);
expect(cfg.NODE_ENV).toBe("development");
expect(cfg.WEBSERVER_PORT).toBe(3001);
expect(cfg.OPUS_FRAME_SIZE).toBe(960);
expect(cfg.AUDIO_SAMPLE_RATE).toBe(48000);
expect(cfg.LOG_LEVEL).toBe("info");
});
it("loadConfig throws ConfigError when DISCORD_TOKEN is missing", () => {
expect(() => loadConfig({})).toThrow(ConfigError);
});
it("schema parses boolean-string transforms correctly", () => {
const result = configSchema.parse({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
VERBOSE: "true",
AI_ANALYSIS_ENABLED: "true",
AI_LLM_API_KEY: "sk-test",
});
expect(result.AI_ANALYSIS_ENABLED).toBe(true);
});
it("schema provides sensible default for NODE_ENV", () => {
const result = configSchema.parse({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
});
expect(result.NODE_ENV).toBe("development");
});
it("gateway loadConfig adds EFFECTIVE_TEXT_GUILD_ID from MONITOR_GUILD_ID", async () => {
const { loadConfig: gwLoadConfig } = await import(
"../src/shared/config/config.js"
);
const cfg = gwLoadConfig({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
MONITOR_GUILD_ID: "guild-1",
});
expect(cfg.EFFECTIVE_TEXT_GUILD_ID).toBe("guild-1");
});
it("gateway loadConfig prefers TEXT_GUILD_ID over MONITOR_GUILD_ID", async () => {
const { loadConfig: gwLoadConfig } = await import(
"../src/shared/config/config.js"
);
const cfg = gwLoadConfig({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
TEXT_GUILD_ID: "text-guild",
MONITOR_GUILD_ID: "monitor-guild",
});
expect(cfg.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 5. Pure function modules
// ═══════════════════════════════════════════════════════════════════════════════
import { sniffImageMimeType } from "../src/modules/ai-moderation/mediaAnalysisClient.js";
describe("sniffImageMimeType", () => {
function buf(...bytes: number[]): Buffer {
const b = Buffer.alloc(12);
for (let i = 0; i < bytes.length; i++) b[i] = bytes[i];
return b;
}
it("detects JPEG", () => {
expect(sniffImageMimeType(buf(0xff, 0xd8, 0xff))).toBe("image/jpeg");
});
it("detects PNG", () => {
expect(
sniffImageMimeType(buf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)),
).toBe("image/png");
});
it("detects GIF", () => {
expect(sniffImageMimeType(buf(0x47, 0x49, 0x46, 0x38))).toBe("image/gif");
});
it("detects WebP", () => {
expect(
sniffImageMimeType(
buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50),
),
).toBe("image/webp");
});
it("detects AVIF", () => {
// ftyp box with avif brand at bytes 8-11
expect(
sniffImageMimeType(
buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66),
),
).toBe("image/avif");
});
it("detects HEIC", () => {
// ftyp box with heic brand at bytes 8-11
expect(
sniffImageMimeType(
buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63),
),
).toBe("image/heic");
});
it("returns null for short buffer (< 12 bytes)", () => {
expect(sniffImageMimeType(Buffer.alloc(3))).toBeNull();
});
it("returns null for unrecognised data", () => {
expect(sniffImageMimeType(Buffer.alloc(12))).toBeNull();
});
});
import {
clampScore,
deriveRecommendedAction,
deriveSeverity,
hasDeferralAnalysis,
} from "../src/modules/ai-moderation/moderationResponseParser.js";
describe("severityDeriver", () => {
describe("clampScore", () => {
it("clamps values between 0 and 1", () => {
expect(clampScore(-0.5)).toBe(0);
expect(clampScore(0.5)).toBe(0.5);
expect(clampScore(1.5)).toBe(1);
});
it("handles undefined and NaN with fallback", () => {
expect(clampScore(undefined)).toBe(0);
expect(clampScore(NaN)).toBe(0);
});
it("allows custom fallback", () => {
expect(clampScore(undefined, 0.5)).toBe(0.5);
});
});
describe("deriveSeverity", () => {
it("returns none for clean status", () => {
expect(deriveSeverity("clean", 0)).toBe("none");
});
it("returns low for warn with low score", () => {
expect(deriveSeverity("warn", 0.5)).toBe("low");
});
it("returns medium for warn with score >= 0.65", () => {
expect(deriveSeverity("warn", 0.65)).toBe("medium");
});
it("returns critical for flagged with score >= 0.9", () => {
expect(deriveSeverity("flagged", 0.9)).toBe("critical");
});
it("returns high for flagged with score >= 0.75", () => {
expect(deriveSeverity("flagged", 0.75)).toBe("high");
});
it("returns medium for flagged with score < 0.75", () => {
expect(deriveSeverity("flagged", 0.5)).toBe("medium");
});
});
describe("deriveRecommendedAction", () => {
it("returns none for clean status", () => {
expect(deriveRecommendedAction("clean", "none")).toBe("none");
});
it("returns review for warn with medium severity", () => {
expect(deriveRecommendedAction("warn", "medium")).toBe("review");
});
it("returns warn for warn with low severity", () => {
expect(deriveRecommendedAction("warn", "low")).toBe("warn");
});
});
describe("hasDeferralAnalysis", () => {
it("detects Indonesian deferral: kurang konteks", () => {
expect(hasDeferralAnalysis("kurang konteks untuk menilai")).toBe(true);
});
it("detects English deferral: insufficient context", () => {
expect(hasDeferralAnalysis("insufficient context to moderate")).toBe(
true,
);
});
it("detects cannot determine pattern", () => {
expect(hasDeferralAnalysis("cannot determine")).toBe(true);
});
it("returns false for non-deferral text", () => {
expect(hasDeferralAnalysis("This message is perfectly clean")).toBe(
false,
);
});
it("returns false for exception pattern (decisive verdict)", () => {
expect(
hasDeferralAnalysis(
"tidak bisa menentukan karena tidak ada pelanggaran",
),
).toBe(false);
});
});
});
import { extractJson } from "../src/modules/ai-moderation/moderationResponseParser.js";
describe("extractJson", () => {
it("extracts from plain JSON string", () => {
expect(extractJson('{"a":1}')).toEqual({ a: 1 });
});
it("extracts from JSON inside markdown code block", () => {
const input = '```json\n{"key": "value"}\n```';
expect(extractJson(input)).toEqual({ key: "value" });
});
it("extracts from JSON inside unlabeled code block", () => {
const input = '```\n{"nested": {"x": 42}}\n```';
expect(extractJson(input)).toEqual({ nested: { x: 42 } });
});
it("extracts JSON from surrounding text", () => {
const input = 'Here is the result: {"status": "ok"} end.';
expect(extractJson(input)).toEqual({ status: "ok" });
});
it("throws an error when no JSON is found", () => {
expect(() => extractJson("this has no json at all")).toThrow(
"No JSON object found",
);
});
it("throws on empty string", () => {
expect(() => extractJson("")).toThrow("No JSON object found");
});
});
@@ -1,147 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VideoHandler } from "../src/modules/command-handler/video.handler.js";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
// ─── mocks ─────────────────────────────────────────────────────────────
function makeClient({ botId = "bot1", channelType = "GUILD_VOICE" } = {}) {
const activeChannel = {
id: "c1",
name: "Lounge",
type: channelType,
members: { has: vi.fn(() => false) },
};
const guild = {
id: "g1",
channels: {
cache: new Map([["c1", activeChannel]]),
fetch: vi.fn().mockResolvedValue(new Map()),
},
};
return {
user: { id: botId },
guilds: {
cache: new Map([["g1", guild]]),
fetch: vi.fn().mockResolvedValue(guild),
},
};
}
function makeVoiceController(activeChannelId = "c1") {
return {
getStatus: vi.fn().mockReturnValue({ activeChannelId, connected: true }),
};
}
function makeCmd(type: string, payload: Record<string, unknown>) {
return { id: "req-1", type, payload, replyChannel: "ch:1" };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(streamWatch, "startStreamWatch").mockResolvedValue();
vi.spyOn(streamWatch, "stopStreamWatch").mockImplementation(() => {});
});
describe("VideoHandler", () => {
it("rejects when gateway not initialized", async () => {
const handler = new VideoHandler(null, null);
const reply = await handler.handleVideoWatch(makeCmd("video:watch", {}));
expect(reply.success).toBe(false);
expect(reply.error).toContain("not initialized");
});
it("rejects when guildId/userId missing", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("guildId and userId are required");
});
it("rejects watching the selfbot's own stream", async () => {
const handler = new VideoHandler(
makeClient({ botId: "bot1" }),
makeVoiceController(),
);
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "bot1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("own stream");
});
it("returns error when no active voice channel found", async () => {
// Freeze the cache so no channel resolves.
const client = makeClient();
client.guilds.cache.get("g1").channels.cache.clear();
const handler = new VideoHandler(client, makeVoiceController());
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("No active voice channel");
});
it("calls startStreamWatch with resolved channel + userId and replies success", async () => {
const client = makeClient();
const handler = new VideoHandler(client, makeVoiceController("c1"));
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(true);
expect(reply.data).toMatchObject({
status: "requested",
guildId: "g1",
channelId: "c1",
userId: "user-x",
});
expect(streamWatch.startStreamWatch).toHaveBeenCalledWith(
expect.objectContaining({ id: "c1" }),
"user-x",
);
});
it("prefers an explicit channelId over the active one", async () => {
const client = makeClient();
// Add a second voice channel.
const guild = client.guilds.cache.get("g1");
guild.channels.cache.set("c9", {
id: "c9",
name: "Other",
type: "GUILD_VOICE",
members: { has: vi.fn(() => false) },
});
const handler = new VideoHandler(client, makeVoiceController("c1"));
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", {
guildId: "g1",
userId: "user-x",
channelId: "c9",
}),
);
expect(reply.success).toBe(true);
expect(streamWatch.startStreamWatch).toHaveBeenCalledWith(
expect.objectContaining({ id: "c9" }),
"user-x",
);
});
it("handleVideoUnwatch calls stopStreamWatch", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoUnwatch(
makeCmd("video:unwatch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(true);
expect(streamWatch.stopStreamWatch).toHaveBeenCalledWith("g1", "user-x");
});
it("handleVideoUnwatch rejects missing args", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoUnwatch(
makeCmd("video:unwatch", { guildId: "g1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("guildId and userId are required");
});
});
@@ -1,166 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { access, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { SSRCMap } from "@discordjs/voice";
import { describe, expect, it } from "vitest";
import {
H264Depacketizer,
muxToMp4,
} from "../src/modules/voice-recording/videoReceiver.js";
// Build a single-NAL RTP payload: [NAL header, ...data]
function singleNal(nalHeader: number, data: number[] = []): Buffer {
return Buffer.from([nalHeader, ...data]);
}
describe("H264Depacketizer", () => {
it("prefixes a single NAL with an AnnexB start code once config seen", () => {
const d = new H264Depacketizer();
// SPS first (0x67 = nal ref idc 3, type 7)
const sps = d.push(singleNal(0x67, [1, 2, 3]));
expect(sps.length).toBe(1);
expect(sps[0].subarray(0, 4)).toEqual(Buffer.from([0, 0, 0, 1]));
expect(sps[0][4]).toBe(0x67); // start code + original NAL
});
it("drops NALs before seeing SPS/PPS/IDR (waits for a keyframe)", () => {
const d = new H264Depacketizer();
// A non-IDR slice (type 1) before any config → dropped
const res = d.push(singleNal(0x21, [9, 9, 9]));
expect(res.length).toBe(0);
});
it("emits frames after config", () => {
const d = new H264Depacketizer();
d.push(singleNal(0x67, [])); // SPS
const slice = d.push(singleNal(0x65, [1, 2, 3, 4])); // IDR (type 5)
expect(slice.length).toBe(1);
expect(slice[0].equals(Buffer.from([0, 0, 0, 1, 0x65, 1, 2, 3, 4]))).toBe(
true,
);
});
it("reassembles FU-A fragments into one NAL", () => {
const d = new H264Depacketizer();
d.push(singleNal(0x67, [])); // SPS config
// FU-A: payload[0]=FU indicator (0x7C=type28), payload[1]=FU header
// start fragment: S=1 E=0, NAL type 5 => 0x85 ; data [10, 11]
const start = Buffer.from([0x7c, 0x85, 10, 11]);
const middle = Buffer.from([0x7c, 0x05, 12, 13]);
const end = Buffer.from([0x7c, 0x45, 14, 15]); // E=1
expect(d.push(start).length).toBe(0); // not complete yet
expect(d.push(middle).length).toBe(0);
const final = d.push(end);
expect(final.length).toBe(1);
// NAL header reconstructed as (0x7C & 0xE0) | 5 = 0x65, then data 10..15
expect(
final[0].equals(Buffer.from([0, 0, 0, 1, 0x65, 10, 11, 12, 13, 14, 15])),
).toBe(true);
});
it("first fragment of a burst with unknown start is dropped gracefully", () => {
const d = new H264Depacketizer();
d.push(singleNal(0x67, [])); // SPS
// A continuation fragment with no buffer → ignored, no crash
const orphan = Buffer.from([0x7c, 0x05, 99]); // S=0
expect(d.push(orphan).length).toBe(0);
});
});
describe("muxToMp4", () => {
it("remuxes a raw h264 file into a playable mp4 and deletes the raw file", async () => {
// Skip if ffmpeg is unavailable (headless Nix-less CI).
let ffmpegOk = true;
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
} catch {
ffmpegOk = false;
}
if (!ffmpegOk) {
return;
}
const dir = mkdtempSync(path.join(tmpdir(), "gmw-video-"));
const raw = path.join(dir, "clip.h264");
try {
execFileSync(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=320x240:rate=10",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-profile:v",
"baseline",
"-pix_fmt",
"yuv420p",
"-f",
"h264",
"-y",
raw,
],
{ stdio: "ignore" },
);
const mp4 = await muxToMp4(raw);
expect(mp4).toBe(path.join(dir, "clip.mp4"));
// raw file deleted, mp4 exists and is non-empty
await expect(access(raw)).rejects.toThrow();
const s = await stat(mp4);
expect(s.size).toBeGreaterThan(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ─── Fork @discordjs/voice patch tests (video receive support) ─────────────
// These assert the behavior added in vendor/discord-voice-fork:
// 1. SSRCMap.update() accepts videoSSRC and get() resolves video SSRCs
// 2. The op-12 Speaking handler maps streams[].ssrc → videoSSRC
describe("discord-voice-fork videoSSRC", () => {
it("registers a videoSSRC and get() resolves it from the video SSRC", () => {
const map = new SSRCMap();
// Simulates what the fork's onWsPacket(op 12) does:
// ssrcMap.update({ userId, audioSSRC, videoSSRC })
map.update({ userId: "user-1", audioSSRC: 1001, videoSSRC: 2001 });
// Audio path unchanged: get by audioSSRC
expect(map.get(1001)?.userId).toBe("user-1");
// Video path (fork): get by videoSSRC resolves to the same user
expect(map.get(2001)?.userId).toBe("user-1");
expect(map.get(2001)?.videoSSRC).toBe(2001);
});
it("emits create event with videoSSRC for videoReceiver attribution", () => {
const map = new SSRCMap();
const seen: Array<{ userId: string; videoSSRC?: number }> = [];
map.on("create", (data) => seen.push(data));
map.update({ userId: "user-2", audioSSRC: 1002, videoSSRC: 2002 });
expect(seen).toHaveLength(1);
expect(seen[0].videoSSRC).toBe(2002);
// videoReceiver.ts hooks this event to build videoSsrcToUser
expect(seen[0].userId).toBe("user-2");
});
it("keeps audio-only behavior when no videoSSRC present", () => {
const map = new SSRCMap();
map.update({ userId: "user-3", audioSSRC: 1003 });
expect(map.get(1003)?.userId).toBe("user-3");
// No videoSSRC → get by a random SSRC returns undefined (no false match)
expect(map.get(2003)).toBeUndefined();
});
});
@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
import {
__resetVideoRecorderState,
setVideoRecorderClient,
setVideoRecordingsDir,
startVideoRecording,
stopVideoRecording,
trackChannel,
untrackChannel,
} from "../src/modules/voice-recording/videoRecorder.js";
// ─── mocks ─────────────────────────────────────────────────────────────
function makeChannel(guildId = "g1", channelId = "c1") {
return {
id: channelId,
guild: {
id: guildId,
members: { fetch: vi.fn().mockResolvedValue(new Map()) },
},
};
}
function makeClient() {
return {
user: { id: "bot1" },
on: vi.fn(),
};
}
let seq = 0;
function makeUser() {
seq += 1;
return `user-${seq}`;
}
beforeEach(() => {
__resetVideoRecorderState();
vi.restoreAllMocks();
setVideoRecordingsDir("/tmp/gmw-vidrec-test");
});
describe("videoRecorder (stream-watch orchestration)", () => {
it("registers a voiceStateUpdate + raw listener on the client (idempotent)", () => {
const client = makeClient();
setVideoRecorderClient(client);
setVideoRecorderClient(client); // idempotent (videoRecorder + streamWatch)
// one voiceStateUpdate (videoRecorder) + raw GUILD_CREATE (videoRecorder)
// + raw STREAM_* (streamWatch) = 3
expect(client.on).toHaveBeenCalledTimes(3);
expect(client.on).toHaveBeenCalledWith(
"voiceStateUpdate",
expect.any(Function),
);
expect(client.on).toHaveBeenCalledWith("raw", expect.any(Function));
});
it("startVideoRecording delegates to streamWatchReceiver.startStreamWatch", () => {
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = makeChannel();
startVideoRecording(ch, makeUser());
expect(spy).toHaveBeenCalledWith(ch, expect.any(String));
});
it("refuses to record the bot's own video", () => {
setVideoRecorderClient(makeClient());
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
startVideoRecording(makeChannel(), "bot1");
expect(spy).not.toHaveBeenCalled();
});
it("stopVideoRecording calls streamWatchReceiver.stopStreamWatch", () => {
const spy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const u = makeUser();
stopVideoRecording("g1", u);
expect(spy).toHaveBeenCalledWith("g1", u);
});
it("untrackChannel tears down all stream watches for the guild", () => {
const spy = vi
.spyOn(streamWatch, "stopAllStreamWatches")
.mockImplementation(() => {});
untrackChannel("g1");
expect(spy).toHaveBeenCalledWith("g1");
});
it("the voiceStateUpdate handler starts a watch when a member starts streaming", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
trackChannel("g1", makeChannel());
const startSpy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1];
listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: true,
});
expect(startSpy).toHaveBeenCalled();
});
it("the voiceStateUpdate handler stops a watch when a member stops streaming", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
trackChannel("g1", makeChannel());
const stopSpy = vi
.spyOn(streamWatch, "stopStreamWatch")
.mockImplementation(() => {});
const listener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "voiceStateUpdate",
)?.[1];
listener(null, {
id: "u1",
guild: { id: "g1" },
channelId: "c1",
streaming: false,
});
expect(stopSpy).toHaveBeenCalledWith("g1", "u1");
});
it("trackChannel scans pre-existing streamers already in the channel (bot join case)", () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = {
id: "c1",
guild: { id: "g1" },
members: new Map([
["u1", { id: "u1", voice: { streaming: true } }], // already streaming
["u2", { id: "u2", voice: { streaming: false } }], // not streaming
["bot1", { id: "bot1", voice: { streaming: true } }], // bot itself
]),
};
trackChannel("g1", ch as any);
// only the already-streaming non-bot member gets watched
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(ch, "u1");
});
it("trackChannel is a no-op when the channel has no members (or none streaming)", () => {
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
trackChannel("g1", makeChannel()); // no members
expect(spy).not.toHaveBeenCalled();
});
it("trackChannel falls back to REST guild.members.fetch() when cache is empty (cold start bug fix)", async () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
// Warmable guild: members.fetch() populates channel.members with a
// camera-on member (selfVideo) — the cold-start scenario where the bot
// joins a channel that already has someone on camera but the member cache
// hasn't been populated yet.
const cameraMember = {
id: "u-cam",
voice: { streaming: false, selfVideo: true },
};
const ch = {
id: "c1",
guild: {
id: "g1",
members: {
fetch: vi.fn().mockImplementation(async () => {
// Simulate REST fetch populating the channel member cache.
(ch as any).members = new Map([["u-cam", cameraMember]]);
return new Map([["u-cam", cameraMember]]);
}),
},
},
members: new Map(), // empty at join time — cold cache
};
trackChannel("g1", ch as any);
// Allow the async scanExistingStreamers (fire-and-forget) to complete.
await vi.waitFor(() => {
expect(spy).toHaveBeenCalledWith(ch as any, "u-cam");
});
});
it("detects pre-existing camera user from buffered GUILD_CREATE.voice_states (selfbot fix)", async () => {
const client = makeClient() as any;
setVideoRecorderClient(client);
const spy = vi
.spyOn(streamWatch, "startStreamWatch")
.mockImplementation(() => {});
const ch = {
id: "c1",
guild: {
id: "g1",
members: { fetch: vi.fn().mockResolvedValue(new Map()) },
},
members: new Map(), // empty — library drops voice_states
};
// Simulate GUILD_CREATE arriving at WS connect (after setVideoRecorderClient
// registered the raw listener, before trackChannel runs).
const rawListener = client.on.mock.calls.find(
(c: unknown[]) => c[0] === "raw",
)?.[1];
expect(rawListener).toBeDefined();
rawListener({
t: "GUILD_CREATE",
d: {
id: "g1",
voice_states: [
{
user_id: "u-cam",
channel_id: "c1",
self_video: true,
self_stream: false,
},
{
user_id: "u-share",
channel_id: "c1",
self_video: false,
self_stream: true,
},
{
user_id: "u-novideo",
channel_id: "c1",
self_video: false,
self_stream: false,
},
{
user_id: "bot1",
channel_id: "c1",
self_video: true,
self_stream: false,
}, // self — skip
{
user_id: "u-otherchan",
channel_id: "c9",
self_video: true,
self_stream: false,
}, // different channel — skip
],
},
});
// Now track the channel — scanExistingStreamers consumes buffered states.
trackChannel("g1", ch as any);
await vi.waitFor(() => {
// camera user + screen-share user watched, self + no-video + other-channel skipped
expect(spy).toHaveBeenCalledWith(ch as any, "u-cam");
expect(spy).toHaveBeenCalledWith(ch as any, "u-share");
});
expect(spy).not.toHaveBeenCalledWith(ch as any, "u-novideo");
expect(spy).not.toHaveBeenCalledWith(ch as any, "bot1");
expect(spy).not.toHaveBeenCalledWith(ch as any, "u-otherchan");
});
});