refactor: split monolith into 3 microservices (frontend, backend, discord-gateway)
- Extract services into services/{frontend,backend,discord-gateway}
- Create packages/shared/ for shared logger, errors, utils, types
- Setup Modular MVC pattern in backend (controller→service→repository)
- Setup event-driven architecture in discord-gateway with Redis pub/sub
- Move Docker files to infra/docker/ with per-service Dockerfiles
- Update docker-compose.yml to use Traefik-only routing (no port exposes)
- Update GitHub Actions deploy workflow for multi-service matrix build
- Fix all import paths and resolve type errors across all services
- All 3 services pass tsc --noEmit clean
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bda8304bb9
commit
c48a0c5e3b
@@ -0,0 +1,250 @@
|
||||
import "dotenv/config";
|
||||
import { z } from "zod";
|
||||
import { ConfigError } from "../errors/errors.js";
|
||||
|
||||
const configSchema = z
|
||||
.object({
|
||||
DISCORD_TOKEN: z
|
||||
.string()
|
||||
.min(1, "DISCORD_TOKEN is required")
|
||||
.transform((value) => value.replace(/^("|')|(?:("|'))$/g, "")),
|
||||
VOICE_CHANNEL_ID: z.string().min(1).optional(),
|
||||
GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_GUILD_ID: z.string().min(1).optional(),
|
||||
TEXT_CHANNEL_ID: z.string().min(1).optional(),
|
||||
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||
VERBOSE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
RECORDINGS_DIR: z.string().default("./recordings"),
|
||||
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
|
||||
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||
WEBSERVER_PORT: z.coerce.number().positive().default(3000),
|
||||
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
|
||||
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(3000),
|
||||
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
|
||||
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
|
||||
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
|
||||
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
|
||||
AVATAR_SIZE: z.coerce.number().positive().default(64),
|
||||
LOG_LEVEL: z
|
||||
.enum(["error", "warn", "info", "http", "verbose", "debug", "silly"])
|
||||
.default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
MONITOR_GUILD_ID: z.string().min(1).optional(),
|
||||
TELE_UPLOAD_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://upload.asepharyana.tech/api/upload"),
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000),
|
||||
ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100),
|
||||
ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3),
|
||||
BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24),
|
||||
BACKLOG_SYNC_BATCH_SIZE: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(100)
|
||||
.default(100),
|
||||
AI_ANALYSIS_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||
OPENAI_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.openai.com/v1"),
|
||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://9router.asepharyana.my.id/v1"),
|
||||
/** Model used for text-only moderation (messages, badword analysis). */
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
/** Model used for image/video moderation (vision-capable model). */
|
||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||
/** Max concurrent LLM API calls (default: 5). */
|
||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||
/** Maximum image dimension in pixels before resize for vision API (default: 1024). */
|
||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1024),
|
||||
/** Maximum messages per text-only moderation batch (default: 20). */
|
||||
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20),
|
||||
/** Timeout in ms for individual media analysis calls (default: 60000). */
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(60000),
|
||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(15000),
|
||||
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||
/** Max messages fetched per conversation batch (token budget is the real constraint). */
|
||||
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
||||
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
|
||||
/** Token budget for target messages specifically (separate from context window). */
|
||||
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000),
|
||||
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(20),
|
||||
/**
|
||||
* How long a conversation is considered locked while being processed.
|
||||
* Must exceed (LLM timeout × max retries) + network overhead.
|
||||
* LLM client timeout=30s, retries=3 → minimum safe value ≈ 100s.
|
||||
*/
|
||||
AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(120000),
|
||||
/** Max concurrent individual-fallback LLM calls (effectively unlimited). */
|
||||
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(1000),
|
||||
/**
|
||||
* How many consecutive individual-fallback errors trigger the individual
|
||||
* circuit breaker (separate from the batch circuit breaker).
|
||||
*/
|
||||
AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
||||
/** NVIDIA Nemotron model identifier. */
|
||||
NVIDIA_NEMOTRON_MODEL: z
|
||||
.string()
|
||||
.default("nvidia/nemotron-3-content-safety"),
|
||||
/** NVIDIA Nemotron API base URL. */
|
||||
NVIDIA_NEMOTRON_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://integrate.api.nvidia.com/v1/chat/completions"),
|
||||
/** Groq API key for Llama Prompt Guard moderation fallback. */
|
||||
GROQ_API_KEY: z.string().optional(),
|
||||
/** Groq moderation model identifier. */
|
||||
GROQ_MODERATION_MODEL: z
|
||||
.string()
|
||||
.default("meta-llama/llama-prompt-guard-2-86m"),
|
||||
/** Groq API base URL. */
|
||||
GROQ_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.groq.com/openai/v1/chat/completions"),
|
||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0),
|
||||
AUTO_DELETE_FLAGGED_DRY_RUN: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5),
|
||||
AUTO_DELETE_ALLOWED_SEVERITIES: z
|
||||
.string()
|
||||
.default("critical,high,medium,low"),
|
||||
AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""),
|
||||
AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
|
||||
AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""),
|
||||
STICKER_CACHE_DIR: z.string().default("./sticker-cache"),
|
||||
STICKER_CACHE_MAX_SIZE_MB: z.coerce.number().int().positive().default(100),
|
||||
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_CLEANUP_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(24 * 60 * 60 * 1000),
|
||||
RETENTION_DRY_RUN: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
AUTO_MIGRATE_ON_STARTUP: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
DATABASE_URL: z.string().optional(),
|
||||
POSTGRES_HOST: z.string().default("localhost"),
|
||||
POSTGRES_PORT: z.coerce.number().int().positive().default(5432),
|
||||
POSTGRES_USER: z.string().optional(),
|
||||
POSTGRES_PASSWORD: z.string().optional(),
|
||||
POSTGRES_DB: z.string().optional(),
|
||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.AI_ANALYSIS_ENABLED) {
|
||||
// Continue to database validationa
|
||||
} else if (!value.AI_LLM_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["AI_LLM_API_KEY"],
|
||||
message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate PostgreSQL configuration
|
||||
if (!value.DATABASE_URL && !value.POSTGRES_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["DATABASE_URL"],
|
||||
message: "Either DATABASE_URL or POSTGRES_HOST must be provided",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema> & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
};
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
try {
|
||||
const parsed = configSchema.parse(env);
|
||||
return {
|
||||
...parsed,
|
||||
// AI text capture and analytics are pinned to the monitor guild.
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const messages = error.issues
|
||||
.map((e) => `${e.path.join(".")}: ${e.message}`)
|
||||
.join("\n");
|
||||
throw new ConfigError(`Configuration validation failed:\n${messages}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = loadConfig();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import type { PoolClient } from "pg";
|
||||
import { Pool } from "pg";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const logger = createChildLogger("drizzle");
|
||||
|
||||
let db: ReturnType<typeof drizzlePostgres> | null = null;
|
||||
let rawPool: Pool | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the PostgreSQL database connection.
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
if (db !== null) {
|
||||
return db;
|
||||
}
|
||||
|
||||
let pool: Pool;
|
||||
|
||||
if (config.DATABASE_URL) {
|
||||
pool = new Pool({
|
||||
connectionString: config.DATABASE_URL,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
} else {
|
||||
pool = new Pool({
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
rawPool = pool;
|
||||
db = drizzlePostgres(pool, { schema });
|
||||
|
||||
try {
|
||||
(db as { run?: (sql: string) => Promise<unknown> }).run = (sql: string) =>
|
||||
pool.query(sql);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
logger.info("PostgreSQL database initialized");
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized database instance.
|
||||
* Throws if database has not been initialized.
|
||||
*/
|
||||
export function getDatabase() {
|
||||
if (db === null) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function convertPlaceholdersForPostgres(sql: string) {
|
||||
let i = 0;
|
||||
return sql.replace(/\?/g, () => `$${++i}`);
|
||||
}
|
||||
|
||||
export async function executeAll(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function executeGet(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a function with a dedicated PostgreSQL client from the shared pool.
|
||||
* Use this for session-scoped operations such as advisory locks.
|
||||
*/
|
||||
export async function withDatabaseClient<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!rawPool) {
|
||||
throw new Error(
|
||||
"Database not initialized. Call initializeDatabase() first.",
|
||||
);
|
||||
}
|
||||
|
||||
const client = await rawPool.connect();
|
||||
try {
|
||||
return await callback(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the PostgreSQL connection pool.
|
||||
*/
|
||||
export async function closeDatabase() {
|
||||
if (rawPool !== null) {
|
||||
await rawPool.end();
|
||||
}
|
||||
|
||||
rawPool = null;
|
||||
db = null;
|
||||
logger.info("PostgreSQL database closed");
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import "dotenv/config";
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import {
|
||||
closeDatabase,
|
||||
initializeDatabase,
|
||||
withDatabaseClient,
|
||||
} from "./drizzle.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const logger = createChildLogger("migrate");
|
||||
const MIGRATION_LOCK_KEY_1 = 2026;
|
||||
const MIGRATION_LOCK_KEY_2 = 531;
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
try {
|
||||
logger.info("Starting PostgreSQL migrations");
|
||||
await initializeDatabase();
|
||||
|
||||
try {
|
||||
await withDatabaseClient(async (client) => {
|
||||
const db = drizzlePostgres(client, { schema });
|
||||
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [
|
||||
MIGRATION_LOCK_KEY_1,
|
||||
MIGRATION_LOCK_KEY_2,
|
||||
]);
|
||||
|
||||
try {
|
||||
await migratePostgres(db, {
|
||||
migrationsFolder: "./drizzle/migrations",
|
||||
});
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1, $2)", [
|
||||
MIGRATION_LOCK_KEY_1,
|
||||
MIGRATION_LOCK_KEY_2,
|
||||
]);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await closeDatabase();
|
||||
}
|
||||
|
||||
logger.info("PostgreSQL migrations completed successfully");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Migration failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
|
||||
const logger = createChildLogger("migrate-cli");
|
||||
|
||||
runMigrations()
|
||||
.then(() => {
|
||||
logger.info("Migrations completed");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.error({ error }, "Migration failed");
|
||||
process.exit(1);
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- Migration: 001_drop_unused_ai_columns.sql
|
||||
-- Date: 2026-05-30
|
||||
-- Description: Drop columns that are written but never read from messages table
|
||||
-- - ai_moderation_raw: raw LLM response, never consumed
|
||||
-- - ai_policy_version: hardcoded string, never used for decisions
|
||||
-- - ai_evidence: JSON evidence array, never read after write
|
||||
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS ai_moderation_raw;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS ai_policy_version;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS ai_evidence;
|
||||
@@ -0,0 +1,433 @@
|
||||
import {
|
||||
bigint as pgBigint,
|
||||
boolean as pgBoolean,
|
||||
foreignKey as pgForeignKey,
|
||||
index as pgIndex,
|
||||
integer as pgInteger,
|
||||
real as pgReal,
|
||||
pgTable,
|
||||
text as pgText,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
// PostgreSQL Schema
|
||||
// ==================
|
||||
|
||||
/**
|
||||
* Muxer Jobs Table (PostgreSQL)
|
||||
* Tracks audio post-processing jobs with status and retry logic
|
||||
*/
|
||||
export const pgMuxerJobsTable = pgTable(
|
||||
"muxer_jobs",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
data: pgText("data").notNull(),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "processing", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
attempts: pgInteger("attempts").notNull().default(0),
|
||||
maxAttempts: pgInteger("maxAttempts").notNull().default(3),
|
||||
createdAt: pgBigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: pgBigint("updatedAt", { mode: "number" }).notNull(),
|
||||
error: pgText("error"),
|
||||
},
|
||||
(table) => ({
|
||||
statusIdx: pgIndex("idx_muxer_jobs_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_muxer_jobs_createdAt").on(table.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Messages Table (PostgreSQL)
|
||||
* Stores text messages with AI moderation analysis
|
||||
*/
|
||||
export const pgMessagesTable = pgTable(
|
||||
"messages",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
thread_id: pgText("thread_id"),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
avatar_url: pgText("avatar_url"),
|
||||
content: pgText("content").notNull(),
|
||||
edited_content: pgText("edited_content"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
edited_at: pgBigint("edited_at", { mode: "number" }),
|
||||
deleted_at: pgBigint("deleted_at", { mode: "number" }),
|
||||
type: pgText("type", { enum: ["text", "edited", "deleted"] })
|
||||
.notNull()
|
||||
.default("text"),
|
||||
metadata: pgText("metadata"),
|
||||
ai_status: pgText("ai_status", {
|
||||
enum: ["pending", "clean", "warn", "flagged", "error"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
ai_moderation_flags: pgText("ai_moderation_flags"),
|
||||
ai_moderation_score: pgReal("ai_moderation_score"),
|
||||
ai_analysis: pgText("ai_analysis"),
|
||||
ai_categories: pgText("ai_categories"),
|
||||
ai_severity: pgText("ai_severity", {
|
||||
enum: ["none", "low", "medium", "high", "critical"],
|
||||
}),
|
||||
ai_confidence: pgReal("ai_confidence"),
|
||||
ai_recommended_action: pgText("ai_recommended_action", {
|
||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||
}),
|
||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_error: pgText("ai_error"),
|
||||
},
|
||||
(table) => ({
|
||||
channelIdx: pgIndex("idx_messages_channel").on(table.channel_id),
|
||||
userIdx: pgIndex("idx_messages_user").on(table.user_id),
|
||||
createdIdx: pgIndex("idx_messages_created").on(table.created_at),
|
||||
threadIdx: pgIndex("idx_messages_thread").on(table.thread_id),
|
||||
channelCreatedIdx: pgIndex("idx_messages_channel_created").on(
|
||||
table.channel_id,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
threadCreatedIdx: pgIndex("idx_messages_thread_created").on(
|
||||
table.thread_id,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
aiStatusCreatedIdx: pgIndex("idx_messages_ai_status_created").on(
|
||||
table.ai_status,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
guildAiStatusCreatedIdx: pgIndex("idx_messages_guild_ai_status_created").on(
|
||||
table.guild_id,
|
||||
table.ai_status,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
guildCreatedDeletedIdx: pgIndex("idx_messages_guild_created_deleted").on(
|
||||
table.guild_id,
|
||||
table.created_at,
|
||||
table.deleted_at,
|
||||
table.id,
|
||||
),
|
||||
channelAiStatusCreatedIdx: pgIndex(
|
||||
"idx_messages_channel_ai_status_created",
|
||||
).on(table.channel_id, table.ai_status, table.created_at, table.id),
|
||||
threadAiStatusCreatedIdx: pgIndex(
|
||||
"idx_messages_thread_ai_status_created",
|
||||
).on(table.thread_id, table.ai_status, table.created_at, table.id),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Attachments Table (PostgreSQL)
|
||||
* Stores attachment metadata with upload status tracking
|
||||
*/
|
||||
export const pgAttachmentsTable = pgTable(
|
||||
"attachments",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
thread_id: pgText("thread_id"),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
filename: pgText("filename").notNull(),
|
||||
size: pgInteger("size").notNull(),
|
||||
type: pgText("type").notNull(),
|
||||
discord_url: pgText("discord_url").notNull(),
|
||||
uploaded_url: pgText("uploaded_url"),
|
||||
upload_status: pgText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: pgText("upload_error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
channelIdx: pgIndex("idx_attachments_channel").on(table.channel_id),
|
||||
messageIdx: pgIndex("idx_attachments_message").on(table.message_id),
|
||||
statusIdx: pgIndex("idx_attachments_status").on(table.upload_status),
|
||||
channelCreatedIdx: pgIndex("idx_attachments_channel_created").on(
|
||||
table.channel_id,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
threadCreatedIdx: pgIndex("idx_attachments_thread_created").on(
|
||||
table.thread_id,
|
||||
table.created_at,
|
||||
table.id,
|
||||
),
|
||||
messageFk: pgForeignKey({
|
||||
columns: [table.message_id],
|
||||
foreignColumns: [pgMessagesTable.id],
|
||||
name: "fk_attachments_message_id",
|
||||
}).onDelete("cascade"),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* UI State Table (PostgreSQL)
|
||||
* Stores persistent UI state (e.g., selected channel, filter preferences)
|
||||
*/
|
||||
export const pgUIStateTable = pgTable("ui_state", {
|
||||
key: pgText("key").primaryKey(),
|
||||
value: pgText("value").notNull(),
|
||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI Analysis Runs Table (PostgreSQL)
|
||||
* Tracks AI analysis batch runs for conversation-level moderation
|
||||
*/
|
||||
export const pgAIAnalysisRunsTable = pgTable(
|
||||
"ai_analysis_runs",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
conversation_key: pgText("conversation_key").notNull(),
|
||||
target_message_ids: pgText("target_message_ids").notNull(), // JSON array
|
||||
model: pgText("model").notNull(),
|
||||
request_tokens_estimate: pgInteger("request_tokens_estimate"),
|
||||
response_raw: pgText("response_raw"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "processing", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
error: pgText("error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
completed_at: pgBigint("completed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
conversationKeyIdx: pgIndex("idx_ai_analysis_runs_conversation_key").on(
|
||||
table.conversation_key,
|
||||
),
|
||||
statusIdx: pgIndex("idx_ai_analysis_runs_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_ai_analysis_runs_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Voice Recordings Table (PostgreSQL)
|
||||
* Stores voice recording segment metadata and upload status
|
||||
*/
|
||||
export const pgVoiceRecordingsTable = pgTable(
|
||||
"voice_recordings",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
user_id: pgText("user_id").notNull(),
|
||||
username: pgText("username").notNull(),
|
||||
avatar_url: pgText("avatar_url"),
|
||||
guild_id: pgText("guild_id"),
|
||||
channel_id: pgText("channel_id"),
|
||||
channel_name: pgText("channel_name"),
|
||||
filename: pgText("filename").notNull(),
|
||||
size_bytes: pgInteger("size_bytes").notNull(),
|
||||
download_url: pgText("download_url"),
|
||||
upload_status: pgText("upload_status", {
|
||||
enum: ["pending", "uploaded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
upload_error: pgText("upload_error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id),
|
||||
channelIdIdx: pgIndex("idx_voice_recordings_channel_id").on(
|
||||
table.channel_id,
|
||||
),
|
||||
createdIdx: pgIndex("idx_voice_recordings_created_at").on(table.created_at),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Message Reviews Table (PostgreSQL)
|
||||
* Tracks manual reviews of messages flagged by AI moderation
|
||||
*/
|
||||
export const pgMessageReviewsTable = pgTable(
|
||||
"message_reviews",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id").notNull(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id").notNull(),
|
||||
reviewer_id: pgText("reviewer_id"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "approved", "rejected", "escalated"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
notes: pgText("notes"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
reviewed_at: pgBigint("reviewed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_message_reviews_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
statusIdx: pgIndex("idx_message_reviews_status").on(table.status),
|
||||
createdAtIdx: pgIndex("idx_message_reviews_created_at").on(
|
||||
table.created_at,
|
||||
),
|
||||
guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on(
|
||||
table.guild_id,
|
||||
table.status,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Moderation Actions Table (PostgreSQL)
|
||||
* Tracks actions taken on messages (delete, mute, etc.)
|
||||
*/
|
||||
export const pgModerationActionsTable = pgTable(
|
||||
"moderation_actions",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
message_id: pgText("message_id"),
|
||||
user_id: pgText("user_id"),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
action_type: pgText("action_type", {
|
||||
enum: [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
],
|
||||
}).notNull(),
|
||||
reason: pgText("reason"),
|
||||
executed_by: pgText("executed_by"),
|
||||
status: pgText("status", {
|
||||
enum: ["pending", "executed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
error: pgText("error"),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
executed_at: pgBigint("executed_at", { mode: "number" }),
|
||||
},
|
||||
(table) => ({
|
||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||
table.message_id,
|
||||
),
|
||||
userIdIdx: pgIndex("idx_moderation_actions_user_id").on(table.user_id),
|
||||
statusIdx: pgIndex("idx_moderation_actions_status").on(table.status),
|
||||
guildStatusIdx: pgIndex("idx_moderation_actions_guild_status").on(
|
||||
table.guild_id,
|
||||
table.status,
|
||||
table.created_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Retention Policies Table (PostgreSQL)
|
||||
* Defines data retention rules per guild/channel
|
||||
*/
|
||||
export const pgRetentionPoliciesTable = pgTable(
|
||||
"retention_policies",
|
||||
{
|
||||
id: pgText("id").primaryKey(),
|
||||
guild_id: pgText("guild_id").notNull(),
|
||||
channel_id: pgText("channel_id"),
|
||||
retention_days: pgInteger("retention_days").notNull().default(90),
|
||||
apply_to_media: pgBoolean("apply_to_media").notNull().default(true),
|
||||
apply_to_voice: pgBoolean("apply_to_voice").notNull().default(true),
|
||||
enabled: pgBoolean("enabled").notNull().default(true),
|
||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
guildIdIdx: pgIndex("idx_retention_policies_guild_id").on(table.guild_id),
|
||||
enabledIdx: pgIndex("idx_retention_policies_enabled").on(table.enabled),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Text Analysis Cache Table (PostgreSQL)
|
||||
* Caches per-normalized-text moderation analysis results so repeated
|
||||
* phrases reuse previously computed API / fallback results instead of
|
||||
* re-calling expensive LLM or external moderation APIs.
|
||||
*
|
||||
* Uses the FULL normalized text (not per-word) because context matters:
|
||||
* "kau" alone is clean, but "awas kau" can be a threat.
|
||||
*/
|
||||
export const pgTextAnalysisCacheTable = pgTable(
|
||||
"text_analysis_cache",
|
||||
{
|
||||
/** Normalized text (lowercase, whitespace-collapsed) — primary key. */
|
||||
text: pgText("text").primaryKey(),
|
||||
/** JSON array of moderation flags detected for this text (e.g. ["vulgar_language","harassment"]). */
|
||||
flags: pgText("flags").notNull().default("[]"),
|
||||
/** Which source produced this result: "local" | "nvidia" | "primary_ai" | "groq" | "vision_llm". */
|
||||
source: pgText("source", {
|
||||
enum: ["local", "nvidia", "primary_ai", "groq", "vision_llm"],
|
||||
})
|
||||
.notNull()
|
||||
.default("local"),
|
||||
/** Epoch millis when the analysis was stored. */
|
||||
analyzed_at: pgBigint("analyzed_at", { mode: "number" }).notNull(),
|
||||
/** Epoch millis when this cache entry expires. */
|
||||
expires_at: pgBigint("expires_at", { mode: "number" }).notNull(),
|
||||
/** How many times this cached text has been reused. */
|
||||
hit_count: pgInteger("hit_count").notNull().default(0),
|
||||
},
|
||||
(table) => ({
|
||||
expiresAtIdx: pgIndex("idx_text_analysis_cache_expires_at").on(
|
||||
table.expires_at,
|
||||
),
|
||||
sourceIdx: pgIndex("idx_text_analysis_cache_source").on(table.source),
|
||||
}),
|
||||
);
|
||||
|
||||
// Runtime table exports
|
||||
// =====================
|
||||
|
||||
export const muxerJobsTable = pgMuxerJobsTable;
|
||||
export const messagesTable = pgMessagesTable;
|
||||
export const attachmentsTable = pgAttachmentsTable;
|
||||
export const uiStateTable = pgUIStateTable;
|
||||
export const aiAnalysisRunsTable = pgAIAnalysisRunsTable;
|
||||
export const voiceRecordingsTable = pgVoiceRecordingsTable;
|
||||
export const messageReviewsTable = pgMessageReviewsTable;
|
||||
export const moderationActionsTable = pgModerationActionsTable;
|
||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||
export const textAnalysisCacheTable = pgTextAnalysisCacheTable;
|
||||
|
||||
// Export table types for use in queries
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||
|
||||
export type Message = typeof messagesTable.$inferSelect;
|
||||
export type MessageInsert = typeof messagesTable.$inferInsert;
|
||||
|
||||
export type Attachment = typeof attachmentsTable.$inferSelect;
|
||||
export type AttachmentInsert = typeof attachmentsTable.$inferInsert;
|
||||
|
||||
export type UIState = typeof uiStateTable.$inferSelect;
|
||||
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||
|
||||
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
|
||||
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
||||
|
||||
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
|
||||
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
|
||||
|
||||
export type MessageReview = typeof messageReviewsTable.$inferSelect;
|
||||
export type MessageReviewInsert = typeof messageReviewsTable.$inferInsert;
|
||||
|
||||
export type ModerationAction = typeof moderationActionsTable.$inferSelect;
|
||||
export type ModerationActionInsert = typeof moderationActionsTable.$inferInsert;
|
||||
|
||||
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
||||
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { getDatabase } from "./drizzle.js";
|
||||
import {
|
||||
type VoiceRecording,
|
||||
type VoiceRecordingInsert,
|
||||
voiceRecordingsTable,
|
||||
} from "./schema.js";
|
||||
|
||||
const logger = createChildLogger("voice-recording-repo");
|
||||
|
||||
interface QueryBuilder<T = unknown> extends PromiseLike<T> {
|
||||
from(...args: unknown[]): QueryBuilder<T>;
|
||||
where(...args: unknown[]): QueryBuilder<T>;
|
||||
orderBy(...args: unknown[]): QueryBuilder<T>;
|
||||
limit(...args: unknown[]): QueryBuilder<T>;
|
||||
offset(...args: unknown[]): QueryBuilder<T>;
|
||||
values(...args: unknown[]): QueryBuilder<T>;
|
||||
onConflictDoNothing(...args: unknown[]): QueryBuilder<T>;
|
||||
returning(...args: unknown[]): QueryBuilder<T>;
|
||||
set(...args: unknown[]): QueryBuilder<T>;
|
||||
}
|
||||
|
||||
interface RecordingDatabase {
|
||||
select<T = unknown[]>(...args: unknown[]): QueryBuilder<T>;
|
||||
insert<T = unknown>(...args: unknown[]): QueryBuilder<T>;
|
||||
update(...args: unknown[]): QueryBuilder<unknown>;
|
||||
}
|
||||
|
||||
function db(): RecordingDatabase {
|
||||
return getDatabase() as unknown as RecordingDatabase;
|
||||
}
|
||||
|
||||
export async function insertVoiceRecording(
|
||||
recording: VoiceRecordingInsert,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.insert(voiceRecordingsTable)
|
||||
.values(recording)
|
||||
.onConflictDoNothing();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
id: recording.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to insert voice recording",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoiceRecordingAsUploaded(
|
||||
id: string,
|
||||
downloadUrl: string,
|
||||
uploadedAt: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.update(voiceRecordingsTable)
|
||||
.set({
|
||||
download_url: downloadUrl,
|
||||
upload_status: "uploaded",
|
||||
uploaded_at: uploadedAt,
|
||||
})
|
||||
.where(eq(voiceRecordingsTable.id, id));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update voice recording status to uploaded",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateVoiceRecordingAsFailed(
|
||||
id: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db()
|
||||
.update(voiceRecordingsTable)
|
||||
.set({
|
||||
upload_status: "failed",
|
||||
upload_error: error,
|
||||
})
|
||||
.where(eq(voiceRecordingsTable.id, id));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ id, error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to update voice recording status to failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listVoiceRecordings(
|
||||
limit = 100,
|
||||
): Promise<VoiceRecording[]> {
|
||||
try {
|
||||
const rows = await db()
|
||||
.select()
|
||||
.from(voiceRecordingsTable)
|
||||
.orderBy(desc(voiceRecordingsTable.created_at))
|
||||
.limit(limit);
|
||||
return rows as VoiceRecording[];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list voice recordings",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type ClientOptions, Options } from "discord.js-selfbot-v13";
|
||||
|
||||
export function createDiscordClientOptions(): ClientOptions {
|
||||
return {
|
||||
makeCache: Options.cacheWithLimits({
|
||||
...Options.defaultMakeCacheSettings,
|
||||
MessageManager: 25,
|
||||
ReactionManager: 0,
|
||||
ReactionUserManager: 0,
|
||||
PresenceManager: 0,
|
||||
}),
|
||||
partials: ["USER", "CHANNEL", "GUILD_MEMBER", "MESSAGE"],
|
||||
sweepers: {
|
||||
messages: { interval: 300, lifetime: 600 },
|
||||
threads: { interval: 3600, lifetime: 14400 },
|
||||
},
|
||||
restRequestTimeout: 15_000,
|
||||
retryLimit: 2,
|
||||
restGlobalRateLimit: 45,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
public code: string;
|
||||
public statusCode: number;
|
||||
|
||||
constructor(message: string, code: string, statusCode: number = 500) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.name = "AppError";
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "CONFIG_ERROR", 500);
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "AUDIO_ERROR", 500);
|
||||
this.name = "AudioError";
|
||||
}
|
||||
}
|
||||
|
||||
export class VoiceConnectionError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, "VOICE_CONNECTION_ERROR", 500);
|
||||
this.name = "VoiceConnectionError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
public details?: Record<string, string[]>;
|
||||
|
||||
constructor(message: string, details?: Record<string, string[]>) {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
this.details = details;
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import winston from "winston";
|
||||
import { formatLogMetadata, serializeLogValue } from "./serialization.js";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const logLevel = process.env.LOG_LEVEL || (isDev ? "debug" : "info");
|
||||
const logsDir = path.resolve(process.cwd(), "logs");
|
||||
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
const metadataFormat = winston.format((info) => {
|
||||
const {
|
||||
level: _level,
|
||||
message: _message,
|
||||
timestamp: _timestamp,
|
||||
...metadata
|
||||
} = info;
|
||||
|
||||
for (const key of Object.keys(metadata)) {
|
||||
delete info[key];
|
||||
}
|
||||
|
||||
Object.assign(info, formatLogMetadata(metadata));
|
||||
return info;
|
||||
});
|
||||
|
||||
const consoleFormat = winston.format.printf((info) => {
|
||||
const { level, message, timestamp, context, ...metadata } = info;
|
||||
const contextLabel = context ? ` [${String(context)}]` : "";
|
||||
const metadataText = Object.keys(metadata).length
|
||||
? ` ${JSON.stringify(formatLogMetadata(metadata))}`
|
||||
: "";
|
||||
|
||||
return `${timestamp} ${level}${contextLabel}: ${message}${metadataText}`;
|
||||
});
|
||||
|
||||
export interface CustomLogger {
|
||||
error: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
warn: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
info: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
debug: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
trace: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
fatal: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
silent: (msgOrObj: any, msgOrArgs?: any, ...args: any[]) => void;
|
||||
child(options: { context: string } & Record<string, any>): CustomLogger;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const winstonLogger = winston.createLogger({
|
||||
level: logLevel,
|
||||
levels: winston.config.npm.levels,
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
metadataFormat(),
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp(),
|
||||
metadataFormat(),
|
||||
consoleFormat,
|
||||
),
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, "app.log"),
|
||||
format: winston.format.json(),
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, "error.log"),
|
||||
level: "error",
|
||||
format: winston.format.json(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
function wrapLogger(wLogger: winston.Logger): CustomLogger {
|
||||
const logAtLevel = (level: string) => {
|
||||
return (arg1: any, arg2?: any) => {
|
||||
if (arg1 instanceof Error) {
|
||||
wLogger.log(level, arg1.message, { error: arg1 });
|
||||
} else if (typeof arg1 === "object" && arg1 !== null) {
|
||||
const message = typeof arg2 === "string" ? arg2 : "";
|
||||
wLogger.log(level, message, { ...arg1 });
|
||||
} else {
|
||||
const message = typeof arg1 === "string" ? arg1 : String(arg1);
|
||||
const metadata = typeof arg2 === "object" && arg2 !== null ? arg2 : {};
|
||||
wLogger.log(level, message, metadata);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const wrapped: CustomLogger = {
|
||||
error: logAtLevel("error"),
|
||||
warn: logAtLevel("warn"),
|
||||
info: logAtLevel("info"),
|
||||
debug: logAtLevel("debug"),
|
||||
trace: logAtLevel("debug"),
|
||||
fatal: logAtLevel("error"),
|
||||
silent: () => {},
|
||||
child: (options: any) => {
|
||||
const childWinston = wLogger.child(options);
|
||||
return wrapLogger(childWinston);
|
||||
},
|
||||
};
|
||||
|
||||
const proxy = new Proxy(wrapped, {
|
||||
get(target, prop) {
|
||||
if (prop in target) {
|
||||
return (target as any)[prop];
|
||||
}
|
||||
const val = (wLogger as any)[prop];
|
||||
if (typeof val === "function") {
|
||||
return val.bind(wLogger);
|
||||
}
|
||||
return val;
|
||||
},
|
||||
});
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export const logger: CustomLogger = wrapLogger(winstonLogger);
|
||||
|
||||
export const createChildLogger = (context: string): CustomLogger => {
|
||||
return logger.child({ context });
|
||||
};
|
||||
|
||||
export const serializeLogValueForTest = serializeLogValue;
|
||||
export const formatLogMetadataForTest = formatLogMetadata;
|
||||
@@ -0,0 +1,109 @@
|
||||
export type LogMetadata = Record<string, unknown>;
|
||||
|
||||
type SerializedError = {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
code?: unknown;
|
||||
statusCode?: unknown;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const serializeError = (error: Error): SerializedError => {
|
||||
const serialized: SerializedError = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
|
||||
if (error.stack) {
|
||||
serialized.stack = error.stack;
|
||||
}
|
||||
|
||||
const errorWithFields = error as Error & {
|
||||
code?: unknown;
|
||||
statusCode?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
if (errorWithFields.code !== undefined) {
|
||||
serialized.code = errorWithFields.code;
|
||||
}
|
||||
|
||||
if (errorWithFields.statusCode !== undefined) {
|
||||
serialized.statusCode = errorWithFields.statusCode;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(errorWithFields)) {
|
||||
if (serialized[key] === undefined) {
|
||||
serialized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return serialized;
|
||||
};
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
export const serializeLogValue = (
|
||||
value: unknown,
|
||||
_seen: WeakSet<object> = new WeakSet(),
|
||||
): unknown => {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
if (value instanceof Error) {
|
||||
return serializeError(value);
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (value instanceof RegExp) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (_seen.has(value as object)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
_seen.add(value as object);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => serializeLogValue(item, _seen));
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [
|
||||
key,
|
||||
serializeLogValue(nestedValue, _seen),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return `[Object ${(value as any)?.constructor?.name ?? "unknown"}]`;
|
||||
} catch {
|
||||
return "[Object]";
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const formatLogMetadata = (metadata: LogMetadata): LogMetadata => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).map(([key, value]) => [
|
||||
key,
|
||||
serializeLogValue(value),
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import pRetry from "p-retry";
|
||||
import type { CustomLogger } from "../../shared/logger/logger.js";
|
||||
|
||||
export interface RetryOptions {
|
||||
retries?: number;
|
||||
minTimeout?: number;
|
||||
maxTimeout?: number;
|
||||
factor?: number;
|
||||
logger?: CustomLogger;
|
||||
}
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
retries = 3,
|
||||
minTimeout = 1000,
|
||||
maxTimeout = 30000,
|
||||
factor = 2,
|
||||
logger,
|
||||
} = options;
|
||||
|
||||
return pRetry(fn, {
|
||||
retries,
|
||||
minTimeout,
|
||||
maxTimeout,
|
||||
factor,
|
||||
onFailedAttempt: (error) => {
|
||||
if (logger) {
|
||||
logger.warn(
|
||||
{
|
||||
attempt: error.attemptNumber,
|
||||
retriesLeft: error.retriesLeft,
|
||||
error: error.error,
|
||||
},
|
||||
"Retry attempt",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user