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
@@ -9,6 +9,18 @@ export function createConfigRouter(): Router {
router.get("/config", (_req, res) => {
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
});
});
@@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import { register } from "prom-client";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { healthService } from "./health.service.js";
@@ -10,3 +11,10 @@ export const handleHealthCheck = asyncHandler(
res.status(status).json(result);
},
);
export const handleMetrics = asyncHandler(
async (_req: Request, res: Response) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
},
);
@@ -1,6 +1,11 @@
import { collectDefaultMetrics, register } from "prom-client";
import type { Router } from "express";
import express from "express";
import { handleHealthCheck } from "./health.controller.js";
import { handleHealthCheck, handleMetrics } from "./health.controller.js";
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
// Called once at module load, not per-request.
collectDefaultMetrics();
export function createHealthRouter(): Router {
const router = express.Router();
@@ -8,5 +13,8 @@ export function createHealthRouter(): Router {
// GET /api/health
router.get("/health", handleHealthCheck);
// GET /api/metrics — Prometheus scrape endpoint
router.get("/metrics", handleMetrics);
return router;
}
@@ -2,7 +2,11 @@ import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express";
import express from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { getGuilds, getTextChannels } from "./voice.service.js";
import {
getGuilds,
getTextChannels,
getVoiceChannels,
} from "./voice.service.js";
const logger = createChildLogger("guilds.routes");
@@ -32,5 +36,18 @@ export function createGuildsRouter(): Router {
}),
);
// GET /api/guilds/:guildId/voice-channels
router.get(
"/:guildId/voice-channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = Array.isArray(req.params.guildId)
? req.params.guildId[0]
: req.params.guildId;
logger.debug({ guildId }, "Fetching voice channels");
const channels = await getVoiceChannels(guildId);
res.json(channels);
}),
);
return router;
}
@@ -3,7 +3,6 @@ import express from "express";
import {
handleConnectVoice,
handleDisconnectVoice,
handleGetVoiceChannels,
handleGetVoiceStatus,
handleVoiceCommand,
} from "./voice.controller.js";
@@ -20,9 +19,6 @@ export function createVoiceRouter(): Router {
// POST /api/disconnect
router.post("/disconnect", handleDisconnectVoice);
// GET /api/guilds/:guildId/voice-channels
router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels);
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
router.post("/voice/command", handleVoiceCommand);
+265 -4
View File
@@ -1,7 +1,268 @@
import { describe, expect, it } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("backend", () => {
it("should load without errors", () => {
expect(true).toBe(true);
// ─── Shared Error Classes ────────────────────────────────────────────────────
import {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
DatabaseError,
ConfigError,
} from "@bete/shared/errors";
// ─── Shared utilities ─────────────────────────────────────────────────────────
import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils";
// ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError / Error Hierarchy Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("AppError subclasses", () => {
it("AppError stores message, code, statusCode, and details", () => {
const err = new AppError("custom", "CUSTOM", 418, { reason: "teapot" });
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("custom");
expect(err.code).toBe("CUSTOM");
expect(err.statusCode).toBe(418);
expect(err.details).toEqual({ reason: "teapot" });
expect(err.name).toBe("AppError");
});
it("AppError defaults statusCode to 500", () => {
const err = new AppError("msg", "X");
expect(err.statusCode).toBe(500);
});
it("NotFoundError has 404 status and formatted message", () => {
const err = new NotFoundError("User");
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(404);
expect(err.code).toBe("NOT_FOUND");
expect(err.message).toBe("User not found");
expect(err.name).toBe("NotFoundError");
});
it("NotFoundError appends id when provided", () => {
const err = new NotFoundError("Message", "abc-123");
expect(err.message).toBe("Message not found: abc-123");
});
it("ValidationError has 400 status and forwards details", () => {
const details = { field: "email" };
const err = new ValidationError("Invalid input", details);
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(400);
expect(err.code).toBe("VALIDATION_ERROR");
expect(err.details).toBe(details);
expect(err.name).toBe("ValidationError");
});
it("UnauthorizedError has 401 status and default message", () => {
const err = new UnauthorizedError();
expect(err.statusCode).toBe(401);
expect(err.code).toBe("UNAUTHORIZED");
expect(err.message).toBe("Unauthorized");
});
it("UnauthorizedError accepts custom message", () => {
const err = new UnauthorizedError("Access denied");
expect(err.message).toBe("Access denied");
});
it("DatabaseError has 500 status and forwards details", () => {
const err = new DatabaseError("DB down", { cause: "timeout" });
expect(err.statusCode).toBe(500);
expect(err.code).toBe("DATABASE_ERROR");
expect(err.details).toEqual({ cause: "timeout" });
});
it("ConfigError has 500 status", () => {
const err = new ConfigError("Bad config");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("CONFIG_ERROR");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 2. Utility Function Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("delay", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves after the given time", async () => {
vi.useFakeTimers();
const promise = delay(500);
vi.advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
it("rejects are not triggered on non-matching timer", async () => {
vi.useFakeTimers();
const promise = delay(1000);
// Advance only part way — the timer should NOT fire yet
vi.advanceTimersByTime(500);
// The timer is still pending; the promise has not resolved yet
// We advance the rest
vi.advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
});
describe("retryWithBackoff", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns the result on first success without retrying", async () => {
const fn = vi.fn().mockResolvedValue("ok");
await expect(retryWithBackoff(fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("re-throws after exhausting all retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("persistent"));
await expect(
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
).rejects.toThrow("persistent");
// initial call + 1 retry
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("throws AbortError immediately when signal is already aborted", async () => {
const ac = new AbortController();
ac.abort();
const fn = vi.fn().mockResolvedValue("ok");
await expect(
retryWithBackoff(fn, { retries: 3, signal: ac.signal }),
).rejects.toThrow("Aborted");
expect(fn).not.toHaveBeenCalled();
});
it("respects abort signal during retry", async () => {
vi.useFakeTimers();
const ac = new AbortController();
const fn = vi.fn().mockRejectedValue(new Error("fail"));
const promise = retryWithBackoff(fn, {
retries: 5,
minTimeout: 100,
signal: ac.signal,
});
// Schedule abort after first failure + backoff starts
setTimeout(() => ac.abort(), 150);
vi.advanceTimersByTime(200);
await vi.waitFor(async () => {
await expect(promise).rejects.toThrow("Aborted");
});
});
});
describe("pagination utilities", () => {
it("encodeCursor produces a base64 string", () => {
const result = encodeCursor({ created_at: 1000, id: "msg-1" });
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("encodeCursor round-trips through decodeCursor", () => {
const data = { created_at: 1234567890, id: "abc-def" };
const cursor = encodeCursor(data);
expect(decodeCursor(cursor)).toEqual(data);
});
it("decodeCursor returns null for undefined / empty", () => {
expect(decodeCursor()).toBeNull();
expect(decodeCursor("")).toBeNull();
});
it("decodeCursor returns null for malformed input", () => {
// Completely invalid base64
expect(decodeCursor("!!!not-valid!!!")).toBeNull();
// Valid base64 but not JSON
const notJson = Buffer.from("not-json").toString("base64");
expect(decodeCursor(notJson)).toBeNull();
// Valid JSON but wrong shape (missing created_at / id)
const wrongShape = Buffer.from(JSON.stringify({ foo: "bar" })).toString("base64");
expect(decodeCursor(wrongShape)).toBeNull();
});
it("pageResult truncates and sets nextCursor when rows exceed limit", () => {
const rows = [
{ id: "a", created_at: 100 },
{ id: "b", created_at: 200 },
{ id: "c", created_at: 300 },
];
const { data, nextCursor } = pageResult(rows, 2);
expect(data).toHaveLength(2);
expect(data[0].id).toBe("a");
expect(nextCursor).toBeTruthy();
});
it("pageResult returns null nextCursor when fewer rows than limit", () => {
const rows = [{ id: "a", created_at: 100 }];
const { data, nextCursor } = pageResult(rows, 2);
expect(data).toHaveLength(1);
expect(nextCursor).toBeNull();
});
it("pageResult returns empty data for empty input", () => {
const { data, nextCursor } = pageResult([], 10);
expect(data).toEqual([]);
expect(nextCursor).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 3. Middleware Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("asyncHandler", () => {
it("passes thrown errors to next()", async () => {
const error = new Error("handler-error");
const wrapped = asyncHandler(async () => {
throw error;
});
const next = vi.fn();
wrapped({} as any, {} as any, next);
// .catch(next) is a microtask — flush the queue
await Promise.resolve();
await Promise.resolve();
expect(next).toHaveBeenCalledWith(error);
});
it("does not call next when handler resolves successfully", async () => {
const wrapped = asyncHandler(async (_req: any, _res: any, _next: any) => {
// no-op
});
const next = vi.fn();
wrapped({} as any, {} as any, next);
await Promise.resolve();
await Promise.resolve();
expect(next).not.toHaveBeenCalled();
});
});
describe("requireParam", () => {
it("returns the value for a non-empty string", () => {
expect(requireParam("hello", "param", "name")).toBe("hello");
});
it("throws ValidationError for undefined", () => {
expect(() => requireParam(undefined, "query", "q")).toThrow(ValidationError);
});
it("throws ValidationError for empty string", () => {
expect(() => requireParam("", "param", "id")).toThrow(ValidationError);
});
it("throws with a descriptive message", () => {
expect(() => requireParam(null, "header", "X-Token")).toThrow("Missing header: X-Token");
});
});