feat(core): implement data retention, metrics, and enhanced media handling

This commit introduces several significant improvements across the backend and gateway services:

- **Data Retention**: Added an automated cleanup scheduler in `discord-gateway` to prune expired messages, attachments, and voice recordings based on configurable retention policies.
- **Observability**: Integrated `prom-client` in the `backend` service to expose Prometheus metrics via `/api/metrics` and added default Node.js runtime metrics.
- **Media Handling**: Enhanced `MediaHandler` in `discord-gateway` to support media URL resolution and improved playback status tracking.
- **API & Config**: Expanded the configuration endpoint to expose more system settings and reorganized `.env.example` for better readability.
- **Refactoring & Cleanup**:
    - Removed unused `better-sqlite3` dependency.
    - Refactored voice channel routing.
    - Improved error handling and testing coverage with comprehensive unit tests for shared utilities and error classes.
- **Documentation**: Added `MEMORY.md` for project context.
This commit is contained in:
MythEclipse
2026-06-10 20:56:16 +07:00
parent f04b0f0b42
commit 2557a07916
18 changed files with 1537 additions and 249 deletions
@@ -1,6 +1,8 @@
import { ConfigError, DatabaseError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
import {
@@ -11,19 +13,174 @@ import {
registerMessageCapture,
setEventBroadcaster as setMessageCaptureEventBroadcaster,
} from "../modules/message-capture/messageCapture.js";
import { getExpiredMessages } from "../modules/message-capture/messageStore.js";
import { setEventBroadcaster as setRecorderEventBroadcaster } from "../modules/voice-recording/recorder.js";
import { VoiceController } from "../modules/voice-recording/voiceController.js";
import { config } from "../shared/config/config.js";
import {
closeDatabase,
getDatabase,
initializeDatabase,
} from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway");
// ─── Retention Cleanup ─────────────────────────────────────────────────────
function startRetentionCleanup(): void {
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
const dryRun = config.RETENTION_DRY_RUN;
logger.info(
{
intervalMs,
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
async function runCleanupTick(): Promise<void> {
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
// ── Expired messages ────────────────────────────────────────────────
if (config.RETENTION_MESSAGES_DAYS > 0) {
try {
const expiredMessages = await getExpiredMessages(
config.RETENTION_MESSAGES_DAYS,
);
if (expiredMessages.length > 0) {
const ids = expiredMessages.map((m: { id: string }) => m.id);
logger.info(
{ count: ids.length, dryRun },
"Expired messages found for cleanup",
);
if (!dryRun) {
await db
.delete(messagesTable)
.where(inArray(messagesTable.id, ids));
logger.info({ count: ids.length }, "Expired messages deleted");
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired messages",
);
}
}
// ── Expired attachments ─────────────────────────────────────────────
if (config.RETENTION_ATTACHMENTS_DAYS > 0) {
try {
const cutoff =
Date.now() - config.RETENTION_ATTACHMENTS_DAYS * 24 * 60 * 60 * 1000;
const expiredAttachments = await db
.select({ id: attachmentsTable.id })
.from(attachmentsTable)
.where(lt(attachmentsTable.created_at, cutoff))
.limit(1000);
if (expiredAttachments.length > 0) {
const ids = expiredAttachments.map((a: { id: string }) => a.id);
logger.info(
{ count: ids.length, dryRun },
"Expired attachments found for cleanup",
);
if (!dryRun) {
await db
.delete(attachmentsTable)
.where(inArray(attachmentsTable.id, ids));
logger.info({ count: ids.length }, "Expired attachments deleted");
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired attachments",
);
}
}
// ── Expired voice recordings ────────────────────────────────────────
if (config.RETENTION_VOICE_DAYS > 0) {
try {
const cutoff =
Date.now() - config.RETENTION_VOICE_DAYS * 24 * 60 * 60 * 1000;
const expiredRecordings = await db
.select({ id: voiceRecordingsTable.id })
.from(voiceRecordingsTable)
.where(lt(voiceRecordingsTable.created_at, cutoff))
.limit(1000);
if (expiredRecordings.length > 0) {
const ids = expiredRecordings.map((r: { id: string }) => r.id);
logger.info(
{ count: ids.length, dryRun },
"Expired voice recordings found for cleanup",
);
if (!dryRun) {
await db
.delete(voiceRecordingsTable)
.where(inArray(voiceRecordingsTable.id, ids));
logger.info(
{ count: ids.length },
"Expired voice recordings deleted",
);
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired voice recordings",
);
}
}
}
// Run immediately on start, then schedule
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Initial retention cleanup tick failed",
);
});
setInterval(() => {
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Retention cleanup tick failed",
);
});
}, intervalMs);
}
// ─── Bootstrap ─────────────────────────────────────────────────────────────
export async function initializeDiscordGateway() {
if (config.AI_ANALYSIS_ENABLED && !config.AI_LLM_API_KEY) {
throw new ConfigError(
@@ -98,6 +255,9 @@ export async function initializeDiscordGateway() {
// Start command handler after Discord is ready
commandHandler.start(client, voiceController);
logger.info("Command handler started");
// Start retention cleanup scheduler
startRetentionCleanup();
});
client.on("error", (err) => {
@@ -1,15 +1,23 @@
import { type CommandMessage, type CommandReply } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice";
import { resolveMediaUrl } from "../voice-recording/mediaSource.js";
import { discordPlayer } from "../voice-recording/player.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CurrentTrack {
title: string;
url: string;
duration?: number;
}
export interface MediaStatusPayload {
playing: boolean;
musicVolume: number;
current: unknown;
current: CurrentTrack | null;
queue: unknown[];
}
@@ -19,29 +27,84 @@ export interface MediaStatusPayload {
export class MediaHandler {
private logger = createChildLogger("media-handler");
private currentTrack: CurrentTrack | null = null;
getCurrentMediaStatus(): MediaStatusPayload {
return {
playing: discordPlayer.getStatus() === "playing",
musicVolume: discordPlayer.getMusicVolume(),
current: null,
current: this.currentTrack,
queue: [],
};
}
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
this.logger.info(
"media:queue received — media queueing is handled externally",
);
return {
id: cmd.id,
success: true,
data: this.getCurrentMediaStatus(),
};
const url = String(cmd.payload.url ?? "").trim();
if (!url) {
this.logger.warn("media:queue received without a URL");
return {
id: cmd.id,
success: false,
data: null,
error: "url is required",
};
}
if (!discordPlayer.isConnected()) {
this.logger.warn(
"media:queue attempted without an active voice connection",
);
return {
id: cmd.id,
success: false,
data: null,
error: "Not connected to a voice channel. Connect to voice first.",
};
}
try {
this.logger.info({ url }, "Resolving media URL");
const resolution = await resolveMediaUrl(url);
this.currentTrack = {
title: resolution.title ?? url,
url,
duration: resolution.duration,
};
discordPlayer.playStream(resolution.stream, "music", {
inputType: StreamType.Arbitrary,
inlineVolume: true,
volume: discordPlayer.getMusicVolume(),
});
this.logger.info(
{ url, title: resolution.title },
"Media queued and playback started",
);
return {
id: cmd.id,
success: true,
data: this.getCurrentMediaStatus(),
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error({ error: message, url }, "Failed to queue media");
return {
id: cmd.id,
success: false,
data: null,
error: message,
};
}
}
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music");
this.currentTrack = null;
return {
id: cmd.id,
success: true,
@@ -51,6 +114,7 @@ export class MediaHandler {
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music");
this.currentTrack = null;
return {
id: cmd.id,
success: true,
@@ -111,67 +111,6 @@ export class VoiceHandler {
}
}
async handleGuildsList(cmd: CommandMessage): Promise<CommandReply<unknown>> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
try {
const guilds = this.client.guilds.cache
.map((guild) => ({ id: guild.id, name: guild.name }))
.sort((a, b) => a.name.localeCompare(b.name));
return { id: cmd.id, success: true, data: guilds };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
async handleWatchableChannels(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
try {
const guild = await this.client.guilds.fetch(guildId);
const channels = await guild.channels.fetch();
const textChannels = channels
.filter((c) => c?.type === "GUILD_TEXT")
.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
}));
return { id: cmd.id, success: true, data: textChannels };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
async handleVoiceTransmitStart(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
@@ -0,0 +1,374 @@
import { type ChildProcess, spawn } from "node:child_process";
import { PassThrough, Readable } from "node:stream";
import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice";
const logger = createChildLogger("media-source");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MediaInfo {
title: string;
duration: number;
uploader?: string;
thumbnail?: string;
}
export interface MediaSourceResolution {
stream: Readable;
type: StreamType;
title?: string;
duration?: number;
info: MediaInfo;
}
export interface ResolveOptions {
/** Timeout in milliseconds for the yt-dlp process. */
timeout?: number;
/**
* yt-dlp format string override (e.g. "bestaudio[ext=m4a]").
* Defaults to "bestaudio".
*/
quality?: string;
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Tracks all spawned yt-dlp child processes for shutdown cleanup. */
const activeProcesses: Set<ChildProcess> = new Set();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseSeconds(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Read the first two newline-delimited lines from a Readable stdout stream.
*
* yt-dlp with `--print before_dl:title --print before_dl:duration` outputs:
* line 1: video title
* line 2: duration in seconds (float)
* rest: raw binary audio data
*
* Returns the parsed header and a new Readable that contains all remaining
* data (the audio stream).
*/
function readFirstTwoLines(stdout: Readable): Promise<{
title: string;
duration: number;
remaining: Readable;
}> {
return new Promise((resolve, reject) => {
const passThrough = new PassThrough();
let buffer = Buffer.alloc(0);
let title = "";
let stage: "title" | "duration" | "done" = "title";
function cleanup() {
stdout.removeListener("data", onData);
stdout.removeListener("error", onError);
stdout.removeListener("end", onEnd);
}
function onData(chunk: Buffer) {
if (stage === "done") return;
buffer = Buffer.concat([buffer, chunk]);
processBuffer();
}
function processBuffer() {
while (buffer.length > 0 && stage !== "done") {
const nl = buffer.indexOf(0x0a); // '\n' byte
if (nl === -1) break; // Need more data
const line = buffer.subarray(0, nl).toString("utf8").trim();
buffer = buffer.subarray(nl + 1);
if (stage === "title") {
title = line;
stage = "duration";
} else if (stage === "duration") {
const duration = parseSeconds(line);
stage = "done";
cleanup();
// Write any buffered data that follows the second newline
if (buffer.length > 0) {
passThrough.write(buffer);
}
// Pipe the remainder of stdout into the pass-through
stdout.pipe(passThrough);
resolve({ title, duration, remaining: passThrough });
return;
}
}
}
function onError(err: Error) {
if (stage !== "done") {
cleanup();
reject(err);
}
}
function onEnd() {
if (stage !== "done") {
cleanup();
reject(
new Error(
`yt-dlp stdout ended before metadata could be read. ` +
`Stage: ${stage}, partial title: "${title}"`,
),
);
}
}
stdout.on("data", onData);
stdout.on("error", onError);
stdout.on("end", onEnd);
});
}
function buildNotInstalledError(): Error {
return new Error(
"yt-dlp is not installed or not found in PATH. " +
'Run "pnpm run install:yt-dlp" to install it.',
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolve a media URL (YouTube, Spotify, etc.) to a playable audio stream.
*
* Spawns `yt-dlp`, extracts the title and duration from the first two stdout
* lines, then pipes the remaining raw audio data into a Readable stream.
*
* The returned stream uses `StreamType.Arbitrary` — suitable for
* `DiscordPlayer.playStream()` with `inputType: StreamType.Arbitrary`.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero code
* before the metadata headers have been parsed.
*/
export function resolveMediaUrl(
url: string,
options?: ResolveOptions,
): Promise<MediaSourceResolution> {
return new Promise<MediaSourceResolution>((resolve, reject) => {
const format = options?.quality ?? "bestaudio";
const args = [
"-f",
format,
"--audio-format",
"best",
"-o",
"-",
"--print",
"before_dl:title",
"--print",
"before_dl:duration",
url,
];
logger.info({ url }, "Spawning yt-dlp for media resolution");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stderrBuf = "";
let resolved = false;
// -- helpers -----------------------------------------------------------
const failOnce = (err: Error) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
reject(err);
};
// -- spawn error (ENOENT etc.) ----------------------------------------
proc.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
failOnce(buildNotInstalledError());
} else {
failOnce(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
// -- stderr (capture for diagnostics) ----------------------------------
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
});
}
// -- stdout: parse header, then stream audio ---------------------------
readFirstTwoLines(proc.stdout)
.then(({ title, duration, remaining }) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
const info: MediaInfo = { title, duration };
resolve({
stream: remaining,
type: StreamType.Arbitrary,
title,
duration,
info,
});
})
.catch((err: Error) => {
failOnce(err);
});
// -- process exit (non-zero means failure) -----------------------------
proc.on("close", (code, signal) => {
activeProcesses.delete(proc);
if (resolved) return;
if (code !== null && code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
failOnce(new Error(`yt-dlp exited with code ${code}${detail}`));
} else if (signal) {
failOnce(new Error(`yt-dlp was killed by signal ${signal}`));
}
});
// -- optional timeout --------------------------------------------------
if (options?.timeout && options.timeout > 0) {
const timer = setTimeout(() => {
if (resolved) return;
logger.warn({ url, timeout: options.timeout }, "yt-dlp timed out");
proc.kill("SIGTERM");
failOnce(new Error(`yt-dlp timed out after ${options.timeout}ms`));
}, options.timeout);
proc.once("close", () => clearTimeout(timer));
}
});
}
/**
* Extract metadata (title, duration, uploader, thumbnail) from a media URL
* without downloading the audio stream.
*
* Uses `yt-dlp --dump-json` and parses the JSON output.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero
* code.
*/
export async function extractMediaInfo(url: string): Promise<MediaInfo> {
return new Promise<MediaInfo>((resolve, reject) => {
const args = ["--dump-json", "--no-warnings", url];
logger.debug({ url }, "Spawning yt-dlp for metadata extraction");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stdoutBuf = "";
let stderrBuf = "";
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString("utf8");
});
}
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
});
}
proc.on("error", (err: NodeJS.ErrnoException) => {
activeProcesses.delete(proc);
if (err.code === "ENOENT") {
reject(buildNotInstalledError());
} else {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
proc.on("close", (code) => {
activeProcesses.delete(proc);
if (code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
reject(
new Error(
`yt-dlp metadata extraction exited with code ${code}${detail}`,
),
);
return;
}
try {
const raw = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
resolve({
title: String(raw.title ?? url),
duration: typeof raw.duration === "number" ? raw.duration : 0,
uploader: String(raw.uploader ?? raw.channel ?? "") || undefined,
thumbnail: String(raw.thumbnail ?? "") || undefined,
});
} catch (parseErr) {
reject(
new Error(
`Failed to parse yt-dlp JSON output: ${(parseErr as Error).message}`,
),
);
}
});
});
}
/**
* Kill all active yt-dlp child processes.
*
* Call during graceful shutdown to ensure no orphan processes remain.
*/
export function cleanup(): void {
if (activeProcesses.size === 0) return;
logger.info(
{ count: activeProcesses.size },
"Killing active yt-dlp processes",
);
for (const proc of activeProcesses) {
try {
proc.kill("SIGTERM");
} catch {
// Process may already be dead — ignore
}
}
activeProcesses.clear();
}
@@ -1,7 +1,417 @@
import { describe, expect, it } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
describe("discord-gateway", () => {
it("should load without errors", () => {
expect(true).toBe(true);
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError Hierarchy
// ═══════════════════════════════════════════════════════════════════════════════
import {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
DatabaseError,
ConfigError,
} from "@bete/shared/errors";
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 { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils";
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 {
DISCORD_MESSAGE_CREATED,
DISCORD_MESSAGE_UPDATED,
DISCORD_MESSAGE_DELETED,
DISCORD_MESSAGE_ANALYZED,
DISCORD_ATTACHMENT_CREATED,
DISCORD_VOICE_STARTED,
DISCORD_VOICE_PCM,
DISCORD_ANALYSIS_QUEUE_STATUS,
BACKEND_COMMAND,
VOICE_STATUS_KEY,
MEDIA_STATUS_KEY,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
COMMAND_GUILDS_LIST,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
COMMAND_MODERATION_ACTION,
} from "@bete/shared/redis-channels";
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 { loadConfig, configSchema } from "@bete/shared/config";
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/imageMimeSniffer.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,
deriveSeverity,
deriveRecommendedAction,
hasDeferralAnalysis,
} from "../src/modules/ai-moderation/severityDeriver.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/jsonExtractor.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");
});
});