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:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user