feat(shared): reorder AppError constructor to (message, code, status), add singleton logger, retry and TTL cache utilities

This commit is contained in:
MythEclipse
2026-06-02 21:06:42 +07:00
parent 9045e7e347
commit 2d79d8aefd
50 changed files with 401 additions and 449 deletions
+1
View File
@@ -22,6 +22,7 @@
},
"devDependencies": {
"@types/node": "^25.9.0",
"pino-pretty": "^13.1.3",
"typescript": "^5.9.3"
}
}
+16 -12
View File
@@ -2,9 +2,9 @@
export class AppError extends Error {
constructor(
public code: string,
public statusCode: number,
message: string,
public code: string,
public statusCode: number = 500,
public details?: Record<string, unknown>,
) {
super(message);
@@ -14,35 +14,39 @@ export class AppError extends Error {
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super("VALIDATION_ERROR", 400, message, details);
super(message, "VALIDATION_ERROR", 400, details);
this.name = "ValidationError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
super("NOT_FOUND", 404, `${resource} not found${id ? `: ${id}` : ""}`);
super(
`${resource} not found${id ? `: ${id}` : ""}`,
"NOT_FOUND",
404,
);
this.name = "NotFoundError";
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super("UNAUTHORIZED", 401, message);
super(message, "UNAUTHORIZED", 401);
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends AppError {
constructor(message = "Forbidden") {
super("FORBIDDEN", 403, message);
super(message, "FORBIDDEN", 403);
this.name = "ForbiddenError";
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super("CONFLICT", 409, message);
super(message, "CONFLICT", 409);
this.name = "ConflictError";
}
}
@@ -52,35 +56,35 @@ export class InternalServerError extends AppError {
message = "Internal server error",
details?: Record<string, unknown>,
) {
super("INTERNAL_SERVER_ERROR", 500, message, details);
super(message, "INTERNAL_SERVER_ERROR", 500, details);
this.name = "InternalServerError";
}
}
export class DatabaseError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super("DATABASE_ERROR", 500, message, details);
super(message, "DATABASE_ERROR", 500, details);
this.name = "DatabaseError";
}
}
export class ConfigError extends AppError {
constructor(message: string) {
super("CONFIG_ERROR", 500, message);
super(message, "CONFIG_ERROR", 500);
this.name = "ConfigError";
}
}
export class DiscordError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super("DISCORD_ERROR", 500, message, details);
super(message, "DISCORD_ERROR", 500, details);
this.name = "DiscordError";
}
}
export class TimeoutError extends AppError {
constructor(operation: string) {
super("TIMEOUT", 504, `${operation} timed out`);
super(`${operation} timed out`, "TIMEOUT", 504);
this.name = "TimeoutError";
}
}
+28 -19
View File
@@ -1,25 +1,34 @@
import pino from "pino";
export type Logger = ReturnType<typeof createLogger>;
const rootLogger = pino({
level: process.env.LOG_LEVEL || "info",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
}
: undefined,
} as pino.LoggerOptions);
export function createLogger(context: string) {
return pino({
name: context,
level: process.env.LOG_LEVEL || "info",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
}
: undefined,
} as pino.LoggerOptions);
}
export type Logger = ReturnType<typeof createChildLogger>;
/**
* Returns a child logger bound to the root singleton via pino's .child().
* Preserves parent context and is efficient (no transport re-init per call).
*/
export function createChildLogger(context: string) {
return createLogger(context);
return rootLogger.child({ context });
}
/**
* Alias for createChildLogger for backwards compatibility.
* @deprecated Use createChildLogger instead.
*/
export function createLogger(context: string) {
return createChildLogger(context);
}
+156 -2
View File
@@ -13,7 +13,7 @@ export function formatBytes(bytes: number): string {
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
export function isValidUrl(url: string): boolean {
@@ -26,7 +26,7 @@ export function isValidUrl(url: string): boolean {
}
export function sanitizeString(str: string): string {
return str.replace(/[<>]/g, "").trim().substring(0);
return str.replace(/[<>]/g, "").trim();
}
export interface PaginationParams {
@@ -59,3 +59,157 @@ export function calculatePagination(
export function getOffset(page: number, limit: number): number {
return (page - 1) * limit;
}
// ---------------------------------------------------------------------------
// Retry with exponential backoff (port of discord-gateway retry utility)
// ---------------------------------------------------------------------------
export interface RetryOptions {
/** Number of retry attempts (default: 3) */
retries?: number;
/** Initial delay in ms (default: 1000) */
minTimeout?: number;
/** Maximum delay in ms (default: 30000) */
maxTimeout?: number;
/** Multiplication factor for each retry (default: 2) */
factor?: number;
}
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
retries = 3,
minTimeout = 1_000,
maxTimeout = 30_000,
factor = 2,
} = options;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt === retries) break;
const backoff = Math.min(
minTimeout * factor ** attempt + Math.random() * 100,
maxTimeout,
);
await delay(backoff);
}
}
throw lastError!;
}
// ---------------------------------------------------------------------------
// Generic in-memory TTL cache with LRU-style pruning
// ---------------------------------------------------------------------------
interface CacheEntry<V> {
value: V;
expiresAt: number;
}
export interface TtlCacheOptions<K> {
/** Default TTL in ms for entries (default: 60000) */
defaultTtlMs?: number;
/** Maximum entries before pruning (default: 500) */
maxEntries?: number;
/** Called when an entry is evicted */
onEvict?: (key: K, value: unknown) => void;
}
export class TtlCache<K = string, V = unknown> {
private store = new Map<K, CacheEntry<V>>();
private readonly defaultTtlMs: number;
private readonly maxEntries: number;
private readonly onEvict?: (key: K, value: V) => void;
constructor(options: TtlCacheOptions<K> = {}) {
this.defaultTtlMs = options.defaultTtlMs ?? 60_000;
this.maxEntries = options.maxEntries ?? 500;
this.onEvict = options.onEvict;
}
/**
* Get a value by key. Returns undefined if missing or expired.
*/
get(key: K): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
/**
* Set a value with optional custom TTL. Prunes oldest entries if at capacity.
*/
set(key: K, value: V, ttlMs?: number): void {
if (this.store.size >= this.maxEntries) {
this.prune();
}
this.store.set(key, {
value,
expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs),
});
}
/**
* Check if a key exists and is not expired (without removing it).
*/
has(key: K): boolean {
return this.get(key) !== undefined;
}
/**
* Remove a specific entry.
*/
delete(key: K): boolean {
return this.store.delete(key);
}
/**
* Remove all expired entries.
*/
prune(): void {
const now = Date.now();
const toDelete: K[] = [];
for (const [key, entry] of this.store) {
if (now > entry.expiresAt) {
toDelete.push(key);
}
}
for (const key of toDelete) {
const entry = this.store.get(key);
this.store.delete(key);
if (entry && this.onEvict) this.onEvict(key, entry.value);
}
// If still over limit after TTL pruning, drop oldest entries
if (this.store.size > this.maxEntries) {
const keysToDelete = Array.from(this.store.keys()).slice(
0,
this.store.size - this.maxEntries,
);
for (const key of keysToDelete) {
const entry = this.store.get(key);
this.store.delete(key);
if (entry && this.onEvict) this.onEvict(key, entry.value);
}
}
}
/** Current number of entries (including possibly expired ones). */
get size(): number {
return this.store.size;
}
/** Remove all entries. */
clear(): void {
this.store.clear();
}
}
+73
View File
@@ -36,12 +36,18 @@ importers:
'@types/node':
specifier: ^25.9.0
version: 25.9.0
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
typescript:
specifier: ^5.9.3
version: 5.9.3
services/backend:
dependencies:
'@bete/shared':
specifier: workspace:*
version: link:../../packages/shared
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@discordjs/opus@0.10.0)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(opusscript@0.0.8)
@@ -109,6 +115,9 @@ importers:
services/discord-gateway:
dependencies:
'@bete/shared':
specifier: workspace:*
version: link:../../packages/shared
'@discordjs/opus':
specifier: ^0.10.0
version: 0.10.0
@@ -2498,6 +2507,9 @@ packages:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -2575,6 +2587,9 @@ packages:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
dateformat@4.6.3:
resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
debug-level@4.1.1:
resolution: {integrity: sha512-r/T+zzVbsy4FL91zdUrxc1I788DRXwU4mg65Yk8F5ACHNu9ucTXiWrb4JaKHbNtwuOUKKpE8oEnDg0fcBFmmBw==}
engines: {node: '>=18'}
@@ -2944,6 +2959,9 @@ packages:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
fast-copy@4.0.3:
resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -3194,6 +3212,9 @@ packages:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
@@ -3379,6 +3400,10 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3998,9 +4023,16 @@ packages:
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
pino-abstract-transport@3.0.0:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
pino-http@10.5.0:
resolution: {integrity: sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==}
pino-pretty@13.1.3:
resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==}
hasBin: true
pino-std-serializers@7.1.0:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
@@ -4316,6 +4348,9 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4522,6 +4557,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
@@ -6822,6 +6861,8 @@ snapshots:
color-convert: 3.1.3
color-string: 2.1.4
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -6896,6 +6937,8 @@ snapshots:
dependencies:
'@babel/runtime': 7.29.2
dateformat@4.6.3: {}
debug-level@4.1.1:
dependencies:
asyncc: 2.0.9
@@ -7300,6 +7343,8 @@ snapshots:
iconv-lite: 0.4.24
tmp: 0.0.33
fast-copy@4.0.3: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -7565,6 +7610,8 @@ snapshots:
helmet@8.1.0: {}
help-me@5.0.0: {}
hosted-git-info@2.8.9: {}
hosted-git-info@4.1.0:
@@ -7723,6 +7770,8 @@ snapshots:
jiti@2.7.0: {}
joycon@3.1.1: {}
js-tokens@4.0.0: {}
js-yaml@3.14.2:
@@ -8312,6 +8361,10 @@ snapshots:
dependencies:
split2: 4.2.0
pino-abstract-transport@3.0.0:
dependencies:
split2: 4.2.0
pino-http@10.5.0:
dependencies:
get-caller-file: 2.0.5
@@ -8319,6 +8372,22 @@ snapshots:
pino-std-serializers: 7.1.0
process-warning: 5.0.0
pino-pretty@13.1.3:
dependencies:
colorette: 2.0.20
dateformat: 4.6.3
fast-copy: 4.0.3
fast-safe-stringify: 2.1.1
help-me: 5.0.0
joycon: 3.1.1
minimist: 1.2.8
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pump: 3.0.4
secure-json-parse: 4.1.0
sonic-boom: 4.2.1
strip-json-comments: 5.0.3
pino-std-serializers@7.1.0: {}
pino@9.14.0:
@@ -8652,6 +8721,8 @@ snapshots:
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -8888,6 +8959,8 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
+1
View File
@@ -14,6 +14,7 @@
"test": "vitest run"
},
"dependencies": {
"@bete/shared": "workspace:*",
"@discordjs/voice": "^0.19.2",
"@types/pg": "^8.20.0",
"axios": "^1.16.1",
+1 -1
View File
@@ -16,7 +16,7 @@ import { createRecordingsRouter } from "../modules/recordings/recordings.routes.
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { errorHandler } from "../shared/middlewares/index.js";
const logger = createChildLogger("http.app");
+1 -1
View File
@@ -1,7 +1,7 @@
import { createServer, type Server } from "node:http";
import { config } from "../shared/config/index.js";
import { initializeDatabase } from "../shared/database/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { createHttpApp } from "./app.js";
import { createWebSocketServer } from "../ws/server.js";
import { startRedisBridge } from "../ws/redis-bridge.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { startHttpServer } from "./http/server.js";
import { createChildLogger } from "./shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import type { Server } from "node:http";
const logger = createChildLogger("backend");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { analysisService } from "./analysis.service.js";
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm";
import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("analysis.service");
@@ -1,18 +1,14 @@
import type { NextFunction, Request, Response } from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
import {
asyncHandler,
requireParam,
} from "../../shared/middlewares/index.js";
import { analyticsQuerySchema } from "./analytics.schema.js";
import { analyticsService } from "./analytics.service.js";
const logger = createChildLogger("analytics.controller");
function requireQueryString(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing query parameter: ${name}`);
}
return value;
}
export function handleGetOverview(
req: Request,
res: Response,
@@ -32,7 +28,7 @@ export function handleGetDailyTrend(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const guildId = requireQueryString(req.query.guildId, "guildId");
const guildId = requireParam(req.query.guildId, "query parameter", "guildId");
const hours = req.query.hours ? Number(req.query.hours) : 24;
logger.debug({ guildId, hours }, "Handling get daily trend");
const result = await analyticsService.getDailyTrend(guildId, hours);
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("analytics.repository");
@@ -1,6 +1,6 @@
import { config } from "../../shared/config/index.js";
import { ForbiddenError, ValidationError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { ForbiddenError, ValidationError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { analyticsRepository } from "./analytics.repository.js";
import type { AnalyticsQuery } from "./analytics.schema.js";
@@ -1,8 +1,8 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { config } from "../../shared/config/index.js";
import { UnauthorizedError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { UnauthorizedError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
const logger = createChildLogger("auth.routes");
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("health.repository");
@@ -1,4 +1,4 @@
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js";
const logger = createChildLogger("health.service");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { queue, skip, stop, setVolume, getStatus } from "./media.service.js";
@@ -1,4 +1,4 @@
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
@@ -1,21 +1,14 @@
import type { NextFunction, Request, Response } from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@bete/shared/logger";
import {
asyncHandler,
requireParam,
} from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.controller");
function requireRouteParam(
value: string | string[] | undefined,
name: string,
): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing route parameter: ${name}`);
}
return value;
}
export function handleListMessages(
req: Request,
res: Response,
@@ -35,7 +28,7 @@ export function handleGetMessagesByChannel(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireRouteParam(req.params.channelId, "channelId");
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
@@ -49,7 +42,7 @@ export function handleGetMessageById(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const id = requireRouteParam(req.params.id, "id");
const id = requireParam(req.params.id, "route parameter", "id");
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
@@ -62,7 +55,7 @@ export function handleGetAttachmentsByChannel(
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireRouteParam(req.params.channelId, "channelId");
const channelId = requireParam(req.params.channelId, "route parameter", "channelId");
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(
@@ -1,5 +1,5 @@
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import type {
MessageCreate,
MessageQuery,
@@ -1,7 +1,7 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
handleGetAttachmentsByChannel,
@@ -1,5 +1,5 @@
import { NotFoundError, ValidationError } from "../../shared/errors/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { NotFoundError, ValidationError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { messagesRepository } from "./messages.repository.js";
import type { MessageQuery } from "./messages.schema.js";
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { recordingsService } from "./recordings.service.js";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("recordings.service");
@@ -1,6 +1,6 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { uiStateService } from "./ui-state.service.js";
@@ -1,6 +1,6 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("ui-state.service");
@@ -1,7 +1,9 @@
import Redis from "ioredis";
import { config } from "../../shared/config/index.js";
import { getPool } from "../../shared/database/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
publishCommand,
readRedisStatus,
} from "../../shared/redis/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("voice.service");
@@ -24,109 +26,14 @@ export interface VoiceStatus {
activeChannelName: string | null;
}
interface CommandReply {
id: string;
success: boolean;
data: unknown;
error?: string;
}
// --- Redis command client ---
let commandRedis: Redis | null = null;
let statusRedis: Redis | null = null;
function getCommandRedis(): Redis {
if (!commandRedis) {
commandRedis = config.REDIS_URL
? new Redis(config.REDIS_URL, { keyPrefix: "" })
: new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
keyPrefix: "",
});
}
return commandRedis;
}
function getStatusRedis(): Redis {
if (!statusRedis) {
statusRedis = config.REDIS_URL
? new Redis(config.REDIS_URL, { keyPrefix: "" })
: new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
keyPrefix: "",
});
}
return statusRedis;
}
async function sendCommand<T = unknown>(
type: string,
payload: Record<string, unknown>,
timeoutMs = 10000,
): Promise<T | null> {
const redis = getCommandRedis();
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const replyChannel = `backend:command:reply:${id}`;
return new Promise<T | null>((resolve) => {
const timer = setTimeout(() => {
redis.unsubscribe(replyChannel).catch(() => {});
resolve(null);
}, timeoutMs);
redis.subscribe(replyChannel, (err) => {
if (err) {
clearTimeout(timer);
resolve(null);
return;
}
});
const handler = (_ch: string, msg: string) => {
if (_ch === replyChannel) {
clearTimeout(timer);
redis.unsubscribe(replyChannel).catch(() => {});
try {
const reply: CommandReply = JSON.parse(msg);
resolve(reply.success ? (reply.data as T) : null);
} catch {
resolve(null);
}
}
};
redis.on("message", handler);
redis
.publish(
"backend:command",
JSON.stringify({ id, type, payload, replyChannel }),
)
.catch(() => {
clearTimeout(timer);
resolve(null);
});
});
}
async function readStatus<T>(key: string): Promise<T | null> {
try {
const val = await getStatusRedis().get(key);
return val ? (JSON.parse(val) as T) : null;
} catch {
return null;
}
}
/**
* Get guilds query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/
export async function getGuilds(): Promise<Guild[]> {
const fromGateway = await sendCommand<Guild[]>("guilds:list", {});
if (fromGateway && fromGateway.length > 0) return fromGateway;
const reply = await publishCommand<Guild[]>("guilds:list", {});
if (reply?.success && reply.data && reply.data.length > 0)
return reply.data;
// Fallback: Postgres with synthetic names
logger.warn(
@@ -149,10 +56,11 @@ export async function getGuilds(): Promise<Guild[]> {
* Falls back to database if gateway unreachable.
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
const fromGateway = await sendCommand<Channel[]>("guilds:text-channels", {
const reply = await publishCommand<Channel[]>("guilds:text-channels", {
guildId,
});
if (fromGateway && fromGateway.length > 0) return fromGateway;
if (reply?.success && reply.data && reply.data.length > 0)
return reply.data;
// Fallback: Postgres with synthetic names
logger.warn(
@@ -176,16 +84,16 @@ export async function getTextChannels(guildId: string): Promise<Channel[]> {
* Get voice channels query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
const channels = await sendCommand<Channel[]>("voice:channels", { guildId });
return channels ?? [];
const reply = await publishCommand<Channel[]>("voice:channels", { guildId });
return reply?.success && reply.data ? reply.data : [];
}
/**
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export async function getVoiceStatus(): Promise<VoiceStatus> {
const cached = await readStatus<VoiceStatus>("voice:status");
if (cached) return cached;
const cached = await readRedisStatus("voice:status");
if (cached) return cached as unknown as VoiceStatus;
return {
connected: false,
activeGuildId: null,
@@ -201,38 +109,34 @@ export async function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
const result = await sendCommand<VoiceStatus>("voice:connect", {
const reply = await publishCommand<VoiceStatus>("voice:connect", {
guildId,
channelId,
});
if (result) return result;
if (reply?.success && reply.data) return reply.data;
// Fallback: read from Redis status key
const cached = await readStatus<VoiceStatus>("voice:status");
return (
cached ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
}
);
const cached = await readRedisStatus("voice:status");
return (cached as unknown as VoiceStatus) ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
}
/**
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
const result = await sendCommand<VoiceStatus>("voice:disconnect", {});
if (result) return result;
const reply = await publishCommand<VoiceStatus>("voice:disconnect", {});
if (reply?.success && reply.data) return reply.data;
const cached = await readStatus<VoiceStatus>("voice:status");
return (
cached ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
}
);
const cached = await readRedisStatus("voice:status");
return (cached as unknown as VoiceStatus) ?? {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
};
}
@@ -1,7 +1,7 @@
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { config } from "../config/index.js";
import { createChildLogger } from "../logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("database");
@@ -1,65 +0,0 @@
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
) {
super(message);
this.name = "AppError";
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public details?: Record<string, unknown>,
) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, "NOT_FOUND", 404);
this.name = "NotFoundError";
}
}
export class UnauthorizedError extends AppError {
constructor(message: string = "Unauthorized") {
super(message, "UNAUTHORIZED", 401);
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends AppError {
constructor(message: string = "Forbidden") {
super(message, "FORBIDDEN", 403);
this.name = "ForbiddenError";
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, "CONFLICT", 409);
this.name = "ConflictError";
}
}
export class DatabaseError extends AppError {
constructor(
message: string,
public originalError?: Error,
) {
super(message, "DATABASE_ERROR", 500);
this.name = "DatabaseError";
}
}
export class ConfigError extends AppError {
constructor(message: string) {
super(message, "CONFIG_ERROR", 500);
this.name = "ConfigError";
}
}
@@ -1,24 +0,0 @@
import pino from "pino";
import { config } from "../config/index.js";
const isDev = config.NODE_ENV === "development";
export const logger = pino({
level: config.LOG_LEVEL,
transport: isDev
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
}
: undefined,
});
export function createChildLogger(context: string) {
return logger.child({ context });
}
export type Logger = ReturnType<typeof createChildLogger>;
@@ -1,6 +1,10 @@
import type { NextFunction, Request, Response } from "express";
import { AppError, UnauthorizedError } from "../errors/index.js";
import { createChildLogger } from "../logger/index.js";
import {
AppError,
UnauthorizedError,
ValidationError,
} from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("middleware");
@@ -46,5 +50,13 @@ export function asyncHandler(
};
}
// Import ValidationError for type checking
import { ValidationError } from "../errors/index.js";
/**
* Validate that a value is a non-empty string, or throw a descriptive error.
* Use for both route params and query string values.
*/
export function requireParam(value: unknown, kind: string, name: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing ${kind}: ${name}`);
}
return value;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import Redis from "ioredis";
import { config } from "../config/index.js";
import { createChildLogger } from "../logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("redis.command-channel");
-1
View File
@@ -12,7 +12,6 @@
type BroadcastFn = (data: unknown) => void;
type BroadcastRawFn = (type: string, data: unknown) => void;
// Extend globalThis with broadcast function types
declare global {
// biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry
var __broadcastFns:
+4 -15
View File
@@ -1,6 +1,7 @@
import Redis from "ioredis";
import { config } from "../shared/config/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { getCommandPublisher } from "../shared/redis/index.js";
import { createChildLogger } from "@bete/shared/logger";
import { broadcastRaw } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
@@ -27,9 +28,8 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
];
let subscriber: Redis | null = null;
let publisher: Redis | null = null;
function createRedisInstance(): Redis {
function createSubscriber(): Redis {
if (config.REDIS_URL) {
return new Redis(config.REDIS_URL, { keyPrefix: "" });
}
@@ -40,17 +40,6 @@ function createRedisInstance(): Redis {
});
}
function createSubscriber(): Redis {
return createRedisInstance();
}
function getPublisher(): Redis {
if (!publisher) {
publisher = createRedisInstance();
}
return publisher;
}
/**
* Publish a command to the Discord Gateway via Redis.
* The DG's commandHandler listens on "backend:command" channel.
@@ -58,7 +47,7 @@ function getPublisher(): Redis {
export async function publishCommand(
payload: Record<string, unknown>,
): Promise<void> {
const pub = getPublisher();
const pub = getCommandPublisher();
const envelope = {
type: "command",
data: payload,
+1 -2
View File
@@ -1,6 +1,6 @@
import type { Server } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
import { createChildLogger } from "../shared/logger/index.js";
import { createChildLogger } from "@bete/shared/logger";
const logger = createChildLogger("ws.server");
@@ -10,7 +10,6 @@ interface BroadcastEvent {
timestamp: string;
}
// Extend globalThis with broadcast function types
declare global {
var __broadcastFns:
| {
+1
View File
@@ -15,6 +15,7 @@
"test": "vitest run"
},
"dependencies": {
"@bete/shared": "workspace:*",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
@@ -1,9 +1,9 @@
import type { WebSocket } from "ws";
import { createChildLogger } from "../../shared/logger/logger.js";
import type { MediaState } from "../voice-recording/mediaTypes.js";
import type {
AnalysisQueueStatus,
AttachmentRecord,
MediaState,
MessageRecord,
ModerationWsEvent,
} from "../message-capture/types.js";
@@ -171,33 +171,6 @@ export interface AnalysisResult {
evidence?: string[];
}
export type MediaMode = "music" | "screen";
export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface MediaQueueItem {
id: string;
mode: MediaMode;
source: string;
title: string;
kind: MediaSourceKind;
requestedBy: string;
addedAt: number;
status: MediaQueueItemStatus;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
export type ModerationWsEvent =
| { type: "ui_state"; state: unknown }
| { type: "user_state"; users: unknown[] }
@@ -207,7 +180,7 @@ export type ModerationWsEvent =
| { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: MediaState }
| { type: "media_state"; state: unknown }
| { type: "voice_recording_uploaded"; data: any };
export interface AnalysisQueueStatus {
@@ -1,6 +1,6 @@
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { AppError } from "../../shared/errors/errors.js";
import { AppError } from "@bete/shared/errors";
import { createChildLogger } from "../../shared/logger/logger.js";
import { discordPlayer } from "./player.js";
import { startRecording, stopRecording } from "./recorder.js";
+1 -12
View File
@@ -1,12 +1 @@
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export { Guild, Channel } from "../../shared/api/client";
+1 -17
View File
@@ -1,17 +1 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: MediaMode;
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
export { MediaMode, MediaItem, MediaState } from "../../shared/api/client";
@@ -14,6 +14,15 @@ export interface MessageMetadata {
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
export function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
export interface MessageRecord {
id: string;
guild_id: string;
+1 -14
View File
@@ -1,14 +1 @@
export type DashboardTab = "live" | "messages" | "analytics";
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: DashboardTab;
isListening?: boolean;
isStreaming?: boolean;
}
export { DashboardTab, UIState } from "../../shared/api/client";
+1 -14
View File
@@ -1,14 +1 @@
export interface VoiceStatus {
connected: boolean;
activeGuildId?: string | null;
activeChannelId?: string | null;
activeChannelName?: string | null;
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}
export { VoiceStatus, ActiveSpeaker } from "../../shared/api/client";
@@ -3,6 +3,7 @@
import { Download, Mic } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge, Button, Skeleton } from "../../../shared/ui";
import { formatBytes, formatDate } from "../../../shared/lib/utils";
interface VoiceRecording {
id: string;
@@ -21,16 +22,6 @@ interface VoiceRecording {
uploaded_at: number | null;
}
function formatDate(value: number): string {
return new Date(value).toLocaleString();
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export function RecordingsSubPanel() {
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
const [loading, setLoading] = useState(true);
@@ -1,10 +1,5 @@
import type { MessageRecord } from "../../../shared/api/client";
interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
import { parseMetadata } from "../../../entities/message/types";
interface ImageItem {
url: string;
@@ -13,15 +8,6 @@ interface ImageItem {
message: MessageRecord;
}
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
const images: ImageItem[] = [];
@@ -14,6 +14,7 @@ import { Fragment, useMemo, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client";
import { moderateMessage } from "../../../shared/api/client";
import { Badge, Button, Skeleton } from "../../../shared/ui";
import { parseMetadata } from "../../../entities/message/types";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
@@ -71,21 +72,6 @@ interface MessageCardProps {
compact?: boolean;
}
interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
}
function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
return JSON.parse(value) as MessageMetadata;
} catch {
return {};
}
}
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
@@ -112,6 +112,8 @@ export interface ActiveSpeaker {
speaking: boolean;
}
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
+12
View File
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
}
export function formatDate(value: number): string {
return new Date(value).toLocaleString();
}