chore: auto-commit task -
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "frontend/src/styles.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "frontend/src/components",
|
||||
"utils": "frontend/src/lib/utils",
|
||||
"ui": "frontend/src/components/ui",
|
||||
"lib": "frontend/src/lib",
|
||||
"hooks": "frontend/src/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { closeDatabase, initializeDatabase } from "../database/drizzle.js";
|
||||
import { runMigrations } from "../database/migrate.js";
|
||||
import { createDiscordClientOptions } from "../discordClientOptions.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { startPendingAIAnalysisWorker } from "../moderation/aiAnalyzer.js";
|
||||
import { syncBacklogMessages } from "../moderation/backlogSync.js";
|
||||
import { registerMessageCapture } from "../moderation/messageCapture.js";
|
||||
import { discordPlayer } from "../player.js";
|
||||
import { VoiceController } from "../voiceController.js";
|
||||
import { startWebserver } from "../webserver.js";
|
||||
import { createGracefulShutdown } from "./shutdown.js";
|
||||
|
||||
export async function initializeApp() {
|
||||
const logger = createChildLogger("bot");
|
||||
|
||||
if (!config.AI_LLM_API_KEY) {
|
||||
logger.error(
|
||||
"AI_LLM_API_KEY is missing from environment. Force closing application as AI environment is required.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const token = config.DISCORD_TOKEN;
|
||||
logger.info(
|
||||
{ hasToken: token.length > 0, tokenLength: token.length },
|
||||
"Config loaded",
|
||||
);
|
||||
|
||||
logger.info("Creating Discord client");
|
||||
const client = new Client(createDiscordClientOptions());
|
||||
const voiceController = new VoiceController(client);
|
||||
|
||||
const gracefulShutdown = createGracefulShutdown({
|
||||
logger,
|
||||
closeDatabase,
|
||||
voiceController,
|
||||
discordPlayer,
|
||||
client,
|
||||
});
|
||||
|
||||
try {
|
||||
if (config.AUTO_MIGRATE_ON_STARTUP) {
|
||||
logger.info(
|
||||
"AUTO_MIGRATE_ON_STARTUP enabled; running database migrations",
|
||||
);
|
||||
await runMigrations();
|
||||
}
|
||||
|
||||
logger.info("Initializing database");
|
||||
await initializeDatabase();
|
||||
logger.info("PostgreSQL database initialized");
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to initialize database");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
client.on("debug", (msg) => {
|
||||
if (
|
||||
msg.includes("[VOICE") ||
|
||||
msg.includes("[ffmpeg") ||
|
||||
msg.toLowerCase().includes("error") ||
|
||||
msg.toLowerCase().includes("stream")
|
||||
) {
|
||||
logger.info({ debugMsg: msg }, "Discord Client Debug");
|
||||
} else if (config.VERBOSE) {
|
||||
logger.debug({ debugMsg: msg }, "Discord Client Debug");
|
||||
}
|
||||
});
|
||||
|
||||
client.on("ready", async () => {
|
||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||
registerMessageCapture(client);
|
||||
startPendingAIAnalysisWorker(client);
|
||||
syncBacklogMessages(client).catch((error) => {
|
||||
logger.warn({ error }, "Backlog sync failed");
|
||||
});
|
||||
await startWebserver(config.WEBSERVER_PORT, client, voiceController);
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error({ error: err }, "Client error");
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
gracefulShutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
gracefulShutdown("SIGTERM");
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.error({ error: err }, "Uncaught exception");
|
||||
gracefulShutdown("uncaughtException");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
logger.error({ reason, promise }, "Unhandled rejection");
|
||||
gracefulShutdown("unhandledRejection");
|
||||
});
|
||||
|
||||
logger.info("Calling Discord client.login");
|
||||
client
|
||||
.login(token)
|
||||
.then(() => {
|
||||
logger.info("Discord client.login resolved");
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ error }, "Discord client.login failed");
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import type { closeDatabase } from "../database/drizzle.js";
|
||||
import type { createChildLogger } from "../logger.js";
|
||||
import type { discordPlayer } from "../player.js";
|
||||
import type { VoiceController } from "../voiceController.js";
|
||||
|
||||
type Logger = ReturnType<typeof createChildLogger>;
|
||||
type CloseDatabase = typeof closeDatabase;
|
||||
type DiscordPlayer = typeof discordPlayer;
|
||||
|
||||
export interface GracefulShutdownOptions {
|
||||
logger: Logger;
|
||||
closeDatabase: CloseDatabase;
|
||||
voiceController: VoiceController;
|
||||
discordPlayer: DiscordPlayer;
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export function createGracefulShutdown(options: GracefulShutdownOptions) {
|
||||
let isShuttingDown = false;
|
||||
|
||||
return async function gracefulShutdown(signal: string) {
|
||||
if (isShuttingDown) {
|
||||
options.logger.warn(`Already shutting down, ignoring ${signal}`);
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
options.logger.info({ signal }, "Graceful shutdown initiated");
|
||||
|
||||
try {
|
||||
options.logger.info("Closing database...");
|
||||
await options.closeDatabase();
|
||||
options.logger.info("Database closed");
|
||||
|
||||
options.logger.info("Stopping voice connection...");
|
||||
await options.voiceController.disconnect();
|
||||
|
||||
options.logger.info("Pausing player...");
|
||||
options.discordPlayer.pause();
|
||||
|
||||
options.logger.info("Destroying Discord client...");
|
||||
try {
|
||||
options.client.destroy();
|
||||
} catch (err) {
|
||||
options.logger.warn({ error: err }, "Error destroying client");
|
||||
}
|
||||
|
||||
options.logger.info("Graceful shutdown completed");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
options.logger.error({ error: err }, "Error during graceful shutdown");
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { spawn } from "child_process";
|
||||
|
||||
export interface MuxFfmpegArgsOptions {
|
||||
inputs: string[];
|
||||
filter: string;
|
||||
output: string;
|
||||
codec: string;
|
||||
audioFrequency?: number;
|
||||
audioChannels?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ffmpeg argument array for muxing audio clips.
|
||||
*/
|
||||
export function buildMuxFfmpegArgs(options: MuxFfmpegArgsOptions): string[] {
|
||||
const args: string[] = ["-y"];
|
||||
|
||||
for (const input of options.inputs) {
|
||||
args.push("-i", input);
|
||||
}
|
||||
|
||||
args.push("-filter_complex", options.filter);
|
||||
args.push("-map", "[out]");
|
||||
args.push("-codec:a", options.codec);
|
||||
|
||||
if (options.audioFrequency !== undefined) {
|
||||
args.push("-ar", String(options.audioFrequency));
|
||||
}
|
||||
|
||||
if (options.audioChannels !== undefined) {
|
||||
args.push("-ac", String(options.audioChannels));
|
||||
}
|
||||
|
||||
args.push(options.output);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg with the given arguments.
|
||||
* Resolves on successful (code 0) exit, rejects on error or non-zero exit.
|
||||
*/
|
||||
export function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffmpeg", args, {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export function upsample24kMonoTo48kStereo(mono24k: Buffer): Buffer {
|
||||
const numSamples = mono24k.length / 2;
|
||||
const out = Buffer.alloc(numSamples * 8);
|
||||
|
||||
for (let i = 0; i < numSamples; i++) {
|
||||
const sample = mono24k.readInt16LE(i * 2);
|
||||
const base = i * 8;
|
||||
out.writeInt16LE(sample, base);
|
||||
out.writeInt16LE(sample, base + 2);
|
||||
out.writeInt16LE(sample, base + 4);
|
||||
out.writeInt16LE(sample, base + 6);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rmsDb(pcm: Buffer): number {
|
||||
let sum = 0;
|
||||
const samples = pcm.length / 2;
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const sample = pcm.readInt16LE(i * 2) / 32768;
|
||||
sum += sample * sample;
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / samples);
|
||||
return 20 * Math.log10(rms);
|
||||
}
|
||||
-228
@@ -1,228 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import { z } from "zod";
|
||||
import { ConfigError } from "./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(0),
|
||||
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(0),
|
||||
/** 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),
|
||||
// AI moderation uses the Primary LLM (AI_LLM_*) endpoint only.
|
||||
// No NVIDIA or Groq fallback.
|
||||
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(""),
|
||||
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();
|
||||
@@ -1,129 +0,0 @@
|
||||
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import type { PoolClient } from "pg";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../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");
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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 "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import * as schema from "./schema.js";
|
||||
import {
|
||||
closeDatabase,
|
||||
initializeDatabase,
|
||||
withDatabaseClient,
|
||||
} from "./drizzle.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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { runMigrations } from "./migrate.js";
|
||||
|
||||
const logger = createChildLogger("migrate-cli");
|
||||
|
||||
runMigrations()
|
||||
.then(() => {
|
||||
logger.info("Migrations completed");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ error }, "Migration failed");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,464 +0,0 @@
|
||||
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" | "primary_ai" | "vision_llm". */
|
||||
source: pgText("source", {
|
||||
enum: ["local", "primary_ai", "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),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Sticker Cache Table (PostgreSQL)
|
||||
* Stores base64-encoded sticker images for fast retrieval in media moderation.
|
||||
* Replaces the file-based .dat + index.json cache.
|
||||
*
|
||||
* TTL: 7 days (enforced at query time via fetched_at)
|
||||
* Eviction: LRU by fetched_at, max 100MB total
|
||||
*/
|
||||
export const pgStickerCacheTable = pgTable(
|
||||
"sticker_cache",
|
||||
{
|
||||
/** Sanitized sticker name (encodeURIComponent + %→_) — primary key. */
|
||||
name: pgText("name").primaryKey(),
|
||||
/** Base64-encoded image data. */
|
||||
base64: pgText("base64").notNull(),
|
||||
/** MIME type of the image (e.g. "image/png", "image/gif"). */
|
||||
mime_type: pgText("mime_type").notNull(),
|
||||
/** Byte length of the base64 string (for efficient SUM() eviction queries). */
|
||||
size: pgInteger("size").notNull(),
|
||||
/** Epoch millis when this entry was stored. Used for TTL and LRU eviction. */
|
||||
fetched_at: pgBigint("fetched_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
fetchedAtIdx: pgIndex("idx_sticker_cache_fetched_at").on(table.fetched_at),
|
||||
}),
|
||||
);
|
||||
|
||||
// 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 const stickerCacheTable = pgStickerCacheTable;
|
||||
|
||||
// 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;
|
||||
|
||||
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
||||
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
||||
@@ -1,115 +0,0 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createChildLogger } from "../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;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import helmet from "helmet";
|
||||
import { AppError } from "../errors.js";
|
||||
import type { createChildLogger } from "../logger.js";
|
||||
import type { MediaController } from "../media/mediaController.js";
|
||||
import type { ModerationBroadcaster } from "../moderation/types.js";
|
||||
import { createAnalysisRoutes } from "../routes/analysisRoutes.js";
|
||||
import { createAppConfigRoutes } from "../routes/appConfigRoutes.js";
|
||||
import { createAnalyticsRoutes } from "../routes/analyticsRoutes.js";
|
||||
import { createMediaRoutes } from "../routes/mediaRoutes.js";
|
||||
import { createMessageRoutes } from "../routes/messageRoutes.js";
|
||||
import { createRecordingsRoutes } from "../routes/recordingsRoutes.js";
|
||||
import { createReviewRoutes } from "../routes/reviewRoutes.js";
|
||||
import { createSyncRoutes } from "../routes/syncRoutes.js";
|
||||
import { createUIStateRoutes } from "../routes/uiStateRoutes.js";
|
||||
import { createVoiceRoutes } from "../routes/voiceRoutes.js";
|
||||
import type { SharedUIStatePatch } from "../state/uiState.js";
|
||||
import type { VoiceController } from "../voiceController.js";
|
||||
import { createHealthRoutes } from "./health.js";
|
||||
|
||||
const publicDir = path.resolve(process.cwd(), "public");
|
||||
const reactAppDir = path.join(publicDir, "app");
|
||||
|
||||
type Logger = ReturnType<typeof createChildLogger>;
|
||||
|
||||
export interface CreateHttpAppOptions {
|
||||
client: Client;
|
||||
voiceController: VoiceController;
|
||||
mediaController: MediaController;
|
||||
broadcaster: ModerationBroadcaster;
|
||||
adminPassword: string;
|
||||
getSharedUIState: () => any;
|
||||
patchSharedUIState: (patch: SharedUIStatePatch) => any;
|
||||
activeUserCount: () => number;
|
||||
wsClientCount: () => number;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function createHttpApp(options: CreateHttpAppOptions) {
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith("/api/")) {
|
||||
res.set("Cache-Control", "no-store");
|
||||
}
|
||||
res.on("finish", () => {
|
||||
if (req.originalUrl.startsWith("/.well-known/appspecific/")) return;
|
||||
if (req.originalUrl === "/favicon.ico") return;
|
||||
if (res.statusCode >= 400) {
|
||||
options.logger.error(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
},
|
||||
"HTTP request failed",
|
||||
);
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
app.use(express.static(publicDir));
|
||||
app.use(express.static(reactAppDir));
|
||||
|
||||
app.get("/", (_req: Request, res: Response) => {
|
||||
const reactIndex = path.join(reactAppDir, "index.html");
|
||||
if (fs.existsSync(reactIndex)) {
|
||||
res.sendFile(reactIndex);
|
||||
return;
|
||||
}
|
||||
res
|
||||
.status(503)
|
||||
.send("React dashboard is not built. Run pnpm run build:web.");
|
||||
});
|
||||
|
||||
// Health and auth routes
|
||||
app.use(
|
||||
createHealthRoutes({
|
||||
adminPassword: options.adminPassword,
|
||||
activeUserCount: options.activeUserCount,
|
||||
wsClientCount: options.wsClientCount,
|
||||
}),
|
||||
);
|
||||
|
||||
// Route modules
|
||||
app.use(
|
||||
"/api",
|
||||
createUIStateRoutes({
|
||||
getSharedUIState: options.getSharedUIState,
|
||||
patchSharedUIState: options.patchSharedUIState,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/api",
|
||||
createVoiceRoutes({
|
||||
voiceController: options.voiceController,
|
||||
patchSharedUIState: options.patchSharedUIState,
|
||||
broadcaster: options.broadcaster,
|
||||
adminPassword: options.adminPassword,
|
||||
}),
|
||||
);
|
||||
app.use("/api", createMessageRoutes());
|
||||
app.use("/api", createAnalysisRoutes());
|
||||
app.use("/api", createAppConfigRoutes());
|
||||
app.use("/api", createReviewRoutes());
|
||||
app.use("/api", createAnalyticsRoutes());
|
||||
app.use("/api", createSyncRoutes(options.client));
|
||||
app.use("/api", createRecordingsRoutes());
|
||||
app.use(
|
||||
"/api",
|
||||
createMediaRoutes(options.mediaController, {
|
||||
adminPassword: options.adminPassword,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (error instanceof AppError) {
|
||||
res.status(error.statusCode).json({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
options.logger.error({ error }, "Unhandled webserver error");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "Internal server error",
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import { getMetrics, uptimeGauge } from "../metrics.js";
|
||||
|
||||
export interface HealthRoutesOptions {
|
||||
adminPassword: string;
|
||||
activeUserCount: () => number;
|
||||
wsClientCount: () => number;
|
||||
}
|
||||
|
||||
export function createHealthRoutes(options: HealthRoutesOptions) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
activeUsers: options.activeUserCount(),
|
||||
wsClients: options.wsClientCount(),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/metrics", async (_req, res) => {
|
||||
res.set("Content-Type", "text/plain");
|
||||
uptimeGauge.set(process.uptime());
|
||||
res.send(await getMetrics());
|
||||
});
|
||||
|
||||
router.post("/api/auth/login", (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (password === options.adminPassword) {
|
||||
res.json({ ok: true });
|
||||
return;
|
||||
}
|
||||
res.status(401).json({ error: "Invalid password" });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import http from "node:http";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { MediaController } from "../media/mediaController.js";
|
||||
import { createScreenShareController } from "../media/screenShareController.js";
|
||||
import { createBroadcaster } from "../moderation/broadcaster.js";
|
||||
import {
|
||||
initializeMediaSettings,
|
||||
persistMediaSettings,
|
||||
} from "../state/mediaSettings.js";
|
||||
import { createSharedUIStateStore } from "../state/uiState.js";
|
||||
import { Streamer } from "../streaming/index.js";
|
||||
import type { VoiceController } from "../voiceController.js";
|
||||
import {
|
||||
exposeActiveUserGlobal,
|
||||
exposeModerationGlobals,
|
||||
exposePcmBroadcastGlobal,
|
||||
exposeVideoBroadcastGlobal,
|
||||
} from "../ws/broadcastGlobals.js";
|
||||
import { startWebSocketServer } from "../ws/server.js";
|
||||
import { createHttpApp } from "./app.js";
|
||||
|
||||
const serverLogger = createChildLogger("webserver");
|
||||
|
||||
const activeUsers = new Map<
|
||||
string,
|
||||
{ username: string; avatar: string; speaking: boolean }
|
||||
>();
|
||||
|
||||
export async function startWebserver(
|
||||
port: number = 3000,
|
||||
client: Client,
|
||||
voiceController: VoiceController,
|
||||
) {
|
||||
const { getSharedUIState, patchSharedUIState } =
|
||||
await createSharedUIStateStore();
|
||||
let mediaSettings = await initializeMediaSettings();
|
||||
|
||||
const wsPath = "/ws";
|
||||
|
||||
const broadcaster = createBroadcaster();
|
||||
exposeModerationGlobals(broadcaster, config.ADMIN_PASSWORD);
|
||||
|
||||
const streamer = new Streamer(client);
|
||||
const screenController = createScreenShareController({
|
||||
getVoiceStatus: () => voiceController.getStatus(),
|
||||
streamer,
|
||||
useTranscoder: true,
|
||||
onBeforeStreamStart: async () => {
|
||||
await voiceController.disconnect();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
},
|
||||
onAfterStreamEnd: async (guildId: string, channelId: string) => {
|
||||
const current = voiceController.getStatus();
|
||||
if (current.connected && current.activeGuildId === guildId) return;
|
||||
await voiceController.connect(guildId, channelId);
|
||||
},
|
||||
});
|
||||
|
||||
const mediaController = new MediaController({
|
||||
isVoiceConnected: () => voiceController.getStatus().connected,
|
||||
isBrowserStreaming: () => getSharedUIState().isStreaming,
|
||||
screenController,
|
||||
onStateChange: (state) => broadcaster.mediaState(state),
|
||||
initialMusicVolume: mediaSettings.musicVolume,
|
||||
onMusicVolumeChange: async (volume) => {
|
||||
mediaSettings = { ...mediaSettings, musicVolume: volume };
|
||||
await persistMediaSettings(mediaSettings);
|
||||
},
|
||||
});
|
||||
|
||||
const app = createHttpApp({
|
||||
client,
|
||||
voiceController,
|
||||
mediaController,
|
||||
broadcaster,
|
||||
adminPassword: config.ADMIN_PASSWORD,
|
||||
getSharedUIState,
|
||||
patchSharedUIState,
|
||||
activeUserCount: () => activeUsers.size,
|
||||
wsClientCount: () => broadcaster.clientCount(),
|
||||
logger: serverLogger,
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
function broadcastUserState() {
|
||||
const users = Array.from(activeUsers.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
...data,
|
||||
}));
|
||||
broadcaster.userState(users);
|
||||
}
|
||||
|
||||
exposePcmBroadcastGlobal(broadcaster);
|
||||
exposeVideoBroadcastGlobal(() => broadcaster.getClients(), serverLogger);
|
||||
exposeActiveUserGlobal(activeUsers, broadcastUserState);
|
||||
|
||||
startWebSocketServer({
|
||||
server,
|
||||
port,
|
||||
wsPath,
|
||||
broadcaster,
|
||||
activeUsers,
|
||||
getSharedUIState,
|
||||
mediaController,
|
||||
logger: serverLogger,
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
serverLogger.info({ port }, "Web interface listening");
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import "./mock-crc";
|
||||
import "libsodium-wrappers";
|
||||
import "@snazzah/davey";
|
||||
import "dotenv/config";
|
||||
import { initializeApp } from "./app/bootstrap.js";
|
||||
import { createChildLogger } from "./logger.js";
|
||||
|
||||
const logger = createChildLogger("bot");
|
||||
// Initialize the app
|
||||
initializeApp().catch((error) => {
|
||||
logger.error({ error }, "Failed to initialize app");
|
||||
process.exit(1);
|
||||
});
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import winston from "winston";
|
||||
import { formatLogMetadata, serializeLogValue } from "./loggerSerialization.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;
|
||||
@@ -1,110 +0,0 @@
|
||||
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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Non-plain objects (ClientRequest, IncomingMessage, etc.) — serialize as safe string
|
||||
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),
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -1,280 +0,0 @@
|
||||
import { AppError } from "../errors.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { discordPlayer } from "../player.js";
|
||||
import { MediaQueue } from "./mediaQueue.js";
|
||||
import { resolveMediaSource } from "./mediaResolver.js";
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaState,
|
||||
MusicPlayback,
|
||||
MusicPlayer,
|
||||
QueueMediaOptions,
|
||||
ResolvedMediaSource,
|
||||
ScreenShareController,
|
||||
ScreenSharePlayback,
|
||||
} from "./mediaTypes.js";
|
||||
import { createMusicPlayer } from "./musicPlayer.js";
|
||||
|
||||
const logger = createChildLogger("mediaController");
|
||||
|
||||
export interface MediaControllerDependencies {
|
||||
isVoiceConnected?: () => boolean;
|
||||
isBrowserStreaming?: () => boolean;
|
||||
resolveMediaSource?: (
|
||||
source: string,
|
||||
mode?: MediaMode,
|
||||
) => Promise<ResolvedMediaSource>;
|
||||
musicPlayer?: MusicPlayer;
|
||||
screenController?: ScreenShareController;
|
||||
onStateChange?: (state: MediaState) => void;
|
||||
initialMusicVolume?: number;
|
||||
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||
setMusicVolume?: (volume: number) => void;
|
||||
}
|
||||
|
||||
export class MediaController {
|
||||
private readonly queueStore = new MediaQueue();
|
||||
private readonly musicPlayer: MusicPlayer;
|
||||
private playback: MusicPlayback | null = null;
|
||||
private playbackToken = 0;
|
||||
private skipInProgress = false;
|
||||
private screenPlayback: ScreenSharePlayback | null = null;
|
||||
private activeMode: MediaMode | null = null;
|
||||
private musicVolume: number;
|
||||
private readonly setPlayerMusicVolume: (volume: number) => void;
|
||||
|
||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||
this.setPlayerMusicVolume =
|
||||
dependencies.setMusicVolume ??
|
||||
((volume) => {
|
||||
discordPlayer.setMusicVolume(volume);
|
||||
});
|
||||
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
|
||||
this.setPlayerMusicVolume(this.musicVolume);
|
||||
}
|
||||
|
||||
getState(): MediaState {
|
||||
const snapshot = this.queueStore.snapshot();
|
||||
return {
|
||||
playing:
|
||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||
musicVolume: this.musicVolume,
|
||||
...snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async setMusicVolume(volume: number): Promise<MediaState> {
|
||||
const nextVolume = normalizeVolume(volume, this.musicVolume);
|
||||
if (this.musicVolume === nextVolume) return this.emitState();
|
||||
this.musicVolume = nextVolume;
|
||||
this.setPlayerMusicVolume(nextVolume);
|
||||
await this.dependencies.onMusicVolumeChange?.(nextVolume);
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
async queue(
|
||||
source: string,
|
||||
options: QueueMediaOptions = {},
|
||||
): Promise<MediaState> {
|
||||
const mode = options.mode ?? "music";
|
||||
|
||||
logger.info({ source: source.slice(0, 100), mode }, "Queuing media");
|
||||
const resolved = await (
|
||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||
)(source, mode);
|
||||
logger.info(
|
||||
{ title: resolved.title, kind: resolved.kind },
|
||||
"Media resolved",
|
||||
);
|
||||
|
||||
if (mode === "screen") {
|
||||
// Stop current music if any
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
this.assertCanStartMusic();
|
||||
this.queueStore.clear();
|
||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||
this.queueStore.startNext();
|
||||
logger.info({ title: resolved.title }, "Starting screen share");
|
||||
return this.startScreen(resolved.source);
|
||||
}
|
||||
|
||||
// mode === "music"
|
||||
// If a screen share is active outside of this controller (browser-owned),
|
||||
// reject to avoid stealing the shared player. If this controller started
|
||||
// the screenPlayback, stop it and proceed.
|
||||
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
||||
if (
|
||||
this.dependencies.screenController?.isActive() &&
|
||||
!this.screenPlayback
|
||||
) {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
}
|
||||
|
||||
this.assertCanStartMusic();
|
||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||
logger.info(
|
||||
{
|
||||
title: resolved.title,
|
||||
queueSize: this.queueStore.snapshot().queue.length,
|
||||
},
|
||||
"Added to queue",
|
||||
);
|
||||
this.startNextIfIdle();
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
async skip(): Promise<MediaState> {
|
||||
if (this.skipInProgress) {
|
||||
throw new AppError(
|
||||
"Skip already in progress",
|
||||
"MEDIA_SKIP_IN_PROGRESS",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
this.skipInProgress = true;
|
||||
try {
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
this.queueStore.completeCurrent();
|
||||
this.startNextIfIdle();
|
||||
return this.emitState();
|
||||
} finally {
|
||||
this.skipInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<MediaState> {
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
this.queueStore.clear();
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
private assertCanStartMusic(): void {
|
||||
const isVoiceConnected =
|
||||
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||
if (!isVoiceConnected()) {
|
||||
throw new AppError(
|
||||
"Connect to a voice channel before playing media",
|
||||
"VOICE_NOT_CONNECTED",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.dependencies.isBrowserStreaming?.()) {
|
||||
throw new AppError(
|
||||
"Stop browser microphone streaming before playing media",
|
||||
"BROWSER_STREAM_ACTIVE",
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async startScreen(source: string): Promise<MediaState> {
|
||||
const screenController = this.dependencies.screenController;
|
||||
if (!screenController) {
|
||||
throw new AppError(
|
||||
"Screen sharing is unavailable",
|
||||
"SCREEN_UNAVAILABLE",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
this.activeMode = "screen";
|
||||
try {
|
||||
this.screenPlayback = await screenController.start(source);
|
||||
} catch (error) {
|
||||
this.activeMode = null;
|
||||
this.queueStore.failCurrent();
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.screenPlayback.done.then(
|
||||
() => this.finishScreen(),
|
||||
() => this.finishScreen(),
|
||||
);
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
private finishScreen(): void {
|
||||
if (!this.screenPlayback || this.activeMode !== "screen") return;
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
this.queueStore.completeCurrent();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private startNextIfIdle(): void {
|
||||
if (this.playback) return;
|
||||
const item = this.queueStore.startNext();
|
||||
if (!item) {
|
||||
logger.debug("Queue empty, no playback started");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = ++this.playbackToken;
|
||||
logger.info(
|
||||
{
|
||||
title: item.title,
|
||||
token,
|
||||
queueSize: this.queueStore.snapshot().queue.length,
|
||||
},
|
||||
"Starting playback",
|
||||
);
|
||||
try {
|
||||
this.playback = this.musicPlayer.play(item);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to start playback");
|
||||
this.queueStore.failCurrent();
|
||||
this.playback = null;
|
||||
this.startNextIfIdle();
|
||||
this.emitState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.playback.done.then(
|
||||
() => this.finishCurrent(token, false),
|
||||
(err) => {
|
||||
logger.error({ err, token }, "Playback failed");
|
||||
this.finishCurrent(token, true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private finishCurrent(token: number, failed: boolean): void {
|
||||
if (token !== this.playbackToken) return;
|
||||
this.playback = null;
|
||||
if (failed) {
|
||||
this.queueStore.failCurrent();
|
||||
} else {
|
||||
this.queueStore.completeCurrent();
|
||||
}
|
||||
this.startNextIfIdle();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private emitState(): MediaState {
|
||||
const state = this.getState();
|
||||
this.dependencies.onStateChange?.(state);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVolume(value: number | undefined, fallback: number): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(0, Math.min(1, value as number));
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaQueueItem,
|
||||
MediaState,
|
||||
ResolvedMediaSource,
|
||||
} from "./mediaTypes.js";
|
||||
|
||||
export class MediaQueue {
|
||||
private current: MediaQueueItem | null = null;
|
||||
private readonly items: MediaQueueItem[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly createId: () => string = () => crypto.randomUUID(),
|
||||
private readonly now = () => Date.now(),
|
||||
) {}
|
||||
|
||||
add(
|
||||
source: ResolvedMediaSource,
|
||||
mode: MediaQueueItem["mode"] = "music",
|
||||
requestedBy = "dashboard",
|
||||
): MediaQueueItem {
|
||||
const item: MediaQueueItem = {
|
||||
id: this.createId(),
|
||||
mode,
|
||||
requestedBy,
|
||||
addedAt: this.now(),
|
||||
status: "queued",
|
||||
...source,
|
||||
};
|
||||
this.items.push(item);
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
startNext(): MediaQueueItem | null {
|
||||
if (this.current) return { ...this.current };
|
||||
const next = this.items.shift();
|
||||
if (!next) return null;
|
||||
this.current = { ...next, status: "playing" };
|
||||
return { ...this.current };
|
||||
}
|
||||
|
||||
completeCurrent(): void {
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
failCurrent(): MediaQueueItem | null {
|
||||
if (!this.current) return null;
|
||||
const failed = { ...this.current, status: "failed" as const };
|
||||
this.current = null;
|
||||
return failed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.current = null;
|
||||
this.items.length = 0;
|
||||
}
|
||||
|
||||
snapshot(): Pick<MediaState, "current" | "queue"> {
|
||||
return {
|
||||
current: this.current ? { ...this.current } : null,
|
||||
queue: this.items.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AppError } from "../errors.js";
|
||||
import type { MediaMode, ResolvedMediaSource } from "./mediaTypes.js";
|
||||
import { createPlayDlResolver } from "./playDlResolver.js";
|
||||
import { createYtDlp, type YtDlpClient } from "./ytdlp.js";
|
||||
|
||||
type PlayDlResolver = ReturnType<typeof createPlayDlResolver>;
|
||||
|
||||
export interface MediaResolverDependencies {
|
||||
ytdlp?: YtDlpClient;
|
||||
playDlResolver?: PlayDlResolver;
|
||||
}
|
||||
|
||||
export function createMediaResolver(
|
||||
dependencies: MediaResolverDependencies = {},
|
||||
) {
|
||||
const ytdlp = dependencies.ytdlp ?? createYtDlp();
|
||||
const playDlResolver = dependencies.playDlResolver ?? createPlayDlResolver();
|
||||
|
||||
return async function resolve(
|
||||
input: string,
|
||||
mode: MediaMode = "music",
|
||||
): Promise<ResolvedMediaSource> {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
"Media source is required",
|
||||
"MISSING_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const url = parseUrl(source);
|
||||
if (url && isYouTubeUrl(url)) {
|
||||
const metadata = await ytdlp.getMetadata(source);
|
||||
const directUrl =
|
||||
mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(source)
|
||||
: await ytdlp.getDirectAudioUrl(source);
|
||||
return { source: directUrl, title: metadata.title, kind: "youtube" };
|
||||
}
|
||||
|
||||
if (url && isSpotifyTrackUrl(url)) {
|
||||
const result = await playDlResolver.resolveSpotifyTrack(source);
|
||||
const directUrl =
|
||||
mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(result.url)
|
||||
: await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "spotify" };
|
||||
}
|
||||
|
||||
const urlSource = resolveUrlSource(source);
|
||||
if (urlSource) return urlSource;
|
||||
|
||||
const localPath = path.resolve(source);
|
||||
if (existsSync(localPath) && statSync(localPath).isFile()) {
|
||||
return {
|
||||
source: localPath,
|
||||
title: path.basename(localPath),
|
||||
kind: "local",
|
||||
};
|
||||
}
|
||||
|
||||
if (!url && !looksLikeUrl(source)) {
|
||||
const result = await playDlResolver.searchYouTube(source);
|
||||
const directUrl =
|
||||
mode === "screen"
|
||||
? await ytdlp.getDirectVideoUrl(result.url)
|
||||
: await ytdlp.getDirectAudioUrl(result.url);
|
||||
return { source: directUrl, title: result.title, kind: "search" };
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
"Media source must be an HTTP(S) URL, YouTube URL, Spotify track URL, search query, or existing local file",
|
||||
"UNSUPPORTED_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const resolveMediaSource = createMediaResolver();
|
||||
|
||||
function parseUrl(source: string): URL | null {
|
||||
try {
|
||||
return new URL(source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeUrl(source: string): boolean {
|
||||
return /^[a-z][a-z\d+.-]*:/i.test(source);
|
||||
}
|
||||
|
||||
function isYouTubeUrl(url: URL): boolean {
|
||||
return [
|
||||
"youtube.com",
|
||||
"www.youtube.com",
|
||||
"m.youtube.com",
|
||||
"youtu.be",
|
||||
].includes(url.hostname);
|
||||
}
|
||||
|
||||
function isSpotifyTrackUrl(url: URL): boolean {
|
||||
return (
|
||||
url.hostname === "open.spotify.com" && url.pathname.startsWith("/track/")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUrlSource(source: string): ResolvedMediaSource | null {
|
||||
const url = parseUrl(source);
|
||||
if (!url) return null;
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
|
||||
return {
|
||||
source,
|
||||
title: titleFromUrl(url),
|
||||
kind: "url",
|
||||
};
|
||||
}
|
||||
|
||||
function titleFromUrl(url: URL): string {
|
||||
const filename = decodeURIComponent(url.pathname.split("/").pop() || "");
|
||||
return path.basename(filename) || url.hostname;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { StreamType } from "@discordjs/voice";
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
export type MediaSourceKind =
|
||||
| "url"
|
||||
| "local"
|
||||
| "youtube"
|
||||
| "spotify"
|
||||
| "search";
|
||||
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
|
||||
|
||||
export interface ResolvedMediaSource {
|
||||
source: string;
|
||||
title: string;
|
||||
kind: MediaSourceKind;
|
||||
}
|
||||
|
||||
export interface MediaQueueItem extends ResolvedMediaSource {
|
||||
id: string;
|
||||
mode: MediaMode;
|
||||
requestedBy: string;
|
||||
addedAt: number;
|
||||
status: MediaQueueItemStatus;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
export interface QueueMediaOptions {
|
||||
mode?: MediaMode;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface MusicPlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface MusicPlayer {
|
||||
play(source: ResolvedMediaSource): MusicPlayback;
|
||||
}
|
||||
|
||||
export interface ScreenSharePlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ScreenShareController {
|
||||
isActive(): boolean;
|
||||
start(source: string): Promise<ScreenSharePlayback>;
|
||||
}
|
||||
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||
|
||||
export interface DiscordPlayOptions {
|
||||
inputType?: StreamType;
|
||||
inlineVolume?: boolean;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
getOwner(): DiscordPlayerOwner;
|
||||
isConnected(): boolean;
|
||||
playStream(
|
||||
stream: Readable,
|
||||
owner: DiscordPlayerOwner,
|
||||
options?: DiscordPlayOptions,
|
||||
): void;
|
||||
pause(owner?: DiscordPlayerOwner): void;
|
||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||
stop(owner?: DiscordPlayerOwner): void;
|
||||
getMusicVolume(): number;
|
||||
setMusicVolume(volume: number): void;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import { discordPlayer } from "../player.js";
|
||||
import type {
|
||||
DiscordAudioPlayer,
|
||||
MusicPlayback,
|
||||
MusicPlayer,
|
||||
ResolvedMediaSource,
|
||||
} from "./mediaTypes.js";
|
||||
|
||||
export interface MusicPlayerDependencies {
|
||||
spawn?: typeof nodeSpawn;
|
||||
discordPlayer?: DiscordAudioPlayer;
|
||||
}
|
||||
|
||||
export function createMusicPlayer(
|
||||
dependencies: MusicPlayerDependencies = {},
|
||||
): MusicPlayer {
|
||||
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||
const audioPlayer = dependencies.discordPlayer ?? discordPlayer;
|
||||
|
||||
return {
|
||||
play(source: ResolvedMediaSource): MusicPlayback {
|
||||
if (!audioPlayer.isConnected()) {
|
||||
throw new Error("Discord audio player is not connected");
|
||||
}
|
||||
|
||||
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
|
||||
let stderrOutput = "";
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderrOutput += chunk.toString();
|
||||
const line = chunk.toString().trim();
|
||||
if (line && !line.includes("frame=")) {
|
||||
console.log("[musicPlayer] ffmpeg:", line);
|
||||
}
|
||||
});
|
||||
|
||||
audioPlayer.playStream(proc.stdout, "music", {
|
||||
inputType: StreamType.Raw,
|
||||
inlineVolume: true,
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
audioPlayer.stop("music");
|
||||
};
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
proc.on("error", (error) => {
|
||||
console.error("[musicPlayer] Process error:", error);
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.stdout.on("error", (error) => {
|
||||
console.error("[musicPlayer] Stdout error:", error);
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
release();
|
||||
if (code === 0 || stopped) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const errorMsg = `ffmpeg exited with code ${code}`;
|
||||
console.error("[musicPlayer]", errorMsg);
|
||||
if (stderrOutput) {
|
||||
console.error(
|
||||
"[musicPlayer] ffmpeg stderr:",
|
||||
stderrOutput.slice(-500),
|
||||
);
|
||||
}
|
||||
reject(new Error(errorMsg));
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
done,
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
proc.kill("SIGTERM");
|
||||
release();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFfmpegArgs(source: string): string[] {
|
||||
const args = ["-hide_banner", "-loglevel", "warning"];
|
||||
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) {
|
||||
args.push(
|
||||
"-user_agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
|
||||
);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i",
|
||||
source,
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-ac",
|
||||
"2",
|
||||
"-f",
|
||||
"s16le",
|
||||
"pipe:1",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"[ffmpeg] Command:",
|
||||
"ffmpeg",
|
||||
args.join(" ").slice(0, 200) + "...",
|
||||
);
|
||||
return args;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import play from "play-dl";
|
||||
|
||||
export interface PlayDlResult {
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayDlSearchResult {
|
||||
title?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface SpotifyTrackLike {
|
||||
type?: string;
|
||||
name?: string;
|
||||
artists?: Array<{ name?: string }>;
|
||||
}
|
||||
|
||||
type SearchFunction = (
|
||||
query: string,
|
||||
options: { limit: number },
|
||||
) => Promise<PlayDlSearchResult[]>;
|
||||
|
||||
type SpotifyFunction = (url: string) => Promise<SpotifyTrackLike>;
|
||||
|
||||
export interface PlayDlDependencies {
|
||||
search?: SearchFunction;
|
||||
spotify?: SpotifyFunction;
|
||||
}
|
||||
|
||||
export function createPlayDlResolver(dependencies: PlayDlDependencies = {}) {
|
||||
const search: SearchFunction = dependencies.search ?? play.search;
|
||||
const spotify: SpotifyFunction =
|
||||
dependencies.spotify ?? (play.spotify as SpotifyFunction);
|
||||
|
||||
return {
|
||||
async searchYouTube(query: string): Promise<PlayDlResult> {
|
||||
const results = await search(query, { limit: 1 });
|
||||
const first = results[0];
|
||||
if (!first?.url) throw new Error(`No YouTube result found for ${query}`);
|
||||
return {
|
||||
title: first.title || query,
|
||||
url: first.url,
|
||||
};
|
||||
},
|
||||
|
||||
async resolveSpotifyTrack(url: string): Promise<PlayDlResult> {
|
||||
const track = await spotify(url);
|
||||
if (track.type !== "track") {
|
||||
throw new Error("Only Spotify track URLs are supported");
|
||||
}
|
||||
const artists = (track.artists || [])
|
||||
.map((artist) => artist.name)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const query = `${artists} ${track.name || ""} audio`.trim();
|
||||
return this.searchYouTube(query);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { AppError } from "../errors.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { discordPlayer } from "../player.js";
|
||||
import {
|
||||
playPreparedStream,
|
||||
playTranscodedPreparedStream,
|
||||
Streamer,
|
||||
} from "../streaming/index.js";
|
||||
|
||||
const logger = createChildLogger("screen-share");
|
||||
|
||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes.js";
|
||||
import { createYtDlp } from "./ytdlp.js";
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareControllerDependencies {
|
||||
getVoiceStatus: () => ScreenShareVoiceStatus;
|
||||
getPlayerOwner?: () => DiscordPlayerOwner;
|
||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||
streamer: Streamer;
|
||||
useTranscoder?: boolean;
|
||||
onBeforeStreamStart?: (
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
) => Promise<void> | void;
|
||||
onAfterStreamEnd?: (
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
) => Promise<void> | void;
|
||||
onStreamStart?: () => void;
|
||||
onStreamEnd?: () => void;
|
||||
}
|
||||
|
||||
export function createScreenShareController(
|
||||
dependencies: ScreenShareControllerDependencies,
|
||||
) {
|
||||
let active: ScreenSharePlayback | null = null;
|
||||
const ytdlp = createYtDlp();
|
||||
const getPlayerOwner =
|
||||
dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner());
|
||||
const getDirectVideoUrl =
|
||||
dependencies.getDirectVideoUrl ??
|
||||
((source) => ytdlp.getDirectVideoUrl(source));
|
||||
|
||||
return {
|
||||
isActive(): boolean {
|
||||
return active !== null;
|
||||
},
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = dependencies.getVoiceStatus();
|
||||
let voiceReleased = false;
|
||||
let voiceRestored = false;
|
||||
|
||||
const restoreVoice = async () => {
|
||||
if (voiceRestored || !voiceReleased || !guildId || !channelId) return;
|
||||
voiceRestored = true;
|
||||
await dependencies.onAfterStreamEnd?.(guildId, channelId);
|
||||
};
|
||||
|
||||
if (active) {
|
||||
active.stop();
|
||||
}
|
||||
|
||||
// Ensure bot is in the voice channel and owns the screen-share stream
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
!status.activeChannelId
|
||||
) {
|
||||
throw new AppError(
|
||||
"Connect to a voice channel before sharing screen",
|
||||
"VOICE_NOT_CONNECTED",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const guildId = status.activeGuildId;
|
||||
const channelId = status.activeChannelId;
|
||||
|
||||
// If another media owner (e.g. music) holds the shared player, reject
|
||||
const owner = getPlayerOwner();
|
||||
if (owner === "music") {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const directUrl = await getDirectVideoUrl(source);
|
||||
logger.info(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
},
|
||||
"Creating screen share session",
|
||||
);
|
||||
await dependencies.onBeforeStreamStart?.(guildId, channelId);
|
||||
voiceReleased = true;
|
||||
const session = await dependencies.streamer.createSession(
|
||||
guildId,
|
||||
channelId,
|
||||
);
|
||||
|
||||
dependencies.onStreamStart?.();
|
||||
|
||||
let stopped = false;
|
||||
const playFn = dependencies.useTranscoder
|
||||
? playTranscodedPreparedStream
|
||||
: playPreparedStream;
|
||||
|
||||
const done = playFn(directUrl, session, {
|
||||
fps: 30,
|
||||
bitrate: 2500,
|
||||
includeAudio: true,
|
||||
presetH26x: "superfast",
|
||||
}).finally(() => {
|
||||
active = null;
|
||||
dependencies.onStreamEnd?.();
|
||||
return restoreVoice();
|
||||
});
|
||||
done.catch(() => undefined);
|
||||
logger.info(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
},
|
||||
"Screen share session started",
|
||||
);
|
||||
|
||||
active = {
|
||||
done,
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
session.stop();
|
||||
active = null;
|
||||
void restoreVoice();
|
||||
},
|
||||
};
|
||||
return active;
|
||||
} catch (error) {
|
||||
active = null;
|
||||
if (voiceReleased) {
|
||||
await restoreVoice();
|
||||
}
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
guildId,
|
||||
channelId,
|
||||
},
|
||||
"Screen share startup failed",
|
||||
);
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : "Screen stream failed",
|
||||
"SCREEN_STREAM_FAILED",
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
export interface YtDlpMetadata {
|
||||
title: string;
|
||||
webpageUrl: string;
|
||||
}
|
||||
|
||||
export interface YtDlpClient {
|
||||
getMetadata(url: string): Promise<YtDlpMetadata>;
|
||||
getDirectAudioUrl(url: string): Promise<string>;
|
||||
getDirectVideoUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface YtDlpDependencies {
|
||||
spawn?: typeof nodeSpawn;
|
||||
}
|
||||
|
||||
export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
const spawn = dependencies.spawn ?? nodeSpawn;
|
||||
|
||||
return {
|
||||
async getMetadata(url: string): Promise<YtDlpMetadata> {
|
||||
const data = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--dump-single-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
const parsed = JSON.parse(data) as {
|
||||
title?: string;
|
||||
webpage_url?: string;
|
||||
};
|
||||
return {
|
||||
title: parsed.title || url,
|
||||
webpageUrl: parsed.webpage_url || url,
|
||||
};
|
||||
},
|
||||
|
||||
async getDirectAudioUrl(url: string): Promise<string> {
|
||||
const value = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestaudio[protocol^=http]/bestaudio/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
const directUrl = value.trim().split("\n")[0];
|
||||
if (!directUrl) {
|
||||
console.warn("[ytdlp] No audio URL returned for:", url);
|
||||
throw new Error(`Failed to resolve audio URL for: ${url}`);
|
||||
}
|
||||
console.log(
|
||||
"[ytdlp] Resolved audio URL:",
|
||||
directUrl.slice(0, 100) + "...",
|
||||
);
|
||||
return directUrl;
|
||||
},
|
||||
|
||||
async getDirectVideoUrl(url: string): Promise<string> {
|
||||
const value = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
return value.trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runYtDlp(
|
||||
spawn: typeof nodeSpawn,
|
||||
args: string[],
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
}
|
||||
reject(new Error(`yt-dlp failed with code ${code}: ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Counter, Gauge, Histogram, register } from "prom-client";
|
||||
|
||||
// Audio metrics
|
||||
export const audioLevelGauge = new Gauge({
|
||||
name: "audio_level_db",
|
||||
help: "Current audio level in dB",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
export const recordingDurationCounter = new Counter({
|
||||
name: "recording_duration_seconds_total",
|
||||
help: "Total recording duration in seconds",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
export const activeRecordingsGauge = new Gauge({
|
||||
name: "active_recordings",
|
||||
help: "Number of active recordings",
|
||||
});
|
||||
|
||||
export const recordedSegmentsCounter = new Counter({
|
||||
name: "recorded_segments_total",
|
||||
help: "Total number of recorded segments",
|
||||
labelNames: ["user_id"],
|
||||
});
|
||||
|
||||
// Connection metrics
|
||||
export const voiceConnectionsGauge = new Gauge({
|
||||
name: "voice_connections_active",
|
||||
help: "Number of active voice connections",
|
||||
});
|
||||
|
||||
export const connectionErrorsCounter = new Counter({
|
||||
name: "connection_errors_total",
|
||||
help: "Total number of connection errors",
|
||||
labelNames: ["error_type"],
|
||||
});
|
||||
|
||||
export const reconnectAttemptsCounter = new Counter({
|
||||
name: "reconnect_attempts_total",
|
||||
help: "Total number of reconnection attempts",
|
||||
});
|
||||
|
||||
// WebSocket metrics
|
||||
export const wsClientsGauge = new Gauge({
|
||||
name: "websocket_clients_connected",
|
||||
help: "Number of connected WebSocket clients",
|
||||
});
|
||||
|
||||
export const wsMessagesCounter = new Counter({
|
||||
name: "websocket_messages_total",
|
||||
help: "Total WebSocket messages sent",
|
||||
labelNames: ["message_type"],
|
||||
});
|
||||
|
||||
// Transcoder metrics
|
||||
export const transcoderRestartsCounter = new Counter({
|
||||
name: "transcoder_restarts_total",
|
||||
help: "Total number of transcoder restarts",
|
||||
});
|
||||
|
||||
export const transcoderRunningGauge = new Gauge({
|
||||
name: "transcoder_running",
|
||||
help: "Whether a transcoder process is currently running (1/0)",
|
||||
});
|
||||
|
||||
// HTTP metrics
|
||||
export const httpRequestDurationHistogram = new Histogram({
|
||||
name: "http_request_duration_seconds",
|
||||
help: "HTTP request duration in seconds",
|
||||
labelNames: ["method", "route", "status"],
|
||||
buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 5],
|
||||
});
|
||||
|
||||
export const httpRequestsCounter = new Counter({
|
||||
name: "http_requests_total",
|
||||
help: "Total HTTP requests",
|
||||
labelNames: ["method", "route", "status"],
|
||||
});
|
||||
|
||||
// System metrics
|
||||
export const uptimeGauge = new Gauge({
|
||||
name: "process_uptime_seconds",
|
||||
help: "Process uptime in seconds",
|
||||
});
|
||||
|
||||
export async function getMetrics(): Promise<string> {
|
||||
return register.metrics();
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { AppError } from "./errors.js";
|
||||
import { createChildLogger } from "./logger.js";
|
||||
|
||||
const logger = createChildLogger("middleware");
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
logger.error(
|
||||
{ code: err.code, statusCode: err.statusCode, message: err.message },
|
||||
"Application error",
|
||||
);
|
||||
return res.status(err.statusCode).json({
|
||||
error: err.code,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
logger.error({ error: err.message, stack: err.stack }, "Unexpected error");
|
||||
res.status(500).json({
|
||||
error: "INTERNAL_SERVER_ERROR",
|
||||
message: "An unexpected error occurred",
|
||||
});
|
||||
}
|
||||
|
||||
export function notFoundHandler(_req: Request, res: Response) {
|
||||
res.status(404).json({
|
||||
error: "NOT_FOUND",
|
||||
message: "Endpoint not found",
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||
const CRC_TABLE = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let r = i << 24;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
r = (r & 0x80000000) !== 0 ? (r << 1) ^ 0x04c11db7 : r << 1;
|
||||
}
|
||||
CRC_TABLE[i] = r >>> 0;
|
||||
}
|
||||
|
||||
const Module = require("node:module");
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function (id: string) {
|
||||
if (id === "node-crc") {
|
||||
return {
|
||||
crc: function (
|
||||
width: number,
|
||||
reflectIn: boolean,
|
||||
poly: number,
|
||||
init: number,
|
||||
refOut: boolean,
|
||||
xorOut: number,
|
||||
unk1: number,
|
||||
unk2: number,
|
||||
buffer: Buffer,
|
||||
) {
|
||||
let crc = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
crc =
|
||||
((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ buffer[i]) & 0xff];
|
||||
crc >>>= 0;
|
||||
}
|
||||
const result = Buffer.alloc(4);
|
||||
result.writeUInt32BE(crc, 0);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalRequire.apply(this, arguments);
|
||||
};
|
||||
|
||||
console.log("[mock] node-crc has been mocked globally for ESM.");
|
||||
|
||||
export {};
|
||||
@@ -1,295 +0,0 @@
|
||||
import type { Client, Guild, User } from "discord.js-selfbot-v13";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { getModerationAction, updateModerationAction } from "./messageStore.js";
|
||||
import type { ModerationAction, ModerationActionType } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("action-executor");
|
||||
|
||||
interface ActionExecutionContext {
|
||||
client: Client;
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a moderation action (delete message, mute user, etc.)
|
||||
*/
|
||||
export async function executeModerationAction(
|
||||
action: ModerationAction,
|
||||
context: ActionExecutionContext,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const guild = await context.client.guilds.fetch(context.guildId);
|
||||
if (!guild) {
|
||||
throw new Error(`Guild ${context.guildId} not found`);
|
||||
}
|
||||
|
||||
switch (action.action_type) {
|
||||
case "delete_message":
|
||||
await executeDeleteMessage(action, guild);
|
||||
break;
|
||||
case "mute_user":
|
||||
await executeMuteUser(action, guild);
|
||||
break;
|
||||
case "warn_user":
|
||||
await executeWarnUser(action, guild);
|
||||
break;
|
||||
case "kick_user":
|
||||
await executeKickUser(action, guild);
|
||||
break;
|
||||
case "ban_user":
|
||||
await executeBanUser(action, guild);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown action type: ${action.action_type}`);
|
||||
}
|
||||
|
||||
// Mark action as executed
|
||||
await updateModerationAction(action.id, {
|
||||
status: "executed",
|
||||
executed_at: Date.now(),
|
||||
error: null,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: action.action_type,
|
||||
guildId: context.guildId,
|
||||
},
|
||||
"Moderation action executed successfully",
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Mark action as failed
|
||||
await updateModerationAction(action.id, {
|
||||
status: "failed",
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
logger.error(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: action.action_type,
|
||||
guildId: context.guildId,
|
||||
error: errorMessage,
|
||||
},
|
||||
"Failed to execute moderation action",
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDeleteMessage(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.message_id) {
|
||||
throw new Error("message_id is required for delete_message action");
|
||||
}
|
||||
|
||||
// Note: Discord.js selfbot cannot delete messages from other users
|
||||
// This is a placeholder for the intended behavior
|
||||
logger.warn(
|
||||
{ messageId: action.message_id },
|
||||
"Delete message action requires manual execution or bot permissions",
|
||||
);
|
||||
}
|
||||
|
||||
async function executeMuteUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for mute_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(action.user_id);
|
||||
if (!member) {
|
||||
throw new Error(`Member ${action.user_id} not found in guild`);
|
||||
}
|
||||
|
||||
// Mute by removing speak permission in all voice channels
|
||||
const voiceChannels = guild.channels.cache.filter(
|
||||
(ch) => ch.type === "GUILD_VOICE",
|
||||
);
|
||||
|
||||
for (const [, channel] of voiceChannels) {
|
||||
await channel.permissionOverwrites.create(member, {
|
||||
SPEAK: false,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User muted in all voice channels",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to mute user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWarnUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for warn_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await guild.client.users.fetch(action.user_id);
|
||||
if (!user) {
|
||||
throw new Error(`User ${action.user_id} not found`);
|
||||
}
|
||||
|
||||
const reason = action.reason || "Warned by moderation system";
|
||||
await user.send(`You have been warned in ${guild.name}. Reason: ${reason}`);
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User warned via DM",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
userId: action.user_id,
|
||||
guildId: guild.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to send warning DM to user",
|
||||
);
|
||||
// Don't throw - warning DM failure is not critical
|
||||
}
|
||||
}
|
||||
|
||||
async function executeKickUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for kick_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await guild.members.fetch(action.user_id);
|
||||
if (!member) {
|
||||
throw new Error(`Member ${action.user_id} not found in guild`);
|
||||
}
|
||||
|
||||
const reason = action.reason || "Kicked by moderation system";
|
||||
await member.kick(reason);
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User kicked from guild",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to kick user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBanUser(
|
||||
action: ModerationAction,
|
||||
guild: Guild,
|
||||
): Promise<void> {
|
||||
if (!action.user_id) {
|
||||
throw new Error("user_id is required for ban_user action");
|
||||
}
|
||||
|
||||
try {
|
||||
const reason = action.reason || "Banned by moderation system";
|
||||
await guild.bans.create(action.user_id, { reason });
|
||||
|
||||
logger.info(
|
||||
{ userId: action.user_id, guildId: guild.id },
|
||||
"User banned from guild",
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to ban user: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes pending moderation actions for a guild
|
||||
*/
|
||||
export async function processPendingActions(
|
||||
guildId: string,
|
||||
context: ActionExecutionContext,
|
||||
): Promise<{ processed: number; failed: number }> {
|
||||
const result = { processed: 0, failed: 0 };
|
||||
|
||||
try {
|
||||
const { listModerationActions } = await import("./messageStore.js");
|
||||
|
||||
const { data: actions } = await listModerationActions({
|
||||
guildId,
|
||||
status: ["pending"],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
await executeModerationAction(action, context);
|
||||
result.processed++;
|
||||
} catch (error) {
|
||||
result.failed++;
|
||||
logger.error(
|
||||
{
|
||||
actionId: action.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to process pending action",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ guildId, ...result }, "Processed pending moderation actions");
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to process pending actions",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic action processor
|
||||
*/
|
||||
export function startActionProcessor(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
intervalMs: number = 60 * 1000, // 1 minute
|
||||
): NodeJS.Timeout {
|
||||
logger.info({ guildId, intervalMs }, "Starting action processor");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await processPendingActions(guildId, { client, guildId });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
guildId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Action processor failed",
|
||||
);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
return interval;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { config } from "../config.js";
|
||||
import { initializeDatabase } from "../database/drizzle.js";
|
||||
import { buildConversationContext } from "./conversationContext.js";
|
||||
import { runModerationAnalysis } from "./llmModerationClient.js";
|
||||
import {
|
||||
getAttachmentsForMessages,
|
||||
getConversationContextBefore,
|
||||
updateMessagesAIAnalysisBulk,
|
||||
} from "./messageStore.js";
|
||||
import type { MessageRecord } from "./types.js";
|
||||
|
||||
let dbInitialized = false;
|
||||
let dbInitPromise: Promise<any> | null = null;
|
||||
|
||||
async function ensureDb() {
|
||||
if (dbInitialized) return;
|
||||
if (!dbInitPromise) {
|
||||
dbInitPromise = initializeDatabase().then(() => {
|
||||
dbInitialized = true;
|
||||
});
|
||||
}
|
||||
await dbInitPromise;
|
||||
}
|
||||
|
||||
export interface AnalysisWorkerRequest {
|
||||
conversationKey: string;
|
||||
messages: MessageRecord[];
|
||||
}
|
||||
|
||||
export type AnalysisWorkerResponse =
|
||||
| {
|
||||
ok: true;
|
||||
conversationKey: string;
|
||||
rows: MessageRecord[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
conversationKey: string;
|
||||
rows: MessageRecord[];
|
||||
error: string;
|
||||
};
|
||||
|
||||
export default async function processAnalysisRequest({
|
||||
conversationKey,
|
||||
messages,
|
||||
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
||||
if (!config.AI_LLM_API_KEY) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: "FATAL",
|
||||
context: "aiAnalysisWorker",
|
||||
error:
|
||||
"AI_LLM_API_KEY is missing from environment. Force closing worker operation.",
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await ensureDb();
|
||||
} catch (dbError) {
|
||||
const msg = dbError instanceof Error ? dbError.message : String(dbError);
|
||||
return {
|
||||
ok: false,
|
||||
conversationKey,
|
||||
rows: [],
|
||||
error: `Database init failed: ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
const firstMessage = messages[0];
|
||||
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
||||
|
||||
const contextBefore = await getConversationContextBefore({
|
||||
channelId: firstMessage.channel_id,
|
||||
threadId: firstMessage.thread_id,
|
||||
beforeCreatedAt: firstMessage.created_at,
|
||||
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
const contextLines = await buildConversationContext({
|
||||
contextBefore,
|
||||
targets: messages,
|
||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
||||
});
|
||||
|
||||
const targetIds = messages.map((m) => m.id);
|
||||
const contextIds = contextBefore.map((m) => m.id);
|
||||
const allMessageIds = [...targetIds, ...contextIds];
|
||||
const attachments = await getAttachmentsForMessages(allMessageIds);
|
||||
|
||||
const result = await runModerationAnalysis({
|
||||
targets: messages,
|
||||
contextText: contextLines.join("\n"),
|
||||
attachments,
|
||||
});
|
||||
|
||||
const updates = result.results.map((analysisResult) => ({
|
||||
messageId: analysisResult.messageId,
|
||||
result: {
|
||||
status: analysisResult.status,
|
||||
flags: JSON.stringify(analysisResult.flags),
|
||||
score: analysisResult.score,
|
||||
analysis: analysisResult.analysis,
|
||||
categories: analysisResult.categories,
|
||||
severity: analysisResult.severity,
|
||||
confidence: analysisResult.confidence,
|
||||
recommendedAction: analysisResult.recommendedAction,
|
||||
analyzedAt: Date.now(),
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||
return { ok: true, conversationKey, rows };
|
||||
} catch (dbErr) {
|
||||
// If bulk update fails, we log it but don't fail the worker completely
|
||||
// so it can at least retry later without blowing up the circuit breaker if it was an isolated issue
|
||||
throw new Error(
|
||||
`Failed to update DB: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
const rows: MessageRecord[] = [];
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: "ERROR",
|
||||
context: "aiAnalysisWorker",
|
||||
conversationKey,
|
||||
messageCount: messages.length,
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
return { ok: false, conversationKey, rows, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@@ -1,918 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { AbortError } from "p-retry";
|
||||
import { Piscina } from "piscina";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { retryWithBackoff } from "../retry.js";
|
||||
import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js";
|
||||
import { buildConversationContext } from "./conversationContext.js";
|
||||
import { runModerationAnalysis } from "./llmModerationClient.js";
|
||||
import { isAgeRestrictedMetadata } from "./messageMetadata.js";
|
||||
import {
|
||||
getAttachmentsForMessages,
|
||||
getConversationContextBefore,
|
||||
getConversationKeysWithIncompleteAnalysis,
|
||||
getIncompleteMessagesByConversation,
|
||||
getMessageById,
|
||||
getPendingConversationKeys,
|
||||
getPendingMessagesByConversation,
|
||||
updateMessageAIAnalysis,
|
||||
updateMessagesAIAnalysisBulk,
|
||||
} from "./messageStore.js";
|
||||
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||
import type {
|
||||
AnalysisQueueStatus,
|
||||
MessageRecord,
|
||||
ModerationBroadcaster,
|
||||
} from "./types.js";
|
||||
|
||||
const logger = createChildLogger("ai-analyzer");
|
||||
|
||||
type ModerationGlobal = typeof globalThis & {
|
||||
moderationBroadcaster?: ModerationBroadcaster;
|
||||
};
|
||||
|
||||
function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
||||
}
|
||||
|
||||
function scheduleAutoDelete(row: MessageRecord): void {
|
||||
if (row.ai_status !== "flagged" && row.ai_status !== "warn") return;
|
||||
const run = () => {
|
||||
attemptAutoDeleteFlaggedMessage(moderationClient, row).catch((error) => {
|
||||
logger.error(
|
||||
{
|
||||
messageId: row.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Unexpected auto-delete error",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (config.AUTO_DELETE_FLAGGED_DELAY_MS > 0) {
|
||||
setTimeout(run, config.AUTO_DELETE_FLAGGED_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
setImmediate(run);
|
||||
}
|
||||
|
||||
function isAgeRestrictedMessage(message: MessageRecord): boolean {
|
||||
return isAgeRestrictedMetadata(message.metadata);
|
||||
}
|
||||
|
||||
function buildAgeRestrictedSkipResult(): {
|
||||
status: "clean";
|
||||
flags: string | null;
|
||||
score: number;
|
||||
analysis: string;
|
||||
categories: string[];
|
||||
severity: "none";
|
||||
confidence: number;
|
||||
recommendedAction: "none";
|
||||
analyzedAt: number;
|
||||
error: null;
|
||||
} {
|
||||
return {
|
||||
status: "clean",
|
||||
flags: JSON.stringify(["age_restricted"]),
|
||||
score: 0,
|
||||
analysis: "Skipped moderation for age-restricted content.",
|
||||
categories: ["age_restricted"],
|
||||
severity: "none",
|
||||
confidence: 1,
|
||||
recommendedAction: "none",
|
||||
analyzedAt: Date.now(),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function skipAgeRestrictedMessages(
|
||||
messages: MessageRecord[],
|
||||
): Promise<MessageRecord[]> {
|
||||
const ageRestrictedMessages = messages.filter(isAgeRestrictedMessage);
|
||||
if (ageRestrictedMessages.length === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const skippedRows = await updateMessagesAIAnalysisBulk(
|
||||
ageRestrictedMessages.map((message) => ({
|
||||
messageId: message.id,
|
||||
result: buildAgeRestrictedSkipResult(),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const row of skippedRows) {
|
||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
||||
}
|
||||
|
||||
const skippedIds = new Set(
|
||||
ageRestrictedMessages.map((message) => message.id),
|
||||
);
|
||||
return messages.filter((message) => !skippedIds.has(message.id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch pipeline state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Debounce timer handle per conversation key. */
|
||||
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
/** Timestamp of when processing started per conversation key. */
|
||||
const conversationProcessing = new Map<string, number>();
|
||||
/** Cooldown expiry timestamp per conversation key after an error. */
|
||||
const conversationErrorCooldown = new Map<string, number>();
|
||||
|
||||
let activeRequests = 0;
|
||||
let lastError: string | null = null;
|
||||
let moderationClient: Client | undefined;
|
||||
|
||||
// Batch circuit breaker
|
||||
let consecutiveErrors = 0;
|
||||
const MAX_CONSECUTIVE_ERRORS = 5;
|
||||
let globalCooldownUntil = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual fallback queue — runs PARALLEL to the batch pipeline.
|
||||
//
|
||||
// Design guarantees:
|
||||
// • Concurrency is capped at config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT.
|
||||
// • A flat Set<messageId> de-duplicates so the same message can't be
|
||||
// in-flight twice (Discord snowflakes are globally unique, but be safe).
|
||||
// • A Map<conversationKey, count> lets the recovery worker skip conversations
|
||||
// that already have individual work in progress (#4 fix).
|
||||
// • A separate circuit breaker prevents a cascade of individual failures
|
||||
// from hammering a down/rate-limited LLM endpoint (#1+#5 fix).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** IDs currently being processed one-by-one. */
|
||||
const individualInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Per-conversation count of in-flight individual messages.
|
||||
* Used by the recovery worker to avoid re-scheduling a conversation that
|
||||
* already has individual fallback work running for it.
|
||||
*/
|
||||
const individualInFlightByConversation = new Map<string, number>();
|
||||
|
||||
/** Counter for observability. */
|
||||
let activeIndividualRequests = 0;
|
||||
|
||||
// Individual fallback circuit breaker (independent of batch CB)
|
||||
let individualConsecutiveErrors = 0;
|
||||
let individualCooldownUntil = 0;
|
||||
const INDIVIDUAL_COOLDOWN_MS = 30000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Piscina worker pool (batch path only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAnalysisWorkerUrl(): URL {
|
||||
const candidates = [
|
||||
new URL("./aiAnalysisWorker.js", import.meta.url),
|
||||
new URL("../aiAnalysisWorker.js", import.meta.url),
|
||||
new URL("./aiAnalysisWorker.ts", import.meta.url),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[2];
|
||||
}
|
||||
|
||||
const workerPool = new Piscina({
|
||||
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
||||
execArgv: process.execArgv,
|
||||
});
|
||||
|
||||
interface AnalysisWorkerResponse {
|
||||
ok: boolean;
|
||||
conversationKey: string;
|
||||
rows: MessageRecord[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gets the conversation key for a message (thread_id or channel_id).
|
||||
*/
|
||||
export function getConversationKey(message: MessageRecord): string {
|
||||
return message.thread_id || message.channel_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a batch of messages within a token budget.
|
||||
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
|
||||
* Uses a rough character-based token estimate (avoids async formatMessageForPrompt
|
||||
* since this function runs in a synchronous promise chain).
|
||||
*/
|
||||
export function pickBatchWithinBudget(
|
||||
messages: MessageRecord[],
|
||||
maxTokens: number,
|
||||
tokensPerMessage: number,
|
||||
): MessageRecord[] {
|
||||
const batch: MessageRecord[] = [];
|
||||
let usedTokens = 0;
|
||||
|
||||
for (const msg of messages) {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
// Rough token estimate: ~3 chars per token + metadata overhead
|
||||
const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage;
|
||||
|
||||
if (usedTokens + msgTokens <= maxTokens) {
|
||||
batch.push(msg);
|
||||
usedTokens += msgTokens;
|
||||
}
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation lock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isConversationProcessingLocked(conversationKey: string): boolean {
|
||||
const startedAt = conversationProcessing.get(conversationKey);
|
||||
// FIX #7: use configurable timeout that exceeds (LLM timeout × max retries).
|
||||
// Old hardcoded value was 30 000 ms — shorter than a single LLM call under retries.
|
||||
return Boolean(
|
||||
startedAt &&
|
||||
Date.now() - startedAt < config.AI_ANALYSIS_PROCESSING_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual fallback pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Processes a single message directly in the main process (no IPC/worker
|
||||
* pool overhead). Never called from the batch path.
|
||||
*
|
||||
* FIX #1+#5: Increments the individual circuit breaker on failure so a
|
||||
* sustained outage stops hammering the LLM endpoint.
|
||||
*
|
||||
* Infinite-loop prevention: if the LLM consistently drops the single target
|
||||
* message across all retries (analysis_incomplete), we write a terminal flag
|
||||
* 'individual_analysis_exhausted' to DB instead of 'analysis_incomplete'.
|
||||
* The recovery worker only queries for 'analysis_incomplete', so exhausted
|
||||
* messages are permanently excluded from the reprocessing loop.
|
||||
* Transient failures (network/parse/DB) are NOT written as exhausted — they
|
||||
* stay as 'analysis_incomplete' so the circuit-breaker-throttled recovery
|
||||
* cycle can retry them later.
|
||||
*/
|
||||
async function processIndividualFallback(
|
||||
message: MessageRecord,
|
||||
): Promise<void> {
|
||||
const { id: messageId } = message;
|
||||
const conversationKey = getConversationKey(message);
|
||||
|
||||
activeIndividualRequests++;
|
||||
// Increment per-conversation counter so the recovery worker can see it.
|
||||
individualInFlightByConversation.set(
|
||||
conversationKey,
|
||||
(individualInFlightByConversation.get(conversationKey) ?? 0) + 1,
|
||||
);
|
||||
|
||||
// Track whether all retries were exhausted specifically because the LLM
|
||||
// consistently returned no result for this message (vs. a transient error).
|
||||
let exhaustedOnIncomplete = false;
|
||||
|
||||
try {
|
||||
const contextBefore = await getConversationContextBefore({
|
||||
channelId: message.channel_id,
|
||||
threadId: message.thread_id,
|
||||
beforeCreatedAt: message.created_at,
|
||||
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
const contextLines = await buildConversationContext({
|
||||
contextBefore,
|
||||
targets: [message],
|
||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
||||
});
|
||||
|
||||
const contextIds = contextBefore.map((m) => m.id);
|
||||
const attachments = await getAttachmentsForMessages([
|
||||
messageId,
|
||||
...contextIds,
|
||||
]);
|
||||
|
||||
const analysisResult = await retryWithBackoff(
|
||||
async () => {
|
||||
try {
|
||||
const result = await runModerationAnalysis({
|
||||
targets: [message],
|
||||
contextText: contextLines.join("\n"),
|
||||
attachments,
|
||||
});
|
||||
|
||||
// If the LLM still dropped our only target, convert to a retryable
|
||||
// throw so backoff kicks in. Track this so the catch block can
|
||||
// distinguish it from a transient network/parse failure.
|
||||
const stillIncomplete = result.results.some((r) =>
|
||||
r.flags.includes("analysis_incomplete"),
|
||||
);
|
||||
if (stillIncomplete) {
|
||||
exhaustedOnIncomplete = true;
|
||||
throw new Error(
|
||||
`LLM returned no result for single-target message ${messageId} — will retry with backoff`,
|
||||
);
|
||||
}
|
||||
|
||||
// Got a real result — clear the incomplete flag.
|
||||
exhaustedOnIncomplete = false;
|
||||
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
// Propagate AbortError so outer retry is immediately cancelled on 429.
|
||||
if (err instanceof AbortError) {
|
||||
throw err;
|
||||
}
|
||||
if (
|
||||
err?.status === 429 ||
|
||||
err?.status === 401 ||
|
||||
err?.status === 403
|
||||
) {
|
||||
throw new AbortError(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: 0,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
const updates = analysisResult.results.map((r) => ({
|
||||
messageId: r.messageId,
|
||||
result: {
|
||||
status: r.status,
|
||||
flags: JSON.stringify(r.flags),
|
||||
score: r.score,
|
||||
analysis: r.analysis,
|
||||
categories: r.categories,
|
||||
severity: r.severity,
|
||||
confidence: r.confidence,
|
||||
recommendedAction: r.recommendedAction,
|
||||
analyzedAt: Date.now(),
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||
for (const row of rows) {
|
||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
||||
invalidateAnalyticsCache(row.guild_id);
|
||||
scheduleAutoDelete(row);
|
||||
}
|
||||
|
||||
// Reset individual CB on success.
|
||||
individualConsecutiveErrors = 0;
|
||||
|
||||
logger.info(
|
||||
{ messageId, status: analysisResult.results[0]?.status },
|
||||
"Individual fallback analysis complete",
|
||||
);
|
||||
} catch (error) {
|
||||
// FIX #5: individual failures now feed their own circuit breaker.
|
||||
individualConsecutiveErrors++;
|
||||
if (
|
||||
individualConsecutiveErrors >= config.AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD
|
||||
) {
|
||||
individualCooldownUntil = Date.now() + INDIVIDUAL_COOLDOWN_MS;
|
||||
logger.warn(
|
||||
{
|
||||
threshold: config.AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD,
|
||||
cooldownUntil: new Date(individualCooldownUntil).toISOString(),
|
||||
},
|
||||
"Individual fallback circuit breaker triggered",
|
||||
);
|
||||
}
|
||||
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Infinite-loop prevention: if all retries were exhausted because the LLM
|
||||
// consistently dropped this specific message (not a transient error),
|
||||
// overwrite the DB entry with a terminal flag that the recovery query
|
||||
// does NOT match. This permanently removes it from the recovery loop
|
||||
// while keeping it visible as an error in the dashboard.
|
||||
if (exhaustedOnIncomplete) {
|
||||
await updateMessagesAIAnalysisBulk([
|
||||
{
|
||||
messageId,
|
||||
result: {
|
||||
status: "error",
|
||||
flags: JSON.stringify(["individual_analysis_exhausted"]),
|
||||
score: 0,
|
||||
analysis:
|
||||
"Individual fallback exhausted all retries: LLM consistently dropped this message even in single-target mode",
|
||||
categories: ["individual_analysis_exhausted"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
analyzedAt: Date.now(),
|
||||
error: lastError,
|
||||
},
|
||||
},
|
||||
]).catch((dbErr) => {
|
||||
logger.error(
|
||||
{ messageId, error: String(dbErr) },
|
||||
"Failed to write terminal exhausted status — message may re-enter recovery loop",
|
||||
);
|
||||
});
|
||||
logger.warn(
|
||||
{ messageId },
|
||||
"Individual fallback exhausted — marked as individual_analysis_exhausted to stop recovery loop",
|
||||
);
|
||||
} else {
|
||||
// Transient failure (network/parse/DB): do NOT write terminal status.
|
||||
// Message stays as error/analysis_incomplete in DB and will be retried
|
||||
// by the recovery worker, subject to the individual circuit breaker.
|
||||
logger.error(
|
||||
{
|
||||
messageId,
|
||||
error: lastError,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"Individual fallback analysis failed (transient) — will be retried by recovery worker",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
activeIndividualRequests--;
|
||||
individualInFlight.delete(messageId);
|
||||
|
||||
// Decrement per-conversation counter; remove key when it hits zero.
|
||||
const prev = individualInFlightByConversation.get(conversationKey) ?? 1;
|
||||
if (prev <= 1) {
|
||||
individualInFlightByConversation.delete(conversationKey);
|
||||
} else {
|
||||
individualInFlightByConversation.set(conversationKey, prev - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fans out message records to the individual fallback queue.
|
||||
*
|
||||
* FIX #1: Checks concurrency cap before admitting new work.
|
||||
* FIX #5: Checks individual circuit breaker before admitting new work.
|
||||
* Messages that cannot be admitted remain as `error/analysis_incomplete` in
|
||||
* the DB and will be picked up by the recovery worker on the next interval.
|
||||
*/
|
||||
function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
|
||||
// FIX #5: Honour the individual circuit breaker.
|
||||
if (Date.now() < individualCooldownUntil) {
|
||||
logger.warn(
|
||||
{
|
||||
until: new Date(individualCooldownUntil).toISOString(),
|
||||
skipped: messages.length,
|
||||
},
|
||||
"Individual fallback circuit breaker active — messages will be recovered later",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessages = messages.filter((m) => !individualInFlight.has(m.id));
|
||||
if (newMessages.length === 0) return;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
count: newMessages.length,
|
||||
messageIds: newMessages.map((m) => m.id),
|
||||
},
|
||||
"Enqueueing individual fallback analysis for batch-incomplete messages",
|
||||
);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
individualInFlight.add(msg.id);
|
||||
// Fire-and-forget: processIndividualFallback handles all errors internally.
|
||||
processIndividualFallback(msg).catch((err) => {
|
||||
// Belt-and-suspenders guard — should never reach here.
|
||||
logger.error(
|
||||
{ messageId: msg.id, error: String(err) },
|
||||
"Unexpected uncaught error escaping processIndividualFallback",
|
||||
);
|
||||
individualInFlight.delete(msg.id);
|
||||
const ck = getConversationKey(msg);
|
||||
const prev = individualInFlightByConversation.get(ck) ?? 1;
|
||||
if (prev <= 1) {
|
||||
individualInFlightByConversation.delete(ck);
|
||||
} else {
|
||||
individualInFlightByConversation.set(ck, prev - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processBatch(
|
||||
conversationKey: string,
|
||||
messages: MessageRecord[],
|
||||
): Promise<void> {
|
||||
if (messages.length === 0) return;
|
||||
if (Date.now() < globalCooldownUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequests++;
|
||||
let shouldScheduleNext = false;
|
||||
const processingStartedAt = Date.now();
|
||||
conversationProcessing.set(conversationKey, processingStartedAt);
|
||||
try {
|
||||
const result = (await workerPool.run({
|
||||
conversationKey,
|
||||
messages,
|
||||
})) as AnalysisWorkerResponse;
|
||||
|
||||
for (const row of result.rows) {
|
||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
||||
scheduleAutoDelete(row);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
globalCooldownUntil = Date.now() + 60000;
|
||||
logger.warn(
|
||||
"Global circuit breaker triggered due to consecutive errors",
|
||||
);
|
||||
}
|
||||
|
||||
// Batch failed entirely — fall back all messages to individual queue
|
||||
// so no message is permanently lost behind a cooldown.
|
||||
logger.warn(
|
||||
{
|
||||
conversationKey,
|
||||
messageCount: messages.length,
|
||||
error: result.error,
|
||||
},
|
||||
"Batch failed entirely — routing all messages to individual fallback queue",
|
||||
);
|
||||
enqueueIndividualFallbacks(messages);
|
||||
|
||||
lastError = result.error ?? "Analysis worker failed";
|
||||
conversationErrorCooldown.set(
|
||||
conversationKey,
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
);
|
||||
logger.error(
|
||||
{
|
||||
conversationKey,
|
||||
error: lastError,
|
||||
messageCount: messages.length,
|
||||
messageIds: messages.map((m) => m.id),
|
||||
cooldownUntil: new Date(
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
).toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"Batch analysis failed, will retry after cooldown",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch succeeded — but check for messages the LLM silently dropped.
|
||||
// Rows with flag "analysis_incomplete" were produced by parseModerationResponse
|
||||
// as synthetic errors; they must be re-processed individually.
|
||||
const incompleteMessages = messages.filter((msg) => {
|
||||
const row = result.rows.find((r) => r.id === msg.id);
|
||||
if (!row) {
|
||||
// The DB update row is missing entirely — treat as incomplete.
|
||||
return true;
|
||||
}
|
||||
const flags: string[] = (() => {
|
||||
try {
|
||||
return JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
return row.ai_status === "error" && flags.includes("analysis_incomplete");
|
||||
});
|
||||
|
||||
if (incompleteMessages.length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
conversationKey,
|
||||
incompleteCount: incompleteMessages.length,
|
||||
incompleteIds: incompleteMessages.map((m) => m.id),
|
||||
totalBatchSize: messages.length,
|
||||
},
|
||||
"Batch returned incomplete results — fanning out to individual fallback queue",
|
||||
);
|
||||
enqueueIndividualFallbacks(incompleteMessages);
|
||||
}
|
||||
|
||||
consecutiveErrors = 0; // Reset batch circuit breaker
|
||||
conversationErrorCooldown.delete(conversationKey);
|
||||
shouldScheduleNext = true;
|
||||
} catch (error) {
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
||||
globalCooldownUntil = Date.now() + 60000;
|
||||
logger.warn("Global circuit breaker triggered due to consecutive errors");
|
||||
}
|
||||
|
||||
// Unhandled exception — route everything to individual fallback.
|
||||
logger.warn(
|
||||
{ conversationKey, messageCount: messages.length },
|
||||
"Batch threw exception — routing all messages to individual fallback queue",
|
||||
);
|
||||
enqueueIndividualFallbacks(messages);
|
||||
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
conversationErrorCooldown.set(
|
||||
conversationKey,
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
);
|
||||
logger.error(
|
||||
{
|
||||
conversationKey,
|
||||
error: lastError,
|
||||
stack: errorStack,
|
||||
messageCount: messages.length,
|
||||
messageIds: messages.map((m) => m.id),
|
||||
cooldownUntil: new Date(
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
).toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"Analysis worker failed, will retry after cooldown",
|
||||
);
|
||||
} finally {
|
||||
activeRequests--;
|
||||
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
||||
conversationProcessing.delete(conversationKey);
|
||||
}
|
||||
if (shouldScheduleNext) {
|
||||
setImmediate(() => scheduleConversationAnalysis(conversationKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schedules a debounced analysis run for a conversation.
|
||||
*
|
||||
* FIX #3: The async work inside setTimeout is now wrapped in an explicit
|
||||
* .catch() so DB errors don't produce unhandled promise rejections.
|
||||
* FIX #6: Calls pickBatchWithinBudget after fetching messages so token budget
|
||||
* is respected before handing the batch to the LLM.
|
||||
*/
|
||||
function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
if (isConversationProcessingLocked(conversationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const convoCooldown = conversationErrorCooldown.get(conversationKey) || 0;
|
||||
const activeCooldown = Math.max(convoCooldown, globalCooldownUntil);
|
||||
|
||||
if (activeCooldown && Date.now() < activeCooldown) {
|
||||
if (!conversationDebounceTimers.has(conversationKey)) {
|
||||
const remaining = activeCooldown - Date.now();
|
||||
const timer = setTimeout(() => {
|
||||
conversationDebounceTimers.delete(conversationKey);
|
||||
scheduleConversationAnalysis(conversationKey);
|
||||
}, remaining + 500);
|
||||
conversationDebounceTimers.set(conversationKey, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimer = conversationDebounceTimers.get(conversationKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
conversationDebounceTimers.delete(conversationKey);
|
||||
|
||||
// FIX #3: explicit .catch() — no async arrow function to avoid unhandled rejection.
|
||||
getPendingMessagesByConversation(
|
||||
conversationKey,
|
||||
config.AI_ANALYSIS_MAX_BATCH_SIZE,
|
||||
)
|
||||
.then(async (messages) => {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const processableMessages = await skipAgeRestrictedMessages(messages);
|
||||
if (processableMessages.length === 0) return;
|
||||
|
||||
// FIX #6: trim to token budget before sending to LLM.
|
||||
// 50 tokens overhead accounts for JSON structure + id/username fields.
|
||||
let trimmed = pickBatchWithinBudget(
|
||||
processableMessages,
|
||||
config.AI_ANALYSIS_MAX_TARGET_TOKENS,
|
||||
50,
|
||||
);
|
||||
|
||||
// FIX #10: if every message individually exceeds the token budget,
|
||||
// pickBatchWithinBudget returns [] — which would leave them permanently
|
||||
// stuck as `pending`. Fall back to the first message alone so at
|
||||
// least one makes progress; the rest will be processed in later ticks.
|
||||
if (trimmed.length === 0 && processableMessages.length > 0) {
|
||||
trimmed = processableMessages.slice(0, 1);
|
||||
logger.warn(
|
||||
{
|
||||
conversationKey,
|
||||
messageId: processableMessages[0]?.id,
|
||||
tokenBudget: config.AI_ANALYSIS_MAX_TARGET_TOKENS,
|
||||
},
|
||||
"All messages exceed token budget — processing first message alone to avoid stuck-pending deadlock",
|
||||
);
|
||||
}
|
||||
|
||||
return processBatch(conversationKey, trimmed);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
{
|
||||
conversationKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Failed to fetch or dispatch pending messages for scheduled analysis",
|
||||
);
|
||||
});
|
||||
}, config.AI_ANALYSIS_DEBOUNCE_MS);
|
||||
|
||||
conversationDebounceTimers.set(conversationKey, timer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Queues a message for analysis (debounced by conversation).
|
||||
*/
|
||||
export async function queueMessageAnalysis(messageId: string): Promise<void> {
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
|
||||
try {
|
||||
const message = await getMessageById(messageId);
|
||||
if (!message) {
|
||||
logger.warn({ messageId }, "Message not found for analysis queue");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAgeRestrictedMessage(message)) {
|
||||
const updated = await updateMessageAIAnalysis(
|
||||
message.id,
|
||||
buildAgeRestrictedSkipResult(),
|
||||
);
|
||||
if (updated) {
|
||||
getModerationBroadcaster()?.messageAnalyzed(updated);
|
||||
}
|
||||
logger.info(
|
||||
{ messageId },
|
||||
"Skipped AI analysis for age-restricted message",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queueConversationAnalysis(getConversationKey(message));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
messageId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to queue message for analysis",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a conversation for analysis (debounced).
|
||||
*/
|
||||
export function queueConversationAnalysis(conversationKey: string): void {
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
scheduleConversationAnalysis(conversationKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current status of both the batch and individual fallback queues.
|
||||
*/
|
||||
export function getAnalysisQueueStatus(): AnalysisQueueStatus {
|
||||
return {
|
||||
queuedConversations: conversationDebounceTimers.size,
|
||||
activeRequests,
|
||||
activeIndividualRequests,
|
||||
individualInFlightCount: individualInFlight.size,
|
||||
individualCircuitBreakerActive: Date.now() < individualCooldownUntil,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the periodic recovery worker.
|
||||
*
|
||||
* FIX #4: Now also recovers messages stuck in `error/analysis_incomplete`
|
||||
* state (not just `pending`), and skips conversations that already have
|
||||
* individual fallback work in progress to avoid DB last-write-wins races.
|
||||
*/
|
||||
export function startPendingAIAnalysisWorker(client?: Client): void {
|
||||
moderationClient = client;
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
|
||||
setInterval(() => {
|
||||
// FIX #3 pattern: no async arrow — chain promises explicitly.
|
||||
Promise.all([
|
||||
getPendingConversationKeys(500),
|
||||
getConversationKeysWithIncompleteAnalysis(200),
|
||||
])
|
||||
.then(([pendingKeys, incompleteKeys]) => {
|
||||
const now = Date.now();
|
||||
|
||||
// FIX #9: Prune stale entries from state maps to prevent unbounded
|
||||
// memory growth from channels/threads that are no longer active.
|
||||
for (const [key, expiry] of conversationErrorCooldown) {
|
||||
if (now >= expiry) conversationErrorCooldown.delete(key);
|
||||
}
|
||||
for (const [key, startedAt] of conversationProcessing) {
|
||||
if (now - startedAt >= config.AI_ANALYSIS_PROCESSING_TIMEOUT_MS) {
|
||||
conversationProcessing.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// FIX #8: Build a set of keys already targeted for individual recovery
|
||||
// so the batch loop below skips them, preventing a race where batch
|
||||
// scheduling and individual scheduling collide on the same conversation.
|
||||
const incompleteKeySet = new Set(incompleteKeys);
|
||||
|
||||
// --- Batch recovery for `pending` messages ---
|
||||
for (const key of pendingKeys) {
|
||||
if (conversationDebounceTimers.has(key)) continue;
|
||||
if (isConversationProcessingLocked(key)) continue;
|
||||
// FIX #4: skip if individual fallback already running for this conversation.
|
||||
if (individualInFlightByConversation.has(key)) continue;
|
||||
// FIX #8: skip if this conversation also needs individual recovery
|
||||
// (batch processing would conflict with in-flight individual work).
|
||||
if (incompleteKeySet.has(key)) continue;
|
||||
const cooldownUntil = conversationErrorCooldown.get(key);
|
||||
if (cooldownUntil && now < cooldownUntil) continue;
|
||||
scheduleConversationAnalysis(key);
|
||||
}
|
||||
|
||||
// --- Individual recovery for `error/analysis_incomplete` messages ---
|
||||
// Circuit breaker check: no point iterating if individual CB is active.
|
||||
if (now >= individualCooldownUntil) {
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const key of incompleteKeys) {
|
||||
// Skip if individual work is already running for this conversation.
|
||||
if (individualInFlightByConversation.has(key)) continue;
|
||||
// Skip if batch processing is running (it will fan-out if it finds more incomplete).
|
||||
if (isConversationProcessingLocked(key)) continue;
|
||||
|
||||
promises.push(
|
||||
getIncompleteMessagesByConversation(key, 500)
|
||||
.then(async (msgs) => {
|
||||
const processableMessages =
|
||||
await skipAgeRestrictedMessages(msgs);
|
||||
return processableMessages;
|
||||
})
|
||||
.then((msgs) => {
|
||||
if (msgs.length > 0) {
|
||||
enqueueIndividualFallbacks(msgs);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
{ key, error: String(err) },
|
||||
"Failed to fetch incomplete messages for recovery",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Errors are handled per-key; return the combined promise for observability.
|
||||
return Promise.all(promises);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Pending AI analysis recovery worker failed",
|
||||
);
|
||||
});
|
||||
}, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS);
|
||||
}
|
||||
@@ -1,929 +0,0 @@
|
||||
import { executeAll, executeGet } from "../database/drizzle.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import type { MessageRecord } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("analytics-store");
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HourlyBucket {
|
||||
hour: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface TopicTrend {
|
||||
topic: string;
|
||||
count: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface UserStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
message_count: number;
|
||||
edited_count: number;
|
||||
deleted_count: number;
|
||||
flagged_count: number;
|
||||
last_active: number;
|
||||
}
|
||||
|
||||
export interface ModerationBreakdown {
|
||||
total: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
pending: number;
|
||||
average_score: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
period: { start: number; end: number };
|
||||
messages: ModerationBreakdown;
|
||||
hourly: HourlyBucket[];
|
||||
topics: TopicTrend[];
|
||||
top_users: UserStat[];
|
||||
active_users_count: number;
|
||||
total_channels: number;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// GENERIC QUERY CACHE (reduces duplicate DB calls from 5s auto-refresh)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const queryCache = new Map<string, CacheEntry<any>>();
|
||||
|
||||
/** Default TTL for aggregate queries — 10s is long enough to prevent redundant
|
||||
* calls from the 5s auto-refresh but short enough to feel real-time. */
|
||||
const AGGREGATE_CACHE_TTL_MS = 10_000;
|
||||
|
||||
/** Topic extraction is expensive (JSON parsing). Cache longer. */
|
||||
const TOPIC_CACHE_TTL_MS = 120_000;
|
||||
|
||||
function makeCacheKey(prefix: string, params: Record<string, any>): string {
|
||||
return `${prefix}:${JSON.stringify(params)}`;
|
||||
}
|
||||
|
||||
function getCached<T>(key: string): T | undefined {
|
||||
const entry = queryCache.get(key);
|
||||
if (entry && entry.expiresAt > Date.now()) return entry.data;
|
||||
if (entry) queryCache.delete(key); // expired
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function setCache<T>(key: string, data: T, ttl: number): void {
|
||||
queryCache.set(key, { data, expiresAt: Date.now() + ttl });
|
||||
// Prune old entries if cache grows too large (>200 entries)
|
||||
if (queryCache.size > 200) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of queryCache) {
|
||||
if (v.expiresAt <= now) queryCache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hourly Message Stats ───────────────────────────────────────────────
|
||||
|
||||
export async function getHourlyStats(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HourlyBucket[]> {
|
||||
const { guildId, channelId, hours = 24 } = input;
|
||||
const cacheKey = makeCacheKey("hourly", { guildId, channelId, hours });
|
||||
const cached = getCached<HourlyBucket[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const hourExpr = `to_char(to_timestamp((created_at / 3600000) * 3600), 'YYYY-MM-DD HH24:MI:SS') as hour`;
|
||||
|
||||
const rows = await executeAll(
|
||||
`
|
||||
SELECT
|
||||
${hourExpr},
|
||||
count(*) as count,
|
||||
count(case when ai_status = 'clean' then 1 end) as clean,
|
||||
count(case when ai_status = 'warn' then 1 end) as warned,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged,
|
||||
count(case when ai_status = 'error' then 1 end) as error
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
GROUP BY (created_at / 3600000)
|
||||
ORDER BY hour ASC
|
||||
`,
|
||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||
);
|
||||
|
||||
// Initialize all hour buckets (fill gaps with zeros)
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (let h = 0; h < hours; h++) {
|
||||
const ts = new Date(since + h * 3600_000);
|
||||
ts.setMinutes(0, 0, 0);
|
||||
const key = ts.toISOString().slice(0, 13) + ":00:00Z";
|
||||
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.hour.replace(" ", "T") + "Z");
|
||||
const key = d.toISOString().slice(0, 13) + ":00:00Z";
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.count = row.count;
|
||||
bucket.clean = row.clean;
|
||||
bucket.warned = row.warned;
|
||||
bucket.flagged = row.flagged;
|
||||
bucket.error = row.error;
|
||||
}
|
||||
|
||||
const result = Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([hour, data]) => ({ hour, ...data }));
|
||||
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get hourly stats",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Topic Trends ───────────────────────────────────────────────────────
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"yang",
|
||||
"dan",
|
||||
"itu",
|
||||
"ini",
|
||||
"dengan",
|
||||
"akan",
|
||||
"pada",
|
||||
"dari",
|
||||
"di",
|
||||
"ke",
|
||||
"untuk",
|
||||
"tidak",
|
||||
"ada",
|
||||
"juga",
|
||||
"sudah",
|
||||
"saya",
|
||||
"kamu",
|
||||
"dia",
|
||||
"mereka",
|
||||
"kami",
|
||||
"aku",
|
||||
"lo",
|
||||
"lu",
|
||||
"gua",
|
||||
"gue",
|
||||
"org",
|
||||
"orang",
|
||||
"aja",
|
||||
"sama",
|
||||
"kalo",
|
||||
"kalau",
|
||||
"bisa",
|
||||
"karena",
|
||||
"gak",
|
||||
"nggak",
|
||||
"ga",
|
||||
"tak",
|
||||
"belum",
|
||||
"udah",
|
||||
"dah",
|
||||
"lah",
|
||||
"kah",
|
||||
"pun",
|
||||
"nih",
|
||||
"tuh",
|
||||
"deh",
|
||||
"dong",
|
||||
"si",
|
||||
"nya",
|
||||
"kan",
|
||||
"ya",
|
||||
"yah",
|
||||
"yuk",
|
||||
"kok",
|
||||
"loh",
|
||||
"nah",
|
||||
"wow",
|
||||
"eh",
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"is",
|
||||
"are",
|
||||
"was",
|
||||
"were",
|
||||
"be",
|
||||
"been",
|
||||
"being",
|
||||
"have",
|
||||
"has",
|
||||
"had",
|
||||
"having",
|
||||
"do",
|
||||
"does",
|
||||
"did",
|
||||
"doing",
|
||||
"will",
|
||||
"would",
|
||||
"could",
|
||||
"should",
|
||||
"may",
|
||||
"might",
|
||||
"must",
|
||||
"shall",
|
||||
"i",
|
||||
"you",
|
||||
"he",
|
||||
"she",
|
||||
"it",
|
||||
"we",
|
||||
"they",
|
||||
"me",
|
||||
"him",
|
||||
"her",
|
||||
"us",
|
||||
"them",
|
||||
"my",
|
||||
"your",
|
||||
"his",
|
||||
"its",
|
||||
"our",
|
||||
"their",
|
||||
"and",
|
||||
"but",
|
||||
"or",
|
||||
"nor",
|
||||
"not",
|
||||
"so",
|
||||
"yet",
|
||||
"for",
|
||||
"if",
|
||||
"to",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"at",
|
||||
"by",
|
||||
"as",
|
||||
"with",
|
||||
"about",
|
||||
"just",
|
||||
"then",
|
||||
"now",
|
||||
"here",
|
||||
"there",
|
||||
"when",
|
||||
"where",
|
||||
"why",
|
||||
"how",
|
||||
"all",
|
||||
"both",
|
||||
"each",
|
||||
"few",
|
||||
"more",
|
||||
"most",
|
||||
"other",
|
||||
"some",
|
||||
"such",
|
||||
"only",
|
||||
"own",
|
||||
"same",
|
||||
"too",
|
||||
"very",
|
||||
"can",
|
||||
"go",
|
||||
"ok",
|
||||
"okay",
|
||||
"yeah",
|
||||
"yes",
|
||||
"no",
|
||||
]);
|
||||
|
||||
function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
|
||||
const topicScores = new Map<string, { count: number; score: number }>();
|
||||
const wordFreq = new Map<string, number>();
|
||||
const flaggedWordFreq = new Map<string, number>();
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.ai_analysis) {
|
||||
try {
|
||||
const analysis = JSON.parse(msg.ai_analysis);
|
||||
const topics = analysis.topics;
|
||||
if (topics && Array.isArray(topics)) {
|
||||
for (const topic of topics) {
|
||||
const key =
|
||||
typeof topic === "string" ? topic : topic.name || topic.topic;
|
||||
if (!key) continue;
|
||||
const k = key.toLowerCase();
|
||||
const score = msg.ai_moderation_score || 0;
|
||||
const existing = topicScores.get(k);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.score += score;
|
||||
} else {
|
||||
topicScores.set(k, { count: 1, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (analysis.category) {
|
||||
const cat = String(analysis.category).toLowerCase();
|
||||
const existing = topicScores.get(cat);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.score += msg.ai_moderation_score || 0;
|
||||
} else {
|
||||
topicScores.set(cat, {
|
||||
count: 1,
|
||||
score: msg.ai_moderation_score || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* not valid JSON */
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.content) {
|
||||
const words = msg.content
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
||||
|
||||
for (const word of words) {
|
||||
wordFreq.set(word, (wordFreq.get(word) || 0) + 1);
|
||||
if (msg.ai_status === "flagged" || msg.ai_status === "warn") {
|
||||
flaggedWordFreq.set(word, (flaggedWordFreq.get(word) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: TopicTrend[] = [];
|
||||
for (const [topic, data] of topicScores) {
|
||||
results.push({ topic, count: data.count, score: data.score });
|
||||
}
|
||||
|
||||
const sortedWords = Array.from(wordFreq.entries())
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, topN);
|
||||
|
||||
for (const [word, count] of sortedWords) {
|
||||
if (!topicScores.has(word)) {
|
||||
results.push({
|
||||
topic: word,
|
||||
count,
|
||||
score: flaggedWordFreq.get(word) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.count - a.count).slice(0, topN);
|
||||
}
|
||||
|
||||
export async function getTopicTrends(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TopicTrend[]> {
|
||||
const { guildId, channelId, hours = 24 } = input;
|
||||
const cacheKey = makeCacheKey("topics", { guildId, channelId, hours });
|
||||
const cached = getCached<TopicTrend[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
|
||||
// Fetch all analyzed messages within the time window (no hard row cap).
|
||||
// Messages without ai_analysis are excluded which naturally limits rows.
|
||||
const rows = (await executeAll(
|
||||
`
|
||||
SELECT
|
||||
id, content, ai_status, ai_analysis, ai_moderation_score,
|
||||
ai_moderation_flags, created_at
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
AND ai_analysis IS NOT NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||
)) as MessageRecord[];
|
||||
|
||||
const result = extractTopics(rows);
|
||||
setCache(cacheKey, result, TOPIC_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get topic trends",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── User Leaderboard ────────────────────────────────────────────────────
|
||||
|
||||
export async function getUserLeaderboard(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<UserStat[]> {
|
||||
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
||||
const cacheKey = makeCacheKey("leaderboard", {
|
||||
guildId,
|
||||
channelId,
|
||||
hours,
|
||||
limit,
|
||||
});
|
||||
const cached = getCached<UserStat[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const rows = await executeAll(
|
||||
`
|
||||
SELECT
|
||||
user_id,
|
||||
username,
|
||||
avatar_url,
|
||||
count(*) as message_count,
|
||||
count(case when type = 'edited' then 1 end) as edited_count,
|
||||
count(case when type = 'deleted' then 1 end) as deleted_count,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
|
||||
max(created_at) as last_active
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
GROUP BY user_id, username, avatar_url
|
||||
ORDER BY message_count DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
channelId
|
||||
? [guildId, since, channelId, channelId, limit]
|
||||
: [guildId, since, limit],
|
||||
);
|
||||
|
||||
const result = rows as UserStat[];
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get user leaderboard",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Moderation Stats ───────────────────────────────────────────────────
|
||||
|
||||
export async function getModerationStats(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<ModerationBreakdown> {
|
||||
const { guildId, channelId, hours = 24 } = input;
|
||||
const cacheKey = makeCacheKey("modstats", { guildId, channelId, hours });
|
||||
const cached = getCached<ModerationBreakdown>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const avgScoreExpr = `round(avg(ai_moderation_score)::numeric, 2)`;
|
||||
|
||||
const row = await executeGet(
|
||||
`
|
||||
SELECT
|
||||
count(*) as total,
|
||||
count(case when ai_status = 'clean' then 1 end) as clean,
|
||||
count(case when ai_status = 'warn' then 1 end) as warned,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged,
|
||||
count(case when ai_status = 'error' then 1 end) as error,
|
||||
count(case when ai_status = 'pending' or ai_status IS NULL then 1 end) as pending,
|
||||
${avgScoreExpr} as average_score
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
`,
|
||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||
);
|
||||
|
||||
const result: ModerationBreakdown = row
|
||||
? {
|
||||
total: row.total ?? 0,
|
||||
clean: row.clean ?? 0,
|
||||
warned: row.warned ?? 0,
|
||||
flagged: row.flagged ?? 0,
|
||||
error: row.error ?? 0,
|
||||
pending: row.pending ?? 0,
|
||||
average_score: row.average_score ?? 0,
|
||||
}
|
||||
: {
|
||||
total: 0,
|
||||
clean: 0,
|
||||
warned: 0,
|
||||
flagged: 0,
|
||||
error: 0,
|
||||
pending: 0,
|
||||
average_score: 0,
|
||||
};
|
||||
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get moderation stats",
|
||||
);
|
||||
return {
|
||||
total: 0,
|
||||
clean: 0,
|
||||
warned: 0,
|
||||
flagged: 0,
|
||||
error: 0,
|
||||
pending: 0,
|
||||
average_score: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active Channels Count ──────────────────────────────────────────────
|
||||
|
||||
export async function getActiveChannelCount(input: {
|
||||
guildId: string;
|
||||
hours?: number;
|
||||
}): Promise<number> {
|
||||
const { guildId, hours = 24 } = input;
|
||||
const cacheKey = makeCacheKey("channels", { guildId, hours });
|
||||
const cached = getCached<number>(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const row = await executeGet(
|
||||
`
|
||||
SELECT count(DISTINCT channel_id) as cnt
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
`,
|
||||
[guildId, since],
|
||||
);
|
||||
|
||||
const result = row?.cnt ?? 0;
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get active channel count",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top Violators ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ViolatorStat {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
warned_count: number;
|
||||
violation_score: number;
|
||||
worst_flags: string[];
|
||||
last_violation: number;
|
||||
}
|
||||
|
||||
export async function getTopViolators(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}): Promise<ViolatorStat[]> {
|
||||
const { guildId, channelId, hours = 24, limit = 20 } = input;
|
||||
const cacheKey = makeCacheKey("violators", {
|
||||
guildId,
|
||||
channelId,
|
||||
hours,
|
||||
limit,
|
||||
});
|
||||
const cached = getCached<ViolatorStat[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const rows = await executeAll(
|
||||
`
|
||||
SELECT
|
||||
user_id,
|
||||
username,
|
||||
avatar_url,
|
||||
count(*) as total_messages,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged_count,
|
||||
count(case when ai_status = 'warn' then 1 end) as warned_count,
|
||||
max(case when ai_status in ('flagged', 'warn') then created_at else 0 end) as last_violation
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
GROUP BY user_id, username, avatar_url
|
||||
HAVING count(case when ai_status = 'flagged' then 1 end) > 0
|
||||
OR count(case when ai_status = 'warn' then 1 end) > 0
|
||||
ORDER BY (
|
||||
count(case when ai_status = 'flagged' then 1 end) * 3
|
||||
+ count(case when ai_status = 'warn' then 1 end)
|
||||
) DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
channelId
|
||||
? [guildId, since, channelId, channelId, limit]
|
||||
: [guildId, since, limit],
|
||||
);
|
||||
|
||||
const violators: ViolatorStat[] = rows.map((row: any) => {
|
||||
const flaggedCount = Number(row.flagged_count ?? 0);
|
||||
const warnedCount = Number(row.warned_count ?? 0);
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
username: row.username,
|
||||
avatar_url: row.avatar_url,
|
||||
total_messages: Number(row.total_messages ?? 0),
|
||||
flagged_count: flaggedCount,
|
||||
warned_count: warnedCount,
|
||||
violation_score: flaggedCount * 3 + warnedCount,
|
||||
worst_flags: [],
|
||||
last_violation: Number(row.last_violation ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
setCache(cacheKey, violators, AGGREGATE_CACHE_TTL_MS);
|
||||
return violators;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get top violators",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daily Trend (for multi-day line chart) ────────────────────────────
|
||||
|
||||
export interface TrendBucket {
|
||||
date: string;
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export async function getDailyTrend(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<TrendBucket[]> {
|
||||
const { guildId, channelId, hours = 168 } = input;
|
||||
const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours });
|
||||
const cached = getCached<TrendBucket[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const dateExpr = `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date`;
|
||||
|
||||
const rows = await executeAll(
|
||||
`
|
||||
SELECT
|
||||
${dateExpr},
|
||||
count(*) as count,
|
||||
count(case when ai_status = 'clean' then 1 end) as clean,
|
||||
count(case when ai_status = 'warn' then 1 end) as warned,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged,
|
||||
count(case when ai_status = 'error' then 1 end) as error
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
GROUP BY 1
|
||||
ORDER BY 1 ASC
|
||||
`,
|
||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||
);
|
||||
|
||||
// Initialize all day buckets (fill gaps with zeros)
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
error: number;
|
||||
}
|
||||
>();
|
||||
const msPerDay = 86400_000;
|
||||
const startDay = Math.floor(since / msPerDay) * msPerDay;
|
||||
const endDay = Math.floor(Date.now() / msPerDay) * msPerDay;
|
||||
|
||||
for (let d = startDay; d <= endDay; d += msPerDay) {
|
||||
const key = new Date(d).toISOString().slice(0, 10);
|
||||
buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const bucket = buckets.get(row.date);
|
||||
if (!bucket) continue;
|
||||
bucket.count = row.count;
|
||||
bucket.clean = row.clean;
|
||||
bucket.warned = row.warned;
|
||||
bucket.flagged = row.flagged;
|
||||
bucket.error = row.error;
|
||||
}
|
||||
|
||||
const result = Array.from(buckets.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, data]) => ({ date, ...data }));
|
||||
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get daily trend",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activity Heatmap (day-of-week × hour-of-day) ──────────────────────
|
||||
|
||||
export interface HeatmapCell {
|
||||
dayOfWeek: number; // 0=Senin, 6=Minggu
|
||||
hour: number; // 0-23
|
||||
count: number;
|
||||
clean: number;
|
||||
warned: number;
|
||||
flagged: number;
|
||||
}
|
||||
|
||||
export async function getActivityHeatmap(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<HeatmapCell[]> {
|
||||
const { guildId, channelId, hours = 168 } = input;
|
||||
const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours });
|
||||
const cached = getCached<HeatmapCell[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const since = Date.now() - hours * 3600_000;
|
||||
const dayExpr = `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week`;
|
||||
const hourExpr = `extract(hour from to_timestamp(created_at / 1000))::int as hour`;
|
||||
|
||||
const rows = await executeAll(
|
||||
`
|
||||
SELECT
|
||||
${dayExpr},
|
||||
${hourExpr},
|
||||
count(*) as count,
|
||||
count(case when ai_status = 'clean' then 1 end) as clean,
|
||||
count(case when ai_status = 'warn' then 1 end) as warned,
|
||||
count(case when ai_status = 'flagged' then 1 end) as flagged
|
||||
FROM messages
|
||||
WHERE guild_id = ?
|
||||
AND created_at >= ?
|
||||
AND deleted_at IS NULL
|
||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||
GROUP BY day_of_week, hour
|
||||
ORDER BY day_of_week, hour
|
||||
`,
|
||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||
);
|
||||
|
||||
// Initialize all 7×24 cells with zeros
|
||||
const cells = new Map<
|
||||
string,
|
||||
{ count: number; clean: number; warned: number; flagged: number }
|
||||
>();
|
||||
for (let d = 0; d < 7; d++) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
cells.set(`${d}-${h}`, { count: 0, clean: 0, warned: 0, flagged: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const key = `${row.day_of_week}-${row.hour}`;
|
||||
const cell = cells.get(key);
|
||||
if (!cell) continue;
|
||||
cell.count = row.count;
|
||||
cell.clean = row.clean;
|
||||
cell.warned = row.warned;
|
||||
cell.flagged = row.flagged;
|
||||
}
|
||||
|
||||
const result = Array.from(cells.entries())
|
||||
.map(([key, data]) => {
|
||||
const [dayOfWeek, hour] = key.split("-").map(Number);
|
||||
return { dayOfWeek, hour, ...data };
|
||||
})
|
||||
.sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.hour - b.hour);
|
||||
|
||||
setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get activity heatmap",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache Invalidation (called when new messages arrive) ───────────────
|
||||
|
||||
export function invalidateAnalyticsCache(guildId: string): void {
|
||||
const now = Date.now();
|
||||
const needle = `"${guildId}"`;
|
||||
for (const [key, entry] of queryCache) {
|
||||
if (key.includes(needle) && entry.expiresAt > now) {
|
||||
entry.expiresAt = 0; // expire immediately
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Combined Overview ──────────────────────────────────────────────────
|
||||
|
||||
export async function getAnalyticsOverview(input: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
hours?: number;
|
||||
}): Promise<AnalyticsOverview> {
|
||||
const { guildId, hours = 24 } = input;
|
||||
const now = Date.now();
|
||||
const since = now - hours * 3600_000;
|
||||
|
||||
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
|
||||
[
|
||||
getModerationStats(input),
|
||||
getHourlyStats(input),
|
||||
getTopicTrends(input),
|
||||
getUserLeaderboard(input),
|
||||
getActiveChannelCount({ guildId, hours }),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
period: { start: since, end: now },
|
||||
messages,
|
||||
hourly,
|
||||
topics,
|
||||
top_users: topUsers,
|
||||
active_users_count: topUsers.length,
|
||||
total_channels: totalChannels,
|
||||
};
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { uploadToTele } from "../uploader/teleUpload.js";
|
||||
import {
|
||||
updateAttachmentAsFailedUpload,
|
||||
updateAttachmentAsUploaded,
|
||||
updateAttachmentDiscordUrl,
|
||||
} from "./messageStore.js";
|
||||
|
||||
const logger = createChildLogger("attachment-uploader");
|
||||
|
||||
class AttachmentDownloadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AttachmentDownloadError";
|
||||
}
|
||||
}
|
||||
|
||||
export type RefreshDiscordAttachmentUrl = () => Promise<string | null>;
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function shouldRefreshDiscordUrl(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof AttachmentDownloadError &&
|
||||
(error.status === 403 || error.status === 404)
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadAttachmentToTele(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType = "application/octet-stream",
|
||||
): Promise<string> {
|
||||
try {
|
||||
const result = await uploadToTele({
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
uploadUrl: config.TELE_UPLOAD_URL,
|
||||
timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
filename,
|
||||
error: toErrorMessage(error),
|
||||
},
|
||||
"Failed to upload attachment",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDiscordAttachment(url: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(config.ATTACHMENT_UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AttachmentDownloadError(
|
||||
`Download failed with status ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ url, error: toErrorMessage(error) },
|
||||
"Failed to download Discord attachment",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processAttachmentUpload(
|
||||
attachmentId: string,
|
||||
discordUrl: string,
|
||||
filename: string,
|
||||
options: {
|
||||
refreshDiscordUrl?: RefreshDiscordAttachmentUrl;
|
||||
contentType?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
let currentDiscordUrl = discordUrl;
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await downloadDiscordAttachment(currentDiscordUrl);
|
||||
} catch (error) {
|
||||
if (!options.refreshDiscordUrl || !shouldRefreshDiscordUrl(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const freshUrl = await options.refreshDiscordUrl();
|
||||
if (!freshUrl) throw error;
|
||||
currentDiscordUrl = freshUrl;
|
||||
await updateAttachmentDiscordUrl(attachmentId, freshUrl);
|
||||
buffer = await downloadDiscordAttachment(currentDiscordUrl);
|
||||
}
|
||||
|
||||
const sizeMb = buffer.length / (1024 * 1024);
|
||||
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
|
||||
throw new Error(
|
||||
`File size ${sizeMb.toFixed(2)}MB exceeds limit of ${config.ATTACHMENT_MAX_SIZE_MB}MB`,
|
||||
);
|
||||
}
|
||||
|
||||
const uploadedUrl = await uploadAttachmentToTele(
|
||||
buffer,
|
||||
filename,
|
||||
options.contentType,
|
||||
);
|
||||
|
||||
await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now());
|
||||
} catch (error) {
|
||||
const errorMsg = toErrorMessage(error);
|
||||
await updateAttachmentAsFailedUpload(attachmentId, errorMsg);
|
||||
logger.error({ attachmentId, error: errorMsg }, "Attachment upload failed");
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { createModerationAction } from "./messageStore.js";
|
||||
import type { MessageRecord } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("auto-delete-manager");
|
||||
|
||||
const parseStringList = (value?: string | null): string[] => {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
};
|
||||
|
||||
/** Derive severity from legacy messages that lack structured AI fields. */
|
||||
function deriveSeverity(msg: MessageRecord): string {
|
||||
if (msg.ai_severity) return msg.ai_severity;
|
||||
const score = msg.ai_confidence ?? msg.ai_moderation_score ?? 0;
|
||||
if (msg.ai_status === "flagged")
|
||||
return score >= 0.9 ? "critical" : score >= 0.7 ? "high" : "medium";
|
||||
if (msg.ai_status === "warn") return score >= 0.6 ? "medium" : "low";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Derive recommended action from legacy messages that lack structured AI fields. */
|
||||
function deriveRecommendedAction(msg: MessageRecord): string {
|
||||
if (msg.ai_recommended_action) return msg.ai_recommended_action;
|
||||
const severity = deriveSeverity(msg);
|
||||
if (
|
||||
msg.ai_status === "flagged" &&
|
||||
(severity === "critical" || severity === "high")
|
||||
)
|
||||
return "delete";
|
||||
if (msg.ai_status === "flagged") return "review";
|
||||
if (msg.ai_status === "warn") return "warn";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function isAutoDeleteEligible(message: MessageRecord): boolean {
|
||||
if (message.ai_status !== "flagged" && message.ai_status !== "warn")
|
||||
return false;
|
||||
|
||||
const confidence = message.ai_confidence ?? message.ai_moderation_score ?? 0;
|
||||
if (confidence < config.AUTO_DELETE_MIN_CONFIDENCE) {
|
||||
logger.info(
|
||||
{
|
||||
messageId: message.id,
|
||||
confidence,
|
||||
threshold: config.AUTO_DELETE_MIN_CONFIDENCE,
|
||||
},
|
||||
"Auto-delete skipped: confidence below threshold",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const severity = deriveSeverity(message);
|
||||
const allowedSeverities = (config.AUTO_DELETE_ALLOWED_SEVERITIES || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (allowedSeverities.length > 0 && !allowedSeverities.includes(severity)) {
|
||||
logger.info(
|
||||
{ messageId: message.id, severity, allowed: allowedSeverities },
|
||||
"Auto-delete skipped: severity not in allowed list",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const recommendedAction = deriveRecommendedAction(message);
|
||||
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
||||
logger.info(
|
||||
{ messageId: message.id, recommendedAction },
|
||||
"Auto-delete skipped: recommended action is not delete/escalate",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedCategories = parseStringList(
|
||||
config.AUTO_DELETE_ALLOWED_CATEGORIES,
|
||||
);
|
||||
if (allowedCategories.length > 0) {
|
||||
const messageCategories = parseStringList(
|
||||
message.ai_categories ?? message.ai_moderation_flags,
|
||||
);
|
||||
const hasAllowedCategory = messageCategories.some((cat) =>
|
||||
allowedCategories.includes(cat),
|
||||
);
|
||||
if (!hasAllowedCategory) {
|
||||
logger.info(
|
||||
{
|
||||
messageId: message.id,
|
||||
categories: messageCategories,
|
||||
allowed: allowedCategories,
|
||||
},
|
||||
"Auto-delete skipped: no allowed categories match",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const excludedChannels = parseStringList(
|
||||
config.AUTO_DELETE_EXCLUDED_CHANNEL_IDS,
|
||||
);
|
||||
if (excludedChannels.length > 0) {
|
||||
const channelId = message.thread_id ?? message.channel_id;
|
||||
if (excludedChannels.includes(channelId)) {
|
||||
logger.info(
|
||||
{ messageId: message.id, channelId },
|
||||
"Auto-delete skipped: channel excluded",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const excludedUsers = parseStringList(config.AUTO_DELETE_EXCLUDED_USER_IDS);
|
||||
if (excludedUsers.length > 0 && excludedUsers.includes(message.user_id)) {
|
||||
logger.info(
|
||||
{ messageId: message.id, userId: message.user_id },
|
||||
"Auto-delete skipped: user excluded",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function logAutoDeleteAttempt(
|
||||
message: MessageRecord,
|
||||
result: AutoDeleteResult,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await createModerationAction({
|
||||
message_id: message.id,
|
||||
user_id: message.user_id,
|
||||
guild_id: message.guild_id,
|
||||
action_type: "delete_message",
|
||||
reason: result.reason,
|
||||
executed_by: "auto-delete-manager",
|
||||
status: result.deleted
|
||||
? "executed"
|
||||
: result.reason === "dry_run"
|
||||
? "executed"
|
||||
: "failed",
|
||||
error: result.reason === "error" ? result.reason : null,
|
||||
executed_at:
|
||||
result.deleted || result.reason === "dry_run" ? Date.now() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to persist auto-delete action log",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AutoDeleteResult {
|
||||
deleted: boolean;
|
||||
skipped: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): number | string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const maybeCode = (error as { code?: number | string }).code;
|
||||
const maybeStatus = (error as { status?: number | string }).status;
|
||||
return maybeCode ?? maybeStatus;
|
||||
}
|
||||
|
||||
function isAlreadyDeletedError(error: unknown): boolean {
|
||||
const code = getErrorCode(error);
|
||||
return code === 10008 || code === 404 || code === "10008" || code === "404";
|
||||
}
|
||||
|
||||
function hasChannelMessagesApi(channel: unknown): channel is {
|
||||
messages: {
|
||||
fetch: (id: string) => Promise<{ delete: () => Promise<unknown> }>;
|
||||
};
|
||||
} {
|
||||
return Boolean(
|
||||
channel &&
|
||||
typeof channel === "object" &&
|
||||
"messages" in channel &&
|
||||
(channel as { messages?: unknown }).messages &&
|
||||
typeof (channel as { messages: { fetch?: unknown } }).messages.fetch ===
|
||||
"function",
|
||||
);
|
||||
}
|
||||
|
||||
function hasPermissionApi(channel: unknown): channel is {
|
||||
permissionsFor: (
|
||||
member: unknown,
|
||||
) => { has: (permission: string) => boolean } | null;
|
||||
} {
|
||||
return Boolean(
|
||||
channel &&
|
||||
typeof channel === "object" &&
|
||||
"permissionsFor" in channel &&
|
||||
typeof (channel as { permissionsFor?: unknown }).permissionsFor ===
|
||||
"function",
|
||||
);
|
||||
}
|
||||
|
||||
export async function attemptAutoDeleteFlaggedMessage(
|
||||
client: Client | undefined,
|
||||
message: MessageRecord,
|
||||
): Promise<AutoDeleteResult> {
|
||||
if (!config.AUTO_DELETE_FLAGGED_ENABLED) {
|
||||
return { deleted: false, skipped: true, reason: "disabled" };
|
||||
}
|
||||
|
||||
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
||||
const result = {
|
||||
deleted: false,
|
||||
skipped: true,
|
||||
reason: "not_flagged_or_warn",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!isAutoDeleteEligible(message)) {
|
||||
const result = {
|
||||
deleted: false,
|
||||
skipped: true,
|
||||
reason: "not_eligible",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!client?.user?.id) {
|
||||
logger.warn(
|
||||
{ messageId: message.id },
|
||||
"Auto-delete skipped: client user missing",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "client_user_missing" };
|
||||
}
|
||||
|
||||
try {
|
||||
const guild = client.guilds.cache.get(message.guild_id);
|
||||
if (!guild) {
|
||||
logger.warn(
|
||||
{ messageId: message.id, guildId: message.guild_id },
|
||||
"Auto-delete skipped: guild not found",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "guild_not_found" };
|
||||
}
|
||||
|
||||
const channelId = message.thread_id ?? message.channel_id;
|
||||
const channel = guild.channels.cache.get(channelId);
|
||||
if (!channel) {
|
||||
logger.warn(
|
||||
{ messageId: message.id, channelId },
|
||||
"Auto-delete skipped: channel not found",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "channel_not_found" };
|
||||
}
|
||||
|
||||
if (!hasPermissionApi(channel) || !hasChannelMessagesApi(channel)) {
|
||||
logger.warn(
|
||||
{ messageId: message.id, channelId },
|
||||
"Auto-delete skipped: channel cannot delete messages",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "unsupported_channel" };
|
||||
}
|
||||
|
||||
const selfMember = await guild.members.fetch(client.user.id);
|
||||
const permissions = channel.permissionsFor(selfMember);
|
||||
const canManageMessages =
|
||||
permissions?.has("MANAGE_MESSAGES" as PermissionString) ?? false;
|
||||
|
||||
if (!canManageMessages) {
|
||||
logger.warn(
|
||||
{ messageId: message.id, channelId, userId: client.user.id },
|
||||
"Auto-delete skipped: current user lacks Manage Messages",
|
||||
);
|
||||
return {
|
||||
deleted: false,
|
||||
skipped: true,
|
||||
reason: "missing_manage_messages",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.AUTO_DELETE_FLAGGED_DRY_RUN) {
|
||||
const result = {
|
||||
deleted: false,
|
||||
skipped: true,
|
||||
reason: "dry_run",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
logger.info(
|
||||
{ messageId: message.id, channelId },
|
||||
"Auto-delete dry-run: would delete flagged message",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const discordMessage = await channel.messages.fetch(message.id);
|
||||
await discordMessage.delete();
|
||||
|
||||
const result = {
|
||||
deleted: true,
|
||||
skipped: false,
|
||||
reason: "deleted",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
logger.info(
|
||||
{ messageId: message.id, channelId },
|
||||
"Auto-deleted AI-flagged message",
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isAlreadyDeletedError(error)) {
|
||||
const result = {
|
||||
deleted: true,
|
||||
skipped: false,
|
||||
reason: "already_deleted",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
logger.info(
|
||||
{ messageId: message.id, code: getErrorCode(error) },
|
||||
"Auto-delete skipped: message already deleted",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = {
|
||||
deleted: false,
|
||||
skipped: true,
|
||||
reason: "error",
|
||||
} as AutoDeleteResult;
|
||||
await logAutoDeleteAttempt(message, result);
|
||||
logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
code: getErrorCode(error),
|
||||
},
|
||||
"Auto-delete failed",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { Channel, Client, Message } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { captureMessage } from "./messageCapture.js";
|
||||
|
||||
const logger = createChildLogger("backlog-sync");
|
||||
|
||||
type BacklogChannel = Channel & {
|
||||
messages: {
|
||||
fetch(options: { limit: number; before?: string }): Promise<{
|
||||
size: number;
|
||||
values(): IterableIterator<Message>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function hasMessageBacklog(channel: Channel): channel is BacklogChannel {
|
||||
return "messages" in channel;
|
||||
}
|
||||
|
||||
async function syncChannelMessages(
|
||||
channel: BacklogChannel,
|
||||
cutoffTime: number,
|
||||
): Promise<number> {
|
||||
let before: string | undefined;
|
||||
let synced = 0;
|
||||
let shouldContinue = true;
|
||||
|
||||
while (shouldContinue) {
|
||||
const batch = await channel.messages.fetch({
|
||||
limit: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||
...(before ? { before } : {}),
|
||||
});
|
||||
|
||||
if (batch.size === 0) break;
|
||||
|
||||
const messages = Array.from(batch.values()) as Message[];
|
||||
for (const message of messages) {
|
||||
if (message.author?.bot) continue;
|
||||
if (message.createdTimestamp < cutoffTime) {
|
||||
shouldContinue = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureMessage(message, "text", { source: "backlog" });
|
||||
synced++;
|
||||
}
|
||||
|
||||
before = messages[messages.length - 1]?.id;
|
||||
if (!before || batch.size < config.BACKLOG_SYNC_BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
return synced;
|
||||
}
|
||||
|
||||
export async function syncBacklogMessages(client: Client): Promise<void> {
|
||||
const textGuildId = config.EFFECTIVE_TEXT_GUILD_ID;
|
||||
if (!textGuildId) {
|
||||
logger.warn("TEXT_GUILD_ID not configured, skipping backlog sync");
|
||||
return;
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(textGuildId);
|
||||
if (!guild) {
|
||||
logger.warn(
|
||||
{ guildId: textGuildId },
|
||||
"Text guild not found, skipping backlog sync",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.TEXT_CHANNEL_ID) {
|
||||
await syncSelectedChannelBacklog(client, guild.id, config.TEXT_CHANNEL_ID);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSelectedChannelBacklog(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<number> {
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
logger.warn({ guildId }, "Guild not found for backlog sync");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const channel = guild.channels.cache.get(channelId);
|
||||
if (!channel) {
|
||||
logger.warn({ guildId, channelId }, "Channel not found for backlog sync");
|
||||
return 0;
|
||||
}
|
||||
if (!hasMessageBacklog(channel)) {
|
||||
logger.warn({ guildId, channelId }, "Channel cannot fetch message backlog");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - config.BACKLOG_SYNC_HOURS * 60 * 60 * 1000;
|
||||
try {
|
||||
const count = await syncChannelMessages(channel, cutoffTime);
|
||||
return count;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
channelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Backlog sync failed for selected channel",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { WebSocket } from "ws";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import type {
|
||||
AnalysisQueueStatus,
|
||||
AttachmentRecord,
|
||||
MediaState,
|
||||
MessageRecord,
|
||||
ModerationWsEvent,
|
||||
} from "./types.js";
|
||||
|
||||
export type BroadcasterClient = Pick<WebSocket, "readyState" | "send">;
|
||||
|
||||
const log = createChildLogger("broadcaster");
|
||||
|
||||
function sendJson(
|
||||
clients: Set<BroadcasterClient>,
|
||||
event: ModerationWsEvent,
|
||||
): void {
|
||||
const payload = JSON.stringify({ ...event, timestamp: Date.now() });
|
||||
for (const client of clients) {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ error, eventType: event.type },
|
||||
"Failed to send event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBroadcaster() {
|
||||
const clients = new Set<BroadcasterClient>();
|
||||
|
||||
return {
|
||||
addClient(client: BroadcasterClient) {
|
||||
clients.add(client);
|
||||
log.debug({ clientCount: clients.size }, "Client added");
|
||||
},
|
||||
removeClient(client: BroadcasterClient) {
|
||||
clients.delete(client);
|
||||
log.debug({ clientCount: clients.size }, "Client removed");
|
||||
},
|
||||
clientCount() {
|
||||
return clients.size;
|
||||
},
|
||||
getClients() {
|
||||
return Array.from(clients);
|
||||
},
|
||||
uiState(state: unknown) {
|
||||
sendJson(clients, { type: "ui_state", state });
|
||||
},
|
||||
userState(users: unknown[]) {
|
||||
sendJson(clients, { type: "user_state", users });
|
||||
},
|
||||
messageCreated(data: MessageRecord) {
|
||||
sendJson(clients, { type: "message_created", data });
|
||||
},
|
||||
messageUpdated(data: Partial<MessageRecord> & { id: string }) {
|
||||
sendJson(clients, { type: "message_updated", data });
|
||||
},
|
||||
messageDeleted(data: { id: string; deleted_at: number }) {
|
||||
sendJson(clients, { type: "message_deleted", data });
|
||||
},
|
||||
messageAnalyzed(data: MessageRecord) {
|
||||
sendJson(clients, { type: "message_analyzed", data });
|
||||
},
|
||||
attachmentCreated(data: AttachmentRecord) {
|
||||
sendJson(clients, { type: "attachment_created", data });
|
||||
},
|
||||
analysisQueueStatus(data: AnalysisQueueStatus) {
|
||||
sendJson(clients, { type: "analysis_queue_status", data });
|
||||
},
|
||||
mediaState(state: MediaState) {
|
||||
sendJson(clients, { type: "media_state", state });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ModerationBroadcaster = ReturnType<typeof createBroadcaster>;
|
||||
@@ -1,14 +0,0 @@
|
||||
import pLimit from "p-limit";
|
||||
import { config } from "../config.js";
|
||||
|
||||
/**
|
||||
* Concurrency limiter for LLM API calls.
|
||||
*
|
||||
* Prevents rate-limit (429) errors by capping simultaneous requests
|
||||
* to the configured maximum (default: 5).
|
||||
*/
|
||||
const llmSemaphore = pLimit(config.AI_LLM_MAX_CONCURRENT ?? 5);
|
||||
|
||||
export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return llmSemaphore(fn);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||
import { formatMediaEvidenceForPrompt } from "./messageMetadata.js";
|
||||
import type { MessageRecord } from "./types.js";
|
||||
|
||||
export interface ConversationContextInput {
|
||||
contextBefore: MessageRecord[];
|
||||
targets: MessageRecord[];
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp to ISO 8601 string
|
||||
*/
|
||||
function formatTimestamp(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count for a string (pessimistic approximation for Indonesian slang & JSON overhead)
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 3) + 15;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single message for context or target display
|
||||
*/
|
||||
export async function formatMessageForPrompt(
|
||||
msg: MessageRecord,
|
||||
label: "context" | "target",
|
||||
): Promise<string> {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const timestamp = formatTimestamp(msg.created_at);
|
||||
const textEvidence = await formatModerationTextEvidenceForPrompt(content);
|
||||
const textSuffix = textEvidence ? ` ${textEvidence}` : "";
|
||||
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${textSuffix}${mediaSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds conversation historical context without including targets.
|
||||
* Calculates how much token budget targets use, and fills the rest with context.
|
||||
*/
|
||||
export async function buildConversationContext(
|
||||
input: ConversationContextInput,
|
||||
): Promise<string[]> {
|
||||
const { contextBefore, targets, maxTokens } = input;
|
||||
|
||||
// Calculate tokens used by targets (parallel)
|
||||
const targetLines = await Promise.all(
|
||||
targets.map((msg) => formatMessageForPrompt(msg, "target")),
|
||||
);
|
||||
let usedTokens = targetLines.reduce(
|
||||
(sum, line) => sum + estimateTokens(line),
|
||||
0,
|
||||
);
|
||||
|
||||
const contextLines = await Promise.all(
|
||||
contextBefore.map((msg) => formatMessageForPrompt(msg, "context")),
|
||||
);
|
||||
const selectedContextLines: string[] = [];
|
||||
|
||||
// Go backwards through context, taking most recent first
|
||||
for (let i = contextLines.length - 1; i >= 0; i--) {
|
||||
const line = contextLines[i];
|
||||
const lineTokens = estimateTokens(line);
|
||||
|
||||
if (usedTokens + lineTokens <= maxTokens) {
|
||||
// Unshift so oldest context is first in the array
|
||||
selectedContextLines.unshift(line);
|
||||
usedTokens += lineTokens;
|
||||
}
|
||||
}
|
||||
|
||||
return selectedContextLines;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import sharp from "sharp";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const log = createChildLogger("imageResizer");
|
||||
|
||||
/**
|
||||
* Resize an image buffer for optimal vision LLM analysis.
|
||||
*
|
||||
* - Resizes to maxDim x maxDim maintaining aspect ratio
|
||||
* - Converts to JPEG at quality 85 for size reduction
|
||||
* - Falls back to original buffer if sharp fails
|
||||
*
|
||||
* @param buf - Raw image buffer
|
||||
* @param maxDim - Maximum dimension in pixels (default 1024)
|
||||
* @returns Resized buffer with detected MIME type
|
||||
*/
|
||||
export async function resizeImageForVision(
|
||||
buf: Buffer,
|
||||
maxDim = 1024,
|
||||
): Promise<{ data: Buffer; mimeType: string }> {
|
||||
try {
|
||||
const metadata = await sharp(buf).metadata();
|
||||
const inputFormat = metadata.format ?? "jpeg";
|
||||
|
||||
// Skip resize if already smaller than maxDim
|
||||
if ((metadata.width ?? 0) <= maxDim && (metadata.height ?? 0) <= maxDim) {
|
||||
return { data: buf, mimeType: `image/${inputFormat}` };
|
||||
}
|
||||
|
||||
const resized = await sharp(buf)
|
||||
.resize(maxDim, maxDim, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
|
||||
log.debug(
|
||||
{
|
||||
originalSize: buf.length,
|
||||
resizedSize: resized.length,
|
||||
reductionPct: Math.round(
|
||||
((buf.length - resized.length) / buf.length) * 100,
|
||||
),
|
||||
},
|
||||
"Image resized for vision analysis",
|
||||
);
|
||||
|
||||
return { data: resized, mimeType: "image/jpeg" };
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Image resize failed — using original buffer",
|
||||
);
|
||||
// Fallback: return original buffer with best-effort MIME type
|
||||
return { data: buf, mimeType: "image/jpeg" };
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { getCachedText, upsertCachedText } from "./textCacheStore.js";
|
||||
import { llmDetectBadwords } from "./llmClient.js";
|
||||
|
||||
const log = createChildLogger("indonesianTextNormalizer");
|
||||
|
||||
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
|
||||
/**
|
||||
* In-memory cache TTL (10 min) — fastest path for repeated identical texts.
|
||||
*/
|
||||
const BADWORD_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* DB cache TTL (24 hours) — survives restarts, stores full-text results
|
||||
* so context is preserved (e.g. "kaus" is clean, "kau" alone is clean,
|
||||
* but "awas kau" is harassment).
|
||||
*/
|
||||
const DB_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface BadwordCacheEntry {
|
||||
value: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const badwordCache = new Map<string, BadwordCacheEntry>();
|
||||
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
|
||||
|
||||
export interface ModerationTextEvidence {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
notes: string[];
|
||||
badwords: string[];
|
||||
hasBadwords: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function normalizeDiscordCustomEmoji(text: string): {
|
||||
text: string;
|
||||
emojiNames: string[];
|
||||
} {
|
||||
const emojiNames: string[] = [];
|
||||
const normalized = text.replace(
|
||||
CUSTOM_EMOJI_PATTERN,
|
||||
(_match, name: string) => {
|
||||
emojiNames.push(name);
|
||||
return `[emoji:${name}]`;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, emojiNames };
|
||||
}
|
||||
|
||||
function normalizeBadwordCacheKey(text: string): string {
|
||||
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function getCachedBadwords(key: string): string[] | null {
|
||||
const entry = badwordCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
badwordCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return [...entry.value];
|
||||
}
|
||||
|
||||
function setCachedBadwords(key: string, value: string[]): void {
|
||||
badwordCache.set(key, {
|
||||
value: [...new Set(value)],
|
||||
expiresAt: Date.now() + BADWORD_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
if (badwordCache.size > 500) {
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of badwordCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
badwordCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (badwordCache.size > 500) {
|
||||
const oldestKeys = Array.from(badwordCache.entries())
|
||||
.sort((a, b) => a[1].expiresAt - b[1].expiresAt)
|
||||
.slice(0, badwordCache.size - 500)
|
||||
.map(([cacheKey]) => cacheKey);
|
||||
for (const cacheKey of oldestKeys) {
|
||||
badwordCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-tier cache + Primary AI pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect badwords in text using a **two-tier cache + Primary AI**:
|
||||
*
|
||||
* 1. **In-memory cache** (BADWORD_CACHE_TTL_MS, 10 min) — fastest path,
|
||||
* keyed by the full normalized text string.
|
||||
* 2. **DB cache** (DB_CACHE_TTL_MS, 24 h) — same full-text key, persisted
|
||||
* across restarts. Uses the FULL normalized text (not per-word) because
|
||||
* context matters: "kau" alone is clean, but "awas kau" can be a threat.
|
||||
* 3. **Primary AI** (AI_LLM endpoint via llmClient) — only runs when both
|
||||
* cache layers miss.
|
||||
*
|
||||
* No local hardcoded badword list — all detection goes through AI APIs
|
||||
* to eliminate false positives from substring matching.
|
||||
*/
|
||||
export async function detectIndonesianBadwords(
|
||||
text: string,
|
||||
): Promise<string[]> {
|
||||
const cacheKey = normalizeBadwordCacheKey(text);
|
||||
|
||||
// ── Tier 1: In-memory cache (fastest) ──
|
||||
const cached = getCachedBadwords(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// De-duplicate concurrent lookups
|
||||
const inFlight = inFlightBadwordLookups.get(cacheKey);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const lookupPromise = (async () => {
|
||||
// ── Tier 2: DB cache (survives restarts, preserves context) ──
|
||||
const dbEntry = await getCachedText(cacheKey);
|
||||
if (dbEntry) {
|
||||
const flags = [...dbEntry.flags];
|
||||
setCachedBadwords(cacheKey, flags); // populate in-memory too
|
||||
return flags;
|
||||
}
|
||||
|
||||
// ── Tier 3: Primary AI only (via centralized llmClient) ──
|
||||
let finalHits: string[] = [];
|
||||
try {
|
||||
finalHits = await llmDetectBadwords(text);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Primary AI badword detection failed",
|
||||
);
|
||||
}
|
||||
|
||||
// Populate all cache tiers so the same text never triggers another API call
|
||||
// within the TTL window.
|
||||
setCachedBadwords(cacheKey, finalHits);
|
||||
await upsertCachedText(
|
||||
cacheKey,
|
||||
finalHits,
|
||||
"primary_ai",
|
||||
Date.now() + DB_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
return finalHits;
|
||||
})();
|
||||
|
||||
inFlightBadwordLookups.set(cacheKey, lookupPromise);
|
||||
|
||||
try {
|
||||
return await lookupPromise;
|
||||
} finally {
|
||||
inFlightBadwordLookups.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Async evidence builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildModerationTextEvidence(
|
||||
text: string,
|
||||
): Promise<ModerationTextEvidence> {
|
||||
const emojiNormalized = normalizeDiscordCustomEmoji(text);
|
||||
const badwordHits = await detectIndonesianBadwords(emojiNormalized.text);
|
||||
const notes: string[] = [];
|
||||
|
||||
for (const emojiName of emojiNormalized.emojiNames) {
|
||||
notes.push(
|
||||
`emoji:${emojiName}=Discord custom emoji/expression; not text offense by default`,
|
||||
);
|
||||
}
|
||||
|
||||
if (badwordHits.length > 0) {
|
||||
notes.push(`Indonesian badword detected: ${badwordHits.join(", ")}`);
|
||||
} else {
|
||||
notes.push("no Indonesian badword detected");
|
||||
}
|
||||
|
||||
return {
|
||||
raw: text,
|
||||
normalized: emojiNormalized.text,
|
||||
notes: Array.from(new Set(notes)),
|
||||
badwords: badwordHits,
|
||||
hasBadwords: badwordHits.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function formatModerationTextEvidenceForPrompt(
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
const evidence = await buildModerationTextEvidence(text);
|
||||
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return [
|
||||
`[normalized_text: ${evidence.normalized}]`,
|
||||
evidence.notes.length > 0
|
||||
? `[normalization_notes: ${evidence.notes.join("; ")}]`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* Centralised LLM chat completion helper.
|
||||
*
|
||||
* All `openai.chat.completions.create` calls in the moderation subsystem
|
||||
* go through this module so that model, concurrency, retry, and token
|
||||
* defaults are maintained in one place.
|
||||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { config } from "../config.js";
|
||||
import { retryWithBackoff } from "../retry.js";
|
||||
import { withLlmConcurrency } from "./concurrencyLimiter.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const log = createChildLogger("llm-client");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy singleton — created on first use so that config is always resolved.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let openaiClient: OpenAI | null = null;
|
||||
|
||||
function getClient(): OpenAI | null {
|
||||
if (!config.AI_LLM_API_KEY) return null;
|
||||
if (!openaiClient) {
|
||||
openaiClient = new OpenAI({
|
||||
apiKey: config.AI_LLM_API_KEY,
|
||||
baseURL: config.AI_LLM_BASE_URL,
|
||||
maxRetries: 0,
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
return openaiClient;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_TEMPERATURE = 0.2;
|
||||
const DEFAULT_TOP_P = 0.95;
|
||||
const DEFAULT_RETRIES = 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LlmCallOpts {
|
||||
/** Conversation to send. Either a string (→ single user message) or an array of messages. */
|
||||
messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[];
|
||||
/** Which model to use (defaults to config.AI_LLM_MODEL). */
|
||||
model?: string;
|
||||
/** Max output tokens (defaults to 8192). */
|
||||
max_tokens?: number;
|
||||
/** Temperature (defaults to 0.2). */
|
||||
temperature?: number;
|
||||
/** Top-p (defaults to 0.95). */
|
||||
top_p?: number;
|
||||
/** Force JSON output. When true, wraps schema in json_schema response_format. */
|
||||
jsonResponse?:
|
||||
| { type: "json_object" }
|
||||
| {
|
||||
type: "json_schema";
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
strict: boolean;
|
||||
};
|
||||
/** Extra retries beyond DEFAULT_RETRIES (default 2). */
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the LLM with sensible defaults: concurrency cap, retry, model, tokens.
|
||||
*
|
||||
* Returns the raw OpenAI ChatCompletion so callers can inspect
|
||||
* `choices[0].message.content`, `finish_reason`, `usage`, etc.
|
||||
*/
|
||||
export async function llmChat(
|
||||
opts: LlmCallOpts,
|
||||
): Promise<OpenAI.Chat.Completions.ChatCompletion | null> {
|
||||
const client = getClient();
|
||||
if (!client) return null;
|
||||
|
||||
const {
|
||||
messages,
|
||||
model = config.AI_LLM_MODEL,
|
||||
max_tokens = 8192,
|
||||
temperature = DEFAULT_TEMPERATURE,
|
||||
top_p = DEFAULT_TOP_P,
|
||||
jsonResponse,
|
||||
retries = DEFAULT_RETRIES,
|
||||
} = opts;
|
||||
|
||||
const responseFormat:
|
||||
| { type: "json_object" }
|
||||
| {
|
||||
type: "json_schema";
|
||||
json_schema: {
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
strict: boolean;
|
||||
};
|
||||
}
|
||||
| undefined = jsonResponse
|
||||
? jsonResponse.type === "json_schema"
|
||||
? {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: jsonResponse.name,
|
||||
schema: jsonResponse.schema,
|
||||
strict: jsonResponse.strict,
|
||||
},
|
||||
}
|
||||
: jsonResponse
|
||||
: undefined;
|
||||
|
||||
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming =
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
temperature,
|
||||
top_p,
|
||||
max_tokens,
|
||||
stream: false,
|
||||
...(responseFormat ? { response_format: responseFormat } : {}),
|
||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming;
|
||||
|
||||
return retryWithBackoff(
|
||||
async () => {
|
||||
return withLlmConcurrency(async () =>
|
||||
client.chat.completions.create(params),
|
||||
);
|
||||
},
|
||||
{
|
||||
retries,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
factor: 2,
|
||||
logger: log,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for the legacy text-only badword detection call in
|
||||
* `indonesianTextNormalizer`. Returns parsed flags or [].
|
||||
*/
|
||||
export async function llmDetectBadwords(text: string): Promise<string[]> {
|
||||
const completion = await llmChat({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " +
|
||||
'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' +
|
||||
Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") +
|
||||
". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " +
|
||||
text,
|
||||
},
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.1,
|
||||
top_p: 0.9,
|
||||
jsonResponse: { type: "json_object" },
|
||||
retries: 2,
|
||||
});
|
||||
|
||||
if (!completion) return [];
|
||||
const content = completion.choices[0]?.message?.content?.trim();
|
||||
if (!content) return [];
|
||||
return extractFlagsFromContent(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for vision (image/sticker/emoji) analysis.
|
||||
* Returns the raw completion content (trimmed) or null.
|
||||
*/
|
||||
export async function llmVision(
|
||||
promptText: string,
|
||||
imageUrl: { url: string },
|
||||
): Promise<string | null> {
|
||||
const completion = await llmChat({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text" as const, text: promptText },
|
||||
{ type: "image_url" as const, image_url: imageUrl },
|
||||
],
|
||||
},
|
||||
],
|
||||
model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL,
|
||||
max_tokens: 500,
|
||||
temperature: 0.1,
|
||||
top_p: 0.9,
|
||||
retries: 2,
|
||||
});
|
||||
|
||||
if (!completion) return null;
|
||||
return completion.choices[0]?.message?.content?.trim() ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flag extraction (reused from indonesianTextNormalizer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_PRIMARY_AI_FLAGS = new Set([
|
||||
"spam",
|
||||
"hate_speech",
|
||||
"sara",
|
||||
"hoaks",
|
||||
"harassment",
|
||||
"vulgar_language",
|
||||
"sexual_content",
|
||||
"sexual_deviation",
|
||||
"violence",
|
||||
"self_harm",
|
||||
"doxxing",
|
||||
"scam",
|
||||
"misinformation",
|
||||
"nsfw_image",
|
||||
"gore_image",
|
||||
"illegal_content",
|
||||
"gambling",
|
||||
"drugs",
|
||||
"child_safety",
|
||||
"financial_scam",
|
||||
"religious_insult",
|
||||
"self_promo",
|
||||
]);
|
||||
|
||||
function normalizeFlag(value: string): string | null {
|
||||
const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
|
||||
if (!lower) return null;
|
||||
if (VALID_PRIMARY_AI_FLAGS.has(lower)) return lower;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFlagsFromContent(content: string): string[] {
|
||||
const flags = new Set<string>();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const addValue = (v: unknown) => {
|
||||
if (typeof v !== "string") return;
|
||||
const n = normalizeFlag(v);
|
||||
if (n) flags.add(n);
|
||||
};
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) addValue(item);
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
for (const key of ["flags", "categories", "badwords"]) {
|
||||
const val = obj[key];
|
||||
if (Array.isArray(val)) {
|
||||
for (const item of val) addValue(item);
|
||||
} else {
|
||||
addValue(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.size > 0) return Array.from(flags);
|
||||
|
||||
const lower = content.toLowerCase();
|
||||
for (const flag of VALID_PRIMARY_AI_FLAGS) {
|
||||
if (lower.includes(flag)) flags.add(flag);
|
||||
}
|
||||
|
||||
return Array.from(flags);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
||||
import type { Client, Message } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { getModerationBroadcaster } from "../ws/broadcastGlobals.js";
|
||||
import { queueMessageAnalysis } from "./aiAnalyzer.js";
|
||||
import { processAttachmentUpload } from "./attachmentUploader.js";
|
||||
import {
|
||||
getDisplayContent,
|
||||
getMessageLocation,
|
||||
getMessageMetadata,
|
||||
} from "./messageMetadata.js";
|
||||
import {
|
||||
getMessageById,
|
||||
insertAttachment,
|
||||
updateMessageAsDeleted,
|
||||
updateMessageAsEdited,
|
||||
upsertMessageForCapture,
|
||||
} from "./messageStore.js";
|
||||
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||
import type { AttachmentRecord, MessageRecord } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("message-capture");
|
||||
|
||||
export interface TextCaptureTarget {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export interface MessageLocationInput {
|
||||
guildId?: string | null;
|
||||
channelId?: string | null;
|
||||
}
|
||||
|
||||
export function shouldCaptureMessageLocation(
|
||||
message: MessageLocationInput,
|
||||
target: TextCaptureTarget,
|
||||
): boolean {
|
||||
if (
|
||||
message.channelId === "1310988070996414494" ||
|
||||
message.channelId === "1265679542144467035" ||
|
||||
message.channelId === "1310867899745046558"
|
||||
)
|
||||
return false; // Skip specific channels
|
||||
if (!message.guildId || message.guildId !== target.guildId) return false;
|
||||
if (target.channelId && message.channelId !== target.channelId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTextCaptureTarget(): TextCaptureTarget {
|
||||
return {
|
||||
guildId: config.EFFECTIVE_TEXT_GUILD_ID,
|
||||
channelId: config.TEXT_CHANNEL_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMessageRecord(
|
||||
message: Message,
|
||||
type: "text" | "edited" | "deleted",
|
||||
): MessageRecord {
|
||||
const location = getMessageLocation(message);
|
||||
const metadata = getMessageMetadata(message);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
guild_id: message.guildId!,
|
||||
channel_id: location.channelId,
|
||||
thread_id: location.threadId,
|
||||
user_id: message.author?.id,
|
||||
username: message.author?.username,
|
||||
avatar_url: message.author?.avatarURL() || null,
|
||||
content: getDisplayContent(message),
|
||||
edited_content: null,
|
||||
created_at: message.createdTimestamp,
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
type,
|
||||
metadata: JSON.stringify(metadata),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentRecord(
|
||||
message: Message,
|
||||
location: ReturnType<typeof getMessageLocation>,
|
||||
attachment: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
size: number;
|
||||
contentType: string | null;
|
||||
url: string;
|
||||
},
|
||||
): AttachmentRecord {
|
||||
return {
|
||||
id: attachment.id,
|
||||
message_id: message.id,
|
||||
guild_id: message.guildId!,
|
||||
channel_id: location.channelId,
|
||||
thread_id: location.threadId,
|
||||
user_id: message.author?.id,
|
||||
filename: attachment.name || "unknown",
|
||||
size: attachment.size,
|
||||
type: attachment.contentType || "application/octet-stream",
|
||||
discord_url: attachment.url,
|
||||
uploaded_url: null,
|
||||
upload_status: "pending",
|
||||
upload_error: null,
|
||||
created_at: Date.now(),
|
||||
uploaded_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function captureMessage(
|
||||
message: Message,
|
||||
type: "text" | "edited" | "deleted",
|
||||
options: { source?: "live" | "backlog" } = {},
|
||||
): Promise<void> {
|
||||
const location = getMessageLocation(message);
|
||||
const messageRecord = buildMessageRecord(message, type);
|
||||
|
||||
const inserted = await upsertMessageForCapture(messageRecord);
|
||||
if (!inserted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isBacklog = options.source === "backlog";
|
||||
|
||||
const broadcaster = getModerationBroadcaster();
|
||||
if (broadcaster && !isBacklog) {
|
||||
broadcaster.messageCreated(messageRecord);
|
||||
}
|
||||
|
||||
// Invalidate analytics cache so real-time stats reflect the new message
|
||||
invalidateAnalyticsCache(messageRecord.guild_id);
|
||||
|
||||
const attachmentUploadTasks: Promise<void>[] = [];
|
||||
|
||||
// Insert attachments before queuing analysis to avoid race condition
|
||||
if (message.attachments.size > 0) {
|
||||
for (const [, attachment] of message.attachments) {
|
||||
const attachmentRecord = buildAttachmentRecord(message, location, {
|
||||
id: attachment.id,
|
||||
name: attachment.name,
|
||||
size: attachment.size,
|
||||
contentType: attachment.contentType,
|
||||
url: attachment.url,
|
||||
});
|
||||
|
||||
await insertAttachment(attachmentRecord);
|
||||
|
||||
// Initiate async upload (non-blocking, fire-and-forget)
|
||||
if (!isBacklog) {
|
||||
attachmentUploadTasks.push(
|
||||
processAttachmentUpload(
|
||||
attachment.id,
|
||||
attachment.url,
|
||||
attachment.name || "unknown",
|
||||
{
|
||||
contentType: attachment.contentType ?? undefined,
|
||||
refreshDiscordUrl: async () => {
|
||||
const freshMessage = await message.channel.messages.fetch(
|
||||
message.id,
|
||||
);
|
||||
const freshAttachment = freshMessage.attachments.get(
|
||||
attachment.id,
|
||||
);
|
||||
return freshAttachment?.url ?? null;
|
||||
},
|
||||
},
|
||||
).catch((err) => {
|
||||
logger.error(
|
||||
{ attachmentId: attachment.id, error: err },
|
||||
"Failed to initiate attachment upload",
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (broadcaster) {
|
||||
broadcaster.attachmentCreated(attachmentRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue analysis after attachment uploads settle so AI uses stable tele URLs.
|
||||
if (!isBacklog) {
|
||||
if (attachmentUploadTasks.length > 0) {
|
||||
let analysisQueued = false;
|
||||
let fallbackTimer: NodeJS.Timeout | null = null;
|
||||
const queueAnalysisOnce = () => {
|
||||
if (analysisQueued) return;
|
||||
analysisQueued = true;
|
||||
if (fallbackTimer) {
|
||||
clearTimeout(fallbackTimer);
|
||||
fallbackTimer = null;
|
||||
}
|
||||
queueMessageAnalysis(message.id);
|
||||
};
|
||||
|
||||
fallbackTimer = setTimeout(queueAnalysisOnce, 30000);
|
||||
Promise.allSettled(attachmentUploadTasks)
|
||||
.then(queueAnalysisOnce)
|
||||
.catch((err) => {
|
||||
logger.error(
|
||||
{ messageId: message.id, error: err },
|
||||
"Failed to queue message analysis after attachment upload",
|
||||
);
|
||||
queueAnalysisOnce();
|
||||
});
|
||||
} else {
|
||||
queueMessageAnalysis(message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerMessageCapture(client: Client): void {
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||
if (message.author?.bot) return;
|
||||
|
||||
try {
|
||||
await captureMessage(message, "text");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to capture message",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||
if (!shouldCaptureMessageLocation(newMessage, getTextCaptureTarget()))
|
||||
return;
|
||||
if (newMessage.author?.bot) return;
|
||||
|
||||
try {
|
||||
const existing = await getMessageById(newMessage.id);
|
||||
|
||||
if (existing) {
|
||||
const editedAt = Date.now();
|
||||
await updateMessageAsEdited(
|
||||
newMessage.id,
|
||||
getDisplayContent(newMessage as Message),
|
||||
editedAt,
|
||||
);
|
||||
queueMessageAnalysis(newMessage.id);
|
||||
|
||||
const broadcaster = getModerationBroadcaster();
|
||||
if (broadcaster) {
|
||||
broadcaster.messageUpdated({
|
||||
id: newMessage.id,
|
||||
edited_content: getDisplayContent(newMessage as Message),
|
||||
edited_at: editedAt,
|
||||
});
|
||||
}
|
||||
} else if (newMessage.author) {
|
||||
await captureMessage(newMessage as Message, "text");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
messageId: newMessage.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to capture message update",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
client.on("messageDelete", async (message) => {
|
||||
if (!shouldCaptureMessageLocation(message, getTextCaptureTarget())) return;
|
||||
if (!message.author) return;
|
||||
|
||||
try {
|
||||
const deletedAt = Date.now();
|
||||
await updateMessageAsDeleted(message.id, deletedAt);
|
||||
|
||||
const broadcaster = getModerationBroadcaster();
|
||||
if (broadcaster) {
|
||||
broadcaster.messageDeleted({
|
||||
id: message.id,
|
||||
deleted_at: deletedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to capture message deletion",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
import type {
|
||||
Message,
|
||||
TextChannel,
|
||||
ThreadChannel,
|
||||
} from "discord.js-selfbot-v13";
|
||||
|
||||
export interface MessageLocation {
|
||||
channelId: string;
|
||||
threadId: string | null;
|
||||
threadName: string | null;
|
||||
channelName: string | null;
|
||||
nsfw?: boolean;
|
||||
nsfwLevel?: string | null;
|
||||
ageRestricted?: boolean;
|
||||
}
|
||||
|
||||
export interface StickerEvidence {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
format: string | null;
|
||||
}
|
||||
|
||||
export interface CustomEmojiEvidence {
|
||||
id: string;
|
||||
name: string;
|
||||
animated: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface EmbedEvidence {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
url: string | null;
|
||||
color: number | null;
|
||||
image: string | null;
|
||||
thumbnail: string | null;
|
||||
author: {
|
||||
name: string | null;
|
||||
url: string | null;
|
||||
iconURL: string | null;
|
||||
} | null;
|
||||
footer: { text: string | null; iconURL: string | null } | null;
|
||||
fields: Array<{ name: string; value: string; inline: boolean }>;
|
||||
}
|
||||
|
||||
export interface AttachmentEvidence {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
contentType: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface MessageMediaEvidence {
|
||||
stickers: StickerEvidence[];
|
||||
embeds: EmbedEvidence[];
|
||||
attachments: AttachmentEvidence[];
|
||||
customEmojis: CustomEmojiEvidence[];
|
||||
}
|
||||
|
||||
export interface RichMessageMetadata {
|
||||
stickers: Array<StickerEvidence>;
|
||||
embeds: Array<EmbedEvidence>;
|
||||
attachments: Array<AttachmentEvidence>;
|
||||
customEmojis: Array<CustomEmojiEvidence>;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
tag: string | null;
|
||||
avatarURL: string | null;
|
||||
bot: boolean;
|
||||
};
|
||||
member: {
|
||||
displayName: string | null;
|
||||
roles: Array<{ id: string; name: string }>;
|
||||
joinedTimestamp: number | null;
|
||||
} | null;
|
||||
channel: MessageLocation;
|
||||
reference: {
|
||||
messageId: string | null;
|
||||
channelId: string | null;
|
||||
guildId: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function getMessageLocation(message: Message): MessageLocation {
|
||||
const channel = message.channel as TextChannel | ThreadChannel;
|
||||
const safetyChannel = channel as TextChannel & {
|
||||
nsfw?: boolean;
|
||||
nsfwLevel?: string | null;
|
||||
};
|
||||
if (!channel.isThread?.()) {
|
||||
return {
|
||||
channelId: message.channelId,
|
||||
threadId: null,
|
||||
threadName: null,
|
||||
channelName: "name" in channel ? channel.name : null,
|
||||
nsfw:
|
||||
typeof safetyChannel.nsfw === "boolean"
|
||||
? safetyChannel.nsfw
|
||||
: undefined,
|
||||
nsfwLevel:
|
||||
typeof safetyChannel.nsfwLevel === "string"
|
||||
? safetyChannel.nsfwLevel
|
||||
: null,
|
||||
ageRestricted:
|
||||
typeof safetyChannel.nsfw === "boolean"
|
||||
? safetyChannel.nsfw
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
channelId: channel.parentId ?? message.channelId,
|
||||
threadId: channel.id,
|
||||
threadName: channel.name,
|
||||
channelName: channel.parent?.name ?? null,
|
||||
nsfw:
|
||||
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
|
||||
nsfwLevel:
|
||||
typeof safetyChannel.nsfwLevel === "string"
|
||||
? safetyChannel.nsfwLevel
|
||||
: null,
|
||||
ageRestricted:
|
||||
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStickerMetadata(
|
||||
message: Message,
|
||||
): RichMessageMetadata["stickers"] {
|
||||
return Array.from(message.stickers.values()).map((sticker) => ({
|
||||
id: sticker.id,
|
||||
name: sticker.name,
|
||||
url: sticker.url,
|
||||
format: sticker.format ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract custom emoji references from message content.
|
||||
* Builds Discord CDN URLs for each emoji so they can be downloaded
|
||||
* and sent to the vision model for analysis.
|
||||
*/
|
||||
export function getCustomEmojiMetadata(
|
||||
message: Message,
|
||||
): RichMessageMetadata["customEmojis"] {
|
||||
const CUSTOM_EMOJI_PATTERN = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
const emojis: CustomEmojiEvidence[] = [];
|
||||
let match;
|
||||
while ((match = CUSTOM_EMOJI_PATTERN.exec(message.content)) !== null) {
|
||||
const [, animated, name, id] = match;
|
||||
const ext = animated ? "gif" : "png";
|
||||
emojis.push({
|
||||
id,
|
||||
name,
|
||||
animated: animated === "a",
|
||||
url: `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`,
|
||||
});
|
||||
}
|
||||
return emojis;
|
||||
}
|
||||
|
||||
export function getAttachmentMetadata(
|
||||
message: Message,
|
||||
): RichMessageMetadata["attachments"] {
|
||||
return Array.from(message.attachments.values()).map((attachment) => ({
|
||||
id: attachment.id,
|
||||
name: attachment.name || "unknown",
|
||||
url: attachment.url,
|
||||
contentType: attachment.contentType ?? null,
|
||||
size: attachment.size,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEmbedMetadata(
|
||||
message: Message,
|
||||
): RichMessageMetadata["embeds"] {
|
||||
return message.embeds.map((embed) => ({
|
||||
title: embed.title ?? null,
|
||||
description: embed.description ?? null,
|
||||
url: embed.url ?? null,
|
||||
color: embed.color ?? null,
|
||||
image: embed.image?.url ?? null,
|
||||
thumbnail: embed.thumbnail?.url ?? null,
|
||||
author: embed.author
|
||||
? {
|
||||
name: embed.author.name ?? null,
|
||||
url: embed.author.url ?? null,
|
||||
iconURL: embed.author.iconURL ?? null,
|
||||
}
|
||||
: null,
|
||||
footer: embed.footer
|
||||
? {
|
||||
text: embed.footer.text ?? null,
|
||||
iconURL: embed.footer.iconURL ?? null,
|
||||
}
|
||||
: null,
|
||||
fields: embed.fields.map((field) => ({
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
inline: Boolean(field.inline),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||
const member = message.member;
|
||||
return {
|
||||
stickers: getStickerMetadata(message),
|
||||
embeds: getEmbedMetadata(message),
|
||||
attachments: getAttachmentMetadata(message),
|
||||
customEmojis: getCustomEmojiMetadata(message),
|
||||
author: {
|
||||
id: message.author.id,
|
||||
username: message.author.username,
|
||||
tag: "tag" in message.author ? message.author.tag : null,
|
||||
avatarURL: message.author.avatarURL() ?? null,
|
||||
bot: Boolean(message.author.bot),
|
||||
},
|
||||
member: member
|
||||
? {
|
||||
displayName: member.displayName ?? null,
|
||||
roles: member.roles.cache.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
})),
|
||||
joinedTimestamp: member.joinedTimestamp ?? null,
|
||||
}
|
||||
: null,
|
||||
channel: getMessageLocation(message),
|
||||
reference: message.reference
|
||||
? {
|
||||
messageId: message.reference.messageId ?? null,
|
||||
channelId: message.reference.channelId ?? null,
|
||||
guildId: message.reference.guildId ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRichMessageMetadata(
|
||||
metadata: string | null | undefined,
|
||||
): RichMessageMetadata | null {
|
||||
if (!metadata) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadata) as Partial<RichMessageMetadata>;
|
||||
return {
|
||||
stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [],
|
||||
embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [],
|
||||
attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [],
|
||||
customEmojis: Array.isArray(parsed.customEmojis)
|
||||
? parsed.customEmojis
|
||||
: [],
|
||||
author: parsed.author as RichMessageMetadata["author"],
|
||||
member: (parsed.member ?? null) as RichMessageMetadata["member"],
|
||||
channel: parsed.channel as RichMessageMetadata["channel"],
|
||||
reference: (parsed.reference ?? null) as RichMessageMetadata["reference"],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAgeRestrictedMetadata(
|
||||
metadata: string | null | undefined,
|
||||
): boolean {
|
||||
const parsed = parseRichMessageMetadata(metadata);
|
||||
if (!parsed) return false;
|
||||
|
||||
const nsfwLevel = parsed.channel.nsfwLevel?.toUpperCase();
|
||||
return Boolean(
|
||||
parsed.channel.nsfw ||
|
||||
parsed.channel.ageRestricted ||
|
||||
nsfwLevel === "AGE_RESTRICTED",
|
||||
);
|
||||
}
|
||||
|
||||
export function extractMessageMediaEvidence(
|
||||
metadata: string | null | undefined,
|
||||
): MessageMediaEvidence {
|
||||
const parsed = parseRichMessageMetadata(metadata);
|
||||
return {
|
||||
stickers: parsed?.stickers ?? [],
|
||||
embeds: parsed?.embeds ?? [],
|
||||
attachments: parsed?.attachments ?? [],
|
||||
customEmojis: parsed?.customEmojis ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMediaEvidenceForPrompt(
|
||||
metadata: string | null | undefined,
|
||||
): string {
|
||||
const evidence = extractMessageMediaEvidence(metadata);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (evidence.stickers.length > 0) {
|
||||
parts.push(
|
||||
`[stickers: ${evidence.stickers
|
||||
.map((sticker) =>
|
||||
[`name=${sticker.name}`, sticker.url ? `url=${sticker.url}` : null]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
)
|
||||
.join(" | ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.embeds.length > 0) {
|
||||
parts.push(
|
||||
`[embeds: ${evidence.embeds
|
||||
.map((embed) =>
|
||||
[
|
||||
embed.title ? `title=${embed.title}` : null,
|
||||
embed.description ? `description=${embed.description}` : null,
|
||||
embed.url ? `url=${embed.url}` : null,
|
||||
embed.image ? `image=${embed.image}` : null,
|
||||
embed.thumbnail ? `thumbnail=${embed.thumbnail}` : null,
|
||||
embed.fields.length > 0
|
||||
? `fields=${embed.fields.map((field) => `${field.name}: ${field.value}`).join("; ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
)
|
||||
.join(" | ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.attachments.length > 0) {
|
||||
parts.push(
|
||||
`[attachments: ${evidence.attachments
|
||||
.map((attachment) =>
|
||||
[
|
||||
`name=${attachment.name}`,
|
||||
attachment.contentType ? `type=${attachment.contentType}` : null,
|
||||
`size=${attachment.size}`,
|
||||
attachment.url ? `url=${attachment.url}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
)
|
||||
.join(" | ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function getDisplayContent(message: Message): string {
|
||||
if (message.content.trim().length > 0) return message.content;
|
||||
|
||||
const stickers = getStickerMetadata(message);
|
||||
if (stickers.length > 0) {
|
||||
return stickers.map((sticker) => `[Sticker: ${sticker.name}]`).join(" ");
|
||||
}
|
||||
|
||||
const attachments = getAttachmentMetadata(message);
|
||||
if (attachments.length > 0) {
|
||||
return attachments
|
||||
.map((attachment) => `[Attachment: ${attachment.name}]`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const embeds = getEmbedMetadata(message);
|
||||
if (embeds.length > 0) {
|
||||
return embeds
|
||||
.map((embed) => embed.title || embed.description || "[Embed]")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* Modular system prompt builder for LLM moderation.
|
||||
*
|
||||
* Split into composable sections:
|
||||
* - buildSystemRules() — culture/slang/flag definitions (static)
|
||||
* - buildMediaInstructions() — media/sticker analysis guidance (conditional)
|
||||
* - buildFewShotExamples() — 3 example outputs (static)
|
||||
* - buildSystemPrompt() — assembles all sections with XML delimiters
|
||||
*
|
||||
* XML delimiters prevent prompt injection by clearly separating
|
||||
* system instructions from user-supplied data.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: System Rules (static — culture, slang, flag definitions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SYSTEM_RULES = `Kamu adalah asisten moderasi konten untuk server Discord berbahasa Indonesia.
|
||||
Bahasa utama komunitas ini adalah BAHASA INDONESIA. Bahasa Inggris adalah bahasa sekunder.
|
||||
|
||||
## Aturan Umum
|
||||
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll adalah AMAN.
|
||||
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll adalah AMAN.
|
||||
- Makian/kata kasar umum (seperti "anjing", "asu", "bangsat") BUKAN pelanggaran SARA. SARA khusus untuk diskriminasi/hinaan terhadap Suku, Agama, Ras, dan Antargolongan. NAMUN makian/kata kasar TETAP bisa di-flag sebagai "harassment" atau "vulgar_language" HANYA jika: (1) ditujukan langsung ke orang lain sebagai serangan/hinaan, (2) dalam tone agresif/mengancam, atau (3) bagian dari pola harassment berkelanjutan.
|
||||
- Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu".
|
||||
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
|
||||
- Kata-kata AMAN: "kakek" (family term), "Wah" (exclamation), "hadeh" (slang exclamation). Jangan flag sebagai vulgar_language atau harassment.
|
||||
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi, bukan pelanggaran teks.
|
||||
- Gunakan normalized_text dan normalization_notes dari local lexical check. Jika notes hanya berisi slang/emoji aman, jangan flag. Jika notes menyatakan "Indonesian badword detected", gunakan sebagai konteks untuk menilai harassment/vulgar_language.
|
||||
|
||||
## Kategori Pelanggaran & Kriteria Flag
|
||||
Prioritas tertinggi (ANCAMAN KESELAMATAN):
|
||||
- child_safety, self_harm, violence, illegal_content — flag jika ada indikasi nyata
|
||||
- Pornografi/NSFW, ajakan seksual, roleplay seksual → "sexual_content"
|
||||
- Judi/promosi judi → "gambling"
|
||||
- Narkoba/promosi → "drugs"
|
||||
|
||||
Prioritas menengah (PERILAKU MERUSAK):
|
||||
- Ancaman kekerasan, doxxing, scam → flag sesuai kategori
|
||||
- spam self-promo → "spam"
|
||||
- Istilah agama/suku/ras: penyebutan netral/edukasi = clean; hinaan/provokasi/diskriminatif = "sara" atau "hate_speech"
|
||||
|
||||
Prioritas rendah (PELANGGARAN RINGAN):
|
||||
- harassment (targeted insult), vulgar_language (profanity terarah)
|
||||
- sexual_deviation: jika pesan mempromosikan/mendukung topik seksual/identitas yang dibatasi server sebagai pembahasan utama
|
||||
|
||||
## Pohon Keputusan (Decision Tree)
|
||||
1. Apakah ada ancaman keselamatan nyata (child_safety, self_harm, violence)? → flagged, critical
|
||||
2. Apakah ada konten ilegal/explicit (NSFW, drugs, gambling, scam)? → flagged, high
|
||||
3. Apakah ada harassment terarah/hate speech/sara? → flagged, medium-high
|
||||
4. Apakah ada spam/promosi borderline? → warn, low-medium
|
||||
5. Jika tidak ada pelanggaran jelas atau bukti ambigu → clean
|
||||
Jangan pernah flag hanya berdasarkan kecurigaan atau ketidakjelasan konteks.
|
||||
|
||||
## ATURAN UNTUK GAMBAR — DUA MODE BERBEDA
|
||||
|
||||
### Mode 1: Teks + Gambar (teks adalah bukti utama)
|
||||
- Jika ada teks percakapan normal ("Aku suka nasgor loh", "Halo guys") → gambar hampir pasti bukan pelanggaran.
|
||||
- Jika teks clean: OVERRIDE klaim vision tentang judi KECUALI ada bukti spesifik (chip, kartu, odds, logo dikenal).
|
||||
- Teks lebih penting dari gambar.
|
||||
|
||||
### Mode 2: HANYA GAMBAR (teks kosong/sangat pendek/tidak bermakna)
|
||||
- **Deskripsi gambar MENJADI bukti utama.** Tidak ada teks untuk dijadikan acuan.
|
||||
- BACA Media analysis dengan teliti. Deskripsi itulah satu-satunya konteks.
|
||||
- Jika deskripsi menyebutkan "terminal", "console", "editor kode" → itu BUKAN gambling. Clean.
|
||||
- Jika deskripsi menyebutkan "aplikasi chat", "screenshot percakapan" → itu BUKAN gambling. Clean.
|
||||
- Jika deskripsi menyebutkan "foto makanan/pemandangan/selfie/hewan" → Clean.
|
||||
- **HANYA flag gambling jika deskripsi SECARA EKSPLISIT menyebutkan elemen judi NYATA: chip, kartu remi, meja taruhan, odds, deposit/withdraw, logo situs judi.**
|
||||
- JANGAN abaikan gambar hanya karena teks kosong. Analisis TETAP harus dilakukan berdasarkan deskripsi gambar.
|
||||
- **Jika pesan HANYA berisi gambar tanpa teks → WAJIB membaca Media analysis dan membuat keputusan berdasarkan deskripsi tersebut.**`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Media Instructions (conditional — injected when media present)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MEDIA_INSTRUCTIONS = `## Instruksi Analisis Media
|
||||
Gambar, sticker, embed image, preview link, dan attachment sudah DIDESKRIPSIKAN oleh vision model sebelum batch utama.
|
||||
Baris "Media analysis" berisi DESKRIPSI OBJEKTIF tentang apa yang terlihat di gambar, BUKAN keputusan moderasi.
|
||||
Vision model TIDAK memutuskan apakah gambar melanggar atau tidak — ia hanya mendeskripsikan isi visual.
|
||||
|
||||
## ATURAN KRITIS — Kamu yang Memutuskan, Bukan Vision Model
|
||||
- **KAMU adalah moderator.** Deskripsi dari vision model adalah SAKSI MATA, bukan hakim.
|
||||
- Jika deskripsi vision menyebutkan "screenshot terminal", "aplikasi chat", "tampilan website", "foto makanan" → itu BUKAN bukti pelanggaran apapun.
|
||||
- HANYA flag "gambling" jika KAMU menyimpulkan dari deskripsi bahwa gambar menunjukkan situs judi (chip, kartu remi, meja taruhan, odds, deposit/withdraw).
|
||||
- **PESAN HANYA GAMBAR (teks kosong/pendek):** WAJIB menganalisis Media analysis. Deskripsi gambar adalah satu-satunya bukti. JANGAN otomatis clean hanya karena teks kosong. Baca deskripsi → putuskan.
|
||||
- **PESAN DENGAN TEKS:** Bukti teks LEBIH PENTING dari deskripsi gambar. Jika teks pesan adalah percakapan biasa dan tidak mengandung promosi judi, maka gambar tersebut TIDAK MUNGKIN adalah pelanggaran judi.
|
||||
- **Jika teks clean dan deskripsi gambar biasa → wajib clean.**
|
||||
- Deskripsi vision yang menyebutkan hal-hal netral (terminal, chat, editor kode, website, grafik, chart) TIDAK BOLEH dijadikan dasar untuk flag gambling.
|
||||
|
||||
## Panduan Khusus Sticker
|
||||
- Sticker Discord adalah media kartun/meme/ilustrasi, BUKAN foto atau video nyata.
|
||||
- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.
|
||||
- Gambar sticker bisa menampilkan adegan kartun yang terlihat "keras" — itu SENI KARTUN, bukan dokumentasi kekerasan nyata.
|
||||
- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat") adalah konteks satir/humor. JANGAN flag berdasarkan nama sticker saja.
|
||||
- Terapkan standar yang lebih longgar untuk konten kartun/meme dibanding foto/video nyata.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Few-Shot Examples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FEW_SHOT_EXAMPLES = `## Contoh Output yang Benak
|
||||
|
||||
Contoh 1 — Pesan bersih dengan slang:
|
||||
Input: [target] id=12345 user=budi: anjay wkwk gaskeun santuy bro
|
||||
Output: {"results":[{"message_id":"12345","status":"clean","flags":[],"score":0.0,"categories":[],"severity":"none","confidence":0.95,"recommended_action":"none","policy_version":"default-2026-05-30","evidence":[],"analysis":"Slang Indonesia umum tanpa pelanggaran terdeteksi."}]}
|
||||
|
||||
Contoh 2 — Harassment terarah:
|
||||
Input: [target] id=67890 user=anon: lu goblok banget sih kontol, mampus aja lo
|
||||
Output: {"results":[{"message_id":"67890","status":"flagged","flags":["harassment","vulgar_language"],"score":0.85,"categories":["harassment","vulgar_language"],"severity":"high","confidence":0.9,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["lu goblok banget sih kontol","mampus aja lo"],"analysis":"Insult langsung dengan kata kasar terarah ke individu."}]}
|
||||
|
||||
Contoh 3 — Sticker kartun dengan nama provokatif:
|
||||
Input: [target] id=11111 user=citra: <:singa_injek:123456> [sticker: "Singa injek pejabat"]
|
||||
Output: {"results":[{"message_id":"11111","status":"clean","flags":[],"score":0.1,"categories":[],"severity":"none","confidence":0.8,"recommended_action":"none","policy_version":"default-2026-05-30","evidence":[],"analysis":"Sticker kartun satir dengan nama provokatif namun bukan ancaman nyata."}]}
|
||||
|
||||
Contoh 4 — Pesan biasa dengan gambar (JANGAN flag sebagai judi):
|
||||
Input: [target] id=22222 user=rina: Aku suka nasgor loh [Media analysis for message 22222] [gambar di atas adalah attachment foto.jpg dari pesan id=22222]: Gambar menampilkan tangkapan layar aplikasi chat dengan teks percakapan biasa. Tidak ada konten melanggar terlihat. Aman.
|
||||
Output: {"results":[{"message_id":"22222","status":"clean","flags":[],"score":0.0,"categories":[],"severity":"none","confidence":0.95,"recommended_action":"none","policy_version":"default-2026-05-30","evidence":[],"analysis":"Pesan berisi percakapan sehari-hari tentang makanan. Gambar menunjukkan screenshot chat biasa tanpa pelanggaran."}]}
|
||||
|
||||
Contoh 5 — Pesan promosi judi dengan gambar situs judi:
|
||||
Input: [target] id=33333 user=spammer: MAIN DI SINI GACOR PARAH https://judionline.xyz [Media analysis for message 33333] [gambar di atas adalah attachment slot.jpg dari pesan id=33333]: Gambar menampilkan antarmuka situs judi online dengan mesin slot, chip, dan tombol deposit. Terlihat logo "JudiOnline" dan odds taruhan.
|
||||
Output: {"results":[{"message_id":"33333","status":"flagged","flags":["gambling"],"score":0.92,"categories":["gambling"],"severity":"high","confidence":0.92,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["MAIN DI SINI GACOR PARAH","https://judionline.xyz","Gambar menampilkan antarmuka situs judi online dengan mesin slot, chip, dan tombol deposit"],"analysis":"Promosi situs judi online dengan link, teks promosi, dan gambar antarmuka judi yang jelas."}]}
|
||||
|
||||
Contoh 6 — Pesan HANYA GAMBAR tanpa teks (WAJIB analisis deskripsi):
|
||||
Input: [target] id=44444 user=dev: [Media analysis for message 44444] [gambar di atas adalah attachment screenshot.png dari pesan id=44444]: Screenshot terminal Linux dengan background hitam dan teks hijau. Terlihat output command 'ls -la' dan 'git status'. Tidak ada teks atau elemen mencurigakan.
|
||||
Output: {"results":[{"message_id":"44444","status":"clean","flags":[],"score":0.0,"categories":[],"severity":"none","confidence":0.95,"recommended_action":"none","policy_version":"default-2026-05-30","evidence":[],"analysis":"dev mengirim screenshot terminal Linux. Terlihat output command ls -la dan git status dengan teks hijau di background hitam. Aktivitas coding biasa, tidak ada konten melanggar."}]}
|
||||
|
||||
Contoh 7 — Pesan HANYA GAMBAR situs judi (teks kosong, tapi gambar jelas):
|
||||
Input: [target] id=55555 user=promotor: [Media analysis for message 55555] [gambar di atas adalah attachment promo.jpg dari pesan id=55555]: Screenshot website dengan background merah dan emas. Terlihat teks "DEPOSIT NOW", "BONUS 100%", "SLOT GACOR", chip poker, dan roda roulette. Ada tombol "DAFTAR" dan "LOGIN".
|
||||
Output: {"results":[{"message_id":"55555","status":"flagged","flags":["gambling"],"score":0.94,"categories":["gambling"],"severity":"high","confidence":0.94,"recommended_action":"delete","policy_version":"default-2026-05-30","evidence":["Gambar menampilkan antarmuka situs judi online dengan chip, roulette, tombol deposit, dan teks promosi judi"],"analysis":"Promosi situs judi melalui gambar dengan elemen judi jelas: chip, roulette, teks deposit dan bonus."}]}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Output Schema + XML Delimiter Instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OUTPUT_INSTRUCTIONS = `## Format Output
|
||||
Balas HANYA dengan satu objek JSON valid. Tanpa markdown, tanpa prose, tanpa komentar, tanpa XML.
|
||||
Struktur wajib:
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"message_id": "<ID string PERSIS seperti di input>",
|
||||
"status": "clean" | "warn" | "flagged",
|
||||
"flags": ["<string array, kosong jika clean>"],
|
||||
"score": 0.0,
|
||||
"categories": ["<kategori kebijakan, kosong jika clean>"],
|
||||
"severity": "none" | "low" | "medium" | "high" | "critical",
|
||||
"confidence": 0.0,
|
||||
"recommended_action": "none" | "monitor" | "warn" | "review" | "delete" | "escalate",
|
||||
"policy_version": "default-2026-05-30",
|
||||
"evidence": ["<kutipan/evidence singkat>"],
|
||||
"analysis": "<penjelasan singkat dalam Bahasa Indonesia, maks 2-3 kalimat>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
## FORMAT WAJIB — Field "analysis" HARUS deskriptif berdasarkan konten:
|
||||
|
||||
### Jika HANYA TEKS (tidak ada gambar/media):
|
||||
Tulis: "[user] membahas tentang <topik>. <konteks percakapan>. <kesimpulan moderasi>."
|
||||
Contoh baik: "budi membahas tentang makan siang dengan teman-teman. Percakapan santai menggunakan slang Indonesia. Tidak ada pelanggaran."
|
||||
Contoh buruk: "Pesan hanya berisi teks tanpa pelanggaran."
|
||||
|
||||
### Jika HANYA GAMBAR (teks kosong/tidak bermakna):
|
||||
Tulis: "Gambar berupa <jenis gambar dari Media analysis>. Terlihat <deskripsi isi dari Media analysis>. <kesimpulan moderasi>."
|
||||
Contoh baik: "Gambar berupa screenshot terminal Linux. Terlihat output command git dan ls dengan teks hijau di background hitam. Tidak ada konten melanggar."
|
||||
Contoh buruk: "Pesan hanya berisi attachment tanpa pelanggaran."
|
||||
|
||||
### Jika TEKS + GAMBAR:
|
||||
Tulis: "[user] mengirim <jenis gambar dari Media analysis> sambil membahas tentang <topik teks>. <korelasi teks dan gambar>. <kesimpulan moderasi>."
|
||||
Contoh baik: "rina mengirim screenshot chat sambil membahas tentang makanan favorit. Gambar dan teks sama-sama tentang percakapan sehari-hari. Tidak ada pelanggaran."
|
||||
Contoh buruk: "Pesan berisi teks dan gambar tanpa pelanggaran."
|
||||
|
||||
### Jika melanggar:
|
||||
Tulis: "[user] <melakukan pelanggaran X>. <bukti dari teks dan/atau gambar>. <dampak/konteks>."
|
||||
Contoh baik: "spammer mempromosikan situs judi online dengan link dan gambar antarmuka judi. Gambar menunjukkan chip, roulette, dan tombol deposit. Melanggar kebijakan gambling."
|
||||
|
||||
CRITICAL:
|
||||
- JANGAN PERNAH menulis "Pesan hanya berisi..." atau "Pesan tidak mengandung..." sebagai analysis.
|
||||
- Selalu sebutkan ISI KONTEN secara spesifik — apa yang dibicarakan, apa yang terlihat di gambar.
|
||||
- Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
|
||||
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
|
||||
|
||||
Flag yang valid: spam, hate_speech, sara, hoaks, harassment, vulgar_language, sexual_content, sexual_deviation, violence, self_harm, doxxing, scam, misinformation, nsfw_image, gore_image, illegal_content, gambling, drugs, child_safety, financial_scam, religious_insult, self_promo
|
||||
|
||||
CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan perlakukan ID sebagai angka.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composer: assembles all sections with XML delimiters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
contextText: string;
|
||||
includeMediaInstructions: boolean;
|
||||
correction?: { error: string; preview: string };
|
||||
}
|
||||
|
||||
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
const { contextText, includeMediaInstructions, correction } = options;
|
||||
|
||||
const parts: string[] = [SYSTEM_RULES];
|
||||
|
||||
if (includeMediaInstructions) {
|
||||
parts.push(MEDIA_INSTRUCTIONS);
|
||||
}
|
||||
|
||||
parts.push(FEW_SHOT_EXAMPLES);
|
||||
parts.push(OUTPUT_INSTRUCTIONS);
|
||||
|
||||
// XML-delimited context — prevents prompt injection
|
||||
const delimitedContext = `<conversation_context>\n${contextText}\n</conversation_context>`;
|
||||
parts.push(delimitedContext);
|
||||
|
||||
let base = parts.join("\n\n");
|
||||
|
||||
if (correction) {
|
||||
base += `\n\nRESPON SEBELUMNYA GAGAL VALIDASI.\nError: ${correction.error}\nPreview respons tidak valid:\n${correction.preview}\n\nCoba lagi dengan output JSON yang benar sesuai skema di atas.`;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface CursorData {
|
||||
created_at: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeCursor(data: CursorData): string {
|
||||
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||
}
|
||||
|
||||
export function decodeCursor(cursor?: string): CursorData | null {
|
||||
if (!cursor) return null;
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(cursor, "base64").toString("utf-8"));
|
||||
if (typeof data.created_at === "number" && typeof data.id === "string") {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { and, eq, isNull, lt } from "drizzle-orm";
|
||||
import { getDatabase } from "../database/drizzle.js";
|
||||
import {
|
||||
attachmentsTable,
|
||||
messagesTable,
|
||||
retentionPoliciesTable,
|
||||
voiceRecordingsTable,
|
||||
} from "../database/schema.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { getRetentionPolicy } from "./messageStore.js";
|
||||
import type { RetentionPolicy } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("retention-manager");
|
||||
|
||||
interface RetentionResult {
|
||||
messagesDeleted: number;
|
||||
attachmentsDeleted: number;
|
||||
voiceRecordingsDeleted: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes retention policy for a guild
|
||||
* Deletes messages, attachments, and voice recordings older than retention_days
|
||||
*/
|
||||
export async function executeRetentionPolicy(
|
||||
guildId: string,
|
||||
): Promise<RetentionResult> {
|
||||
const result: RetentionResult = {
|
||||
messagesDeleted: 0,
|
||||
attachmentsDeleted: 0,
|
||||
voiceRecordingsDeleted: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const policy = await getRetentionPolicy(guildId);
|
||||
if (!policy || !policy.enabled) {
|
||||
logger.debug({ guildId }, "Retention policy not enabled");
|
||||
return result;
|
||||
}
|
||||
|
||||
const db = getDatabase() as any;
|
||||
const cutoffTime = Date.now() - policy.retention_days * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Delete old messages
|
||||
const deletedMessages = await db
|
||||
.delete(messagesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(messagesTable.guild_id, guildId),
|
||||
lt(messagesTable.created_at, cutoffTime),
|
||||
isNull(messagesTable.deleted_at),
|
||||
),
|
||||
);
|
||||
|
||||
result.messagesDeleted = deletedMessages.rowsAffected || 0;
|
||||
|
||||
// Delete old attachments if policy applies
|
||||
if (policy.apply_to_media) {
|
||||
const deletedAttachments = await db
|
||||
.delete(attachmentsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(attachmentsTable.guild_id, guildId),
|
||||
lt(attachmentsTable.created_at, cutoffTime),
|
||||
),
|
||||
);
|
||||
|
||||
result.attachmentsDeleted = deletedAttachments.rowsAffected || 0;
|
||||
}
|
||||
|
||||
// Delete old voice recordings if policy applies
|
||||
if (policy.apply_to_voice) {
|
||||
const deletedVoice = await db
|
||||
.delete(voiceRecordingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(voiceRecordingsTable.guild_id, guildId),
|
||||
lt(voiceRecordingsTable.created_at, cutoffTime),
|
||||
),
|
||||
);
|
||||
|
||||
result.voiceRecordingsDeleted = deletedVoice.rowsAffected || 0;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
guildId,
|
||||
retentionDays: policy.retention_days,
|
||||
...result,
|
||||
},
|
||||
"Retention policy executed",
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{ guildId, error: message },
|
||||
"Failed to execute retention policy",
|
||||
);
|
||||
result.error = message;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes retention policies for all enabled guilds
|
||||
* Returns summary of deletions
|
||||
*/
|
||||
export async function executeAllRetentionPolicies(): Promise<{
|
||||
policiesExecuted: number;
|
||||
totalMessagesDeleted: number;
|
||||
totalAttachmentsDeleted: number;
|
||||
totalVoiceDeleted: number;
|
||||
errors: Array<{ guildId: string; error: string }>;
|
||||
}> {
|
||||
const summary = {
|
||||
policiesExecuted: 0,
|
||||
totalMessagesDeleted: 0,
|
||||
totalAttachmentsDeleted: 0,
|
||||
totalVoiceDeleted: 0,
|
||||
errors: [] as Array<{ guildId: string; error: string }>,
|
||||
};
|
||||
|
||||
try {
|
||||
const db = getDatabase() as any;
|
||||
const policies = await db
|
||||
.select()
|
||||
.from(retentionPoliciesTable)
|
||||
.where(eq(retentionPoliciesTable.enabled, true));
|
||||
|
||||
for (const policy of policies as RetentionPolicy[]) {
|
||||
const result = await executeRetentionPolicy(policy.guild_id);
|
||||
summary.policiesExecuted++;
|
||||
summary.totalMessagesDeleted += result.messagesDeleted;
|
||||
summary.totalAttachmentsDeleted += result.attachmentsDeleted;
|
||||
summary.totalVoiceDeleted += result.voiceRecordingsDeleted;
|
||||
|
||||
if (result.error) {
|
||||
summary.errors.push({
|
||||
guildId: policy.guild_id,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(summary, "All retention policies executed");
|
||||
return summary;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{ error: message },
|
||||
"Failed to execute all retention policies",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic retention policy executor
|
||||
* Runs every 24 hours by default
|
||||
*/
|
||||
export function startRetentionPolicyWorker(
|
||||
intervalMs: number = 24 * 60 * 60 * 1000,
|
||||
): NodeJS.Timeout {
|
||||
logger.info({ intervalMs }, "Starting retention policy worker");
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await executeAllRetentionPolicies();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Retention policy worker failed",
|
||||
);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
return interval;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { executeAll, executeGet } from "../database/drizzle.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const logger = createChildLogger("sticker-cache");
|
||||
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100MB hardcoded
|
||||
|
||||
let ready = false;
|
||||
let statsCache = { entryCount: 0, totalSizeBytes: 0 };
|
||||
|
||||
export interface StickerCacheEntry {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fetchedAt: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function sanitizeKey(name: string): string {
|
||||
return encodeURIComponent(name).replace(/%/g, "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the sticker cache from PostgreSQL.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export async function initStickerCache(): Promise<void> {
|
||||
if (ready) return;
|
||||
try {
|
||||
// Ensure the table exists — belt-and-suspenders in case the migration
|
||||
// hasn't run yet (e.g. pre-existing DB that Drizzle skips).
|
||||
await executeAll(`
|
||||
CREATE TABLE IF NOT EXISTS "sticker_cache" (
|
||||
"name" text PRIMARY KEY NOT NULL,
|
||||
"base64" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"fetched_at" bigint NOT NULL
|
||||
)
|
||||
`);
|
||||
await executeAll(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_sticker_cache_fetched_at" ON "sticker_cache" USING btree ("fetched_at")`,
|
||||
);
|
||||
|
||||
await executeAll(
|
||||
"DELETE FROM sticker_cache WHERE fetched_at < $1",
|
||||
[Date.now() - TTL_MS],
|
||||
);
|
||||
const row = await executeGet(
|
||||
"SELECT count(*) as cnt, COALESCE(SUM(size), 0) as total FROM sticker_cache",
|
||||
[],
|
||||
);
|
||||
if (row) {
|
||||
statsCache = {
|
||||
entryCount: Number(row.cnt),
|
||||
totalSizeBytes: Number(row.total),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ error: String(err) },
|
||||
"Failed to prune expired stickers on init",
|
||||
);
|
||||
}
|
||||
ready = true;
|
||||
logger.info(statsCache, "Sticker cache initialized (PostgreSQL)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a sticker image by name. Returns null on miss or TTL expiry.
|
||||
*/
|
||||
export async function getStickerFromCache(
|
||||
stickerName: string,
|
||||
): Promise<StickerCacheEntry | null> {
|
||||
if (!ready) return null;
|
||||
const key = sanitizeKey(stickerName);
|
||||
try {
|
||||
const row = await executeGet(
|
||||
"SELECT base64, mime_type, size, fetched_at FROM sticker_cache WHERE name = ? AND fetched_at > ?",
|
||||
[key, Date.now() - TTL_MS],
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
base64: row.base64,
|
||||
mimeType: row.mime_type,
|
||||
fetchedAt: Number(row.fetched_at),
|
||||
size: Number(row.size),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ error: String(err), stickerName },
|
||||
"Failed to get sticker from cache",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a sticker image in the cache. Fires and forgets — never blocks.
|
||||
*/
|
||||
export async function setStickerInCache(
|
||||
stickerName: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
): Promise<void> {
|
||||
if (!ready) return;
|
||||
const key = sanitizeKey(stickerName);
|
||||
const size = Buffer.byteLength(base64, "utf-8");
|
||||
const now = Date.now();
|
||||
try {
|
||||
await evictIfNeeded(size);
|
||||
await executeAll(
|
||||
`INSERT INTO sticker_cache (name, base64, mime_type, size, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
base64 = EXCLUDED.base64,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size = EXCLUDED.size,
|
||||
fetched_at = EXCLUDED.fetched_at`,
|
||||
[key, base64, mimeType, size, now],
|
||||
);
|
||||
statsCache.entryCount++;
|
||||
statsCache.totalSizeBytes += size;
|
||||
logger.debug({ stickerName, size }, "Sticker cached");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ stickerName, error: String(err) },
|
||||
"Failed to write sticker to cache",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function evictIfNeeded(newSize: number): Promise<void> {
|
||||
while (statsCache.totalSizeBytes + newSize > MAX_SIZE_BYTES) {
|
||||
const oldest = await executeGet(
|
||||
"SELECT name, size FROM sticker_cache ORDER BY fetched_at ASC LIMIT 1",
|
||||
);
|
||||
if (!oldest) break;
|
||||
await executeAll("DELETE FROM sticker_cache WHERE name = ?", [
|
||||
oldest.name,
|
||||
]);
|
||||
statsCache.totalSizeBytes -= Number(oldest.size);
|
||||
statsCache.entryCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current cache stats for observability.
|
||||
*/
|
||||
export function getStickerCacheStats(): {
|
||||
entryCount: number;
|
||||
totalSizeBytes: number;
|
||||
} {
|
||||
return { ...statsCache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cache has been initialized.
|
||||
*/
|
||||
export function isStickerCacheReady(): boolean {
|
||||
return ready;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Sticker-specific prompt templates for AI moderation.
|
||||
*
|
||||
* Discord stickers are cartoon/meme artwork — not real photos.
|
||||
* These prompts give the LLM proper context to avoid false-positive flags
|
||||
* based solely on sticker names or cartoon imagery.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prompt used when a sticker image was successfully downloaded (from cache
|
||||
* or network) and is being sent to the vision LLM as a base64 image.
|
||||
*
|
||||
* Explains that stickers are cartoon art, not documentation of real events,
|
||||
* and instructs the model to apply looser standards for cartoon content.
|
||||
*/
|
||||
export function buildStickerVisionPrompt(
|
||||
stickerName: string,
|
||||
messageId: string,
|
||||
): string {
|
||||
return [
|
||||
`Analisis sticker Discord berikut sebagai evidence moderasi.`,
|
||||
`Sticker "${stickerName}" berasal dari pesan id=${messageId}.`,
|
||||
``,
|
||||
`PENTING — Konteks Sticker:`,
|
||||
`- Sticker Discord adalah gambar KARTUN/MEME/ILUSTRASI, BUKAN foto atau video nyata.`,
|
||||
`- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.`,
|
||||
`- Gambar di sticker bisa menampilkan adegan yang terlihat "keras" (tokoh kartun menginjak sesuatu, ledakan komik, senjata kartun, tokoh berantem) — itu SENI KARTUN, bukan dokumentasi kekerasan atau ancaman nyata.`,
|
||||
`- Teks di sticker sering berupa lelucon, sindiran, atau ekspresi khas komunitas — bukan ancaman literal.`,
|
||||
``,
|
||||
`Jelaskan isi visual, teks yang terlihat, dan konteks risiko.`,
|
||||
`Terapkan standar yang lebih longgar untuk konten kartun/meme:`,
|
||||
`- Adegan kartun yang terlihat "keras" ≠ kekerasan nyata → jangan flag "violence" kecuali jelas menargetkan individu/kelompok nyata dengan ancaman serius.`,
|
||||
`- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat") adalah konteks satir/kartun, bukan bukti pelanggaran.`,
|
||||
`- Humor/satir/politik kartun ≠ SARA atau hate speech.`,
|
||||
`- Sticker yang menampilkan tokoh kartun dalam pose agresif adalah ekspresi/emosi umum di Discord, bukan harassment.`,
|
||||
``,
|
||||
`Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for text-only evidence when a sticker image failed to download.
|
||||
*
|
||||
* Returns a formatted string that explicitly tells the LLM not to flag
|
||||
* based on the sticker name alone, since names can sound provocative
|
||||
* while the actual cartoon image is harmless.
|
||||
*/
|
||||
export function buildStickerTextOnlyWarning(
|
||||
stickerName: string,
|
||||
stickerUrl: string,
|
||||
): string {
|
||||
return (
|
||||
`[sticker: "${stickerName}" (${stickerUrl}) — GAMBAR GAGAL DIUNDUH. ` +
|
||||
`"${stickerName}" adalah sticker kartun/meme Discord. ` +
|
||||
`JANGAN flag berdasarkan nama sticker saja tanpa gambar visual. ` +
|
||||
`Sticker Discord adalah seni kartun/ekspresi humor, bukan foto nyata. ` +
|
||||
`Nama yang terdengar provokatif adalah hal umum untuk sticker satir/humor di Discord.]`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt used when a custom emoji image was successfully downloaded
|
||||
* and is being sent to the vision LLM as a base64 image.
|
||||
*
|
||||
* Custom emojis are small icons — context is similar to stickers.
|
||||
*/
|
||||
export function buildCustomEmojiVisionPrompt(
|
||||
emojiName: string,
|
||||
messageId: string,
|
||||
): string {
|
||||
return [
|
||||
`Analisis custom emoji Discord berikut sebagai evidence moderasi.`,
|
||||
`Emoji "${emojiName}" berasal dari pesan id=${messageId}.`,
|
||||
``,
|
||||
`PENTING — Konteks Custom Emoji:`,
|
||||
`- Custom emoji Discord adalah ikon kecil/ekspresi, BUKAN foto atau dokumen nyata.`,
|
||||
`- Emoji sering digunakan untuk ekspresi emosi, reaksi, atau lelucon.`,
|
||||
`- Jangan flag berdasarkan nama emoji saja — analisis isi visual gambar.`,
|
||||
`- Emoji yang terlihat lucu/aneh adalah hal umum di Discord, bukan pelanggaran.`,
|
||||
``,
|
||||
`Jelaskan isi visual dan konteks risiko.`,
|
||||
`Jawab Bahasa Indonesia, maksimal 2 kalimat. Jangan bilang kurang konteks.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback text for when a custom emoji image failed to download.
|
||||
*/
|
||||
export function buildCustomEmojiTextOnlyFallback(emojiName: string): string {
|
||||
return (
|
||||
`[custom_emoji: "${emojiName}" — GAMBAR GAGAL DIUNDUH. ` +
|
||||
`"${emojiName}" adalah custom emoji Discord (ikon kecil). ` +
|
||||
`JANGAN flag berdasarkan nama emoji saja tanpa gambar visual. ` +
|
||||
`Custom emoji di Discord adalah ekspresi/emosi umum, bukan konten ofensif.]`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt for analyzing regular images (attachments, embeds, links).
|
||||
*
|
||||
* VISION MODEL ONLY DESCRIBES — it does NOT decide moderation.
|
||||
* The main text LLM makes all moderation decisions using the description.
|
||||
*/
|
||||
export function buildGeneralImageVisionPrompt(
|
||||
sourceLabel: string,
|
||||
_messageId: string,
|
||||
): string {
|
||||
return [
|
||||
`Deskripsikan gambar ini secara objektif dan spesifik.`,
|
||||
`${sourceLabel}`,
|
||||
``,
|
||||
`Jelaskan HANYA apa yang kamu LIHAT:`,
|
||||
`- Objek utama apa yang ada di gambar?`,
|
||||
`- Teks apa yang terlihat? (tulis persis jika bisa dibaca)`,
|
||||
`- Warna dominan dan layout/tata letak?`,
|
||||
`- Apakah ini screenshot, foto, meme, kartun, atau dokumen?`,
|
||||
`- Konteks: apakah terlihat seperti aplikasi chat, terminal/console,`,
|
||||
` media sosial, game, website, editor kode, dokumen, atau lainnya?`,
|
||||
``,
|
||||
`PENTING — Deskripsi saja, JANGAN MEMUTUSKAN MODERASI:`,
|
||||
`- JANGAN sebut "gambling", "judi", "pelanggaran", "melanggar", atau flag apapun.`,
|
||||
`- JANGAN bilang "harus dihapus", "harus diblokir", atau rekomendasi tindakan.`,
|
||||
`- Tugasmu HANYA mendeskripsikan isi gambar. BUKAN menilai.`,
|
||||
`- Screenshot terminal/console/shell/editor kode → deskripsikan sebagai "terminal/console".`,
|
||||
`- Screenshot aplikasi chat (Discord/WA/Telegram/dll) → deskripsikan sebagai "aplikasi chat".`,
|
||||
`- Screenshot website dengan grafik/chart → deskripsikan kontennya secara faktual.`,
|
||||
`- JANGAN PERNAH mengklaim gambar adalah "situs judi" atau "antarmuka perjudian".`,
|
||||
` Itu BUKAN tugasmu. Kamu hanya perlu menyebutkan: "tampilan website dengan grafik",`,
|
||||
` "screenshot terminal", "aplikasi chat dengan teks percakapan", dll.`,
|
||||
``,
|
||||
`Format jawaban: Deskripsi singkat 2-3 kalimat dalam Bahasa Indonesia.`,
|
||||
`Mulai dengan menyebutkan JENIS gambar (screenshot/foto/kartun/dokumen).`,
|
||||
`Akhiri dengan "Deskripsi selesai."`,
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { executeAll, executeGet } from "../database/drizzle.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const logger = createChildLogger("text-cache-store");
|
||||
|
||||
export interface TextCacheEntry {
|
||||
text: string;
|
||||
flags: string[];
|
||||
source: "local" | "primary_ai" | "vision_llm";
|
||||
analyzed_at: number;
|
||||
expires_at: number;
|
||||
hit_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup cached analysis result for a normalized text string.
|
||||
* Returns null if not found or expired.
|
||||
*/
|
||||
export async function getCachedText(
|
||||
text: string,
|
||||
): Promise<TextCacheEntry | null> {
|
||||
try {
|
||||
const row = await executeGet(
|
||||
`SELECT text, flags, source, analyzed_at, expires_at, hit_count
|
||||
FROM text_analysis_cache
|
||||
WHERE text = $1 AND expires_at > $2`,
|
||||
[text, Date.now()],
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
text: row.text,
|
||||
flags: JSON.parse(row.flags),
|
||||
source: row.source,
|
||||
analyzed_at: row.analyzed_at,
|
||||
expires_at: row.expires_at,
|
||||
hit_count: row.hit_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get cached text",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update a text analysis cache entry.
|
||||
*/
|
||||
export async function upsertCachedText(
|
||||
text: string,
|
||||
flags: string[],
|
||||
source: "local" | "primary_ai" | "vision_llm",
|
||||
expiresAt: number,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await executeAll(
|
||||
`INSERT INTO text_analysis_cache (text, flags, source, analyzed_at, expires_at, hit_count)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
ON CONFLICT (text) DO UPDATE SET
|
||||
flags = EXCLUDED.flags,
|
||||
source = EXCLUDED.source,
|
||||
analyzed_at = EXCLUDED.analyzed_at,
|
||||
expires_at = EXCLUDED.expires_at`,
|
||||
[text, JSON.stringify(flags), source, now, expiresAt],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to upsert cached text",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment hit count for a cached text entry (called on cache hit).
|
||||
*/
|
||||
export async function incrementTextCacheHit(text: string): Promise<void> {
|
||||
try {
|
||||
await executeAll(
|
||||
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
|
||||
[text],
|
||||
);
|
||||
} catch (error) {
|
||||
// Silent fail — this is just a counter, not critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired cache entries. Run periodically to keep the table clean.
|
||||
*/
|
||||
export async function pruneExpiredTexts(): Promise<number> {
|
||||
try {
|
||||
const result = await executeAll(
|
||||
`DELETE FROM text_analysis_cache WHERE expires_at < $1`,
|
||||
[Date.now()],
|
||||
);
|
||||
return (result as any).rowCount ?? 0;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to prune expired texts",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for observability.
|
||||
*/
|
||||
export async function getTextCacheStats(): Promise<{
|
||||
total: number;
|
||||
expired: number;
|
||||
bySource: Record<string, number>;
|
||||
}> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
const [totalRow, expiredRow, sourceRows] = await Promise.all([
|
||||
executeAll(`SELECT count(*) as cnt FROM text_analysis_cache`),
|
||||
executeAll(
|
||||
`SELECT count(*) as cnt FROM text_analysis_cache WHERE expires_at < $1`,
|
||||
[now],
|
||||
),
|
||||
executeAll(
|
||||
`SELECT source, count(*) as cnt FROM text_analysis_cache GROUP BY source`,
|
||||
),
|
||||
]);
|
||||
|
||||
const bySource: Record<string, number> = {};
|
||||
for (const row of sourceRows) {
|
||||
bySource[row.source] = row.cnt;
|
||||
}
|
||||
|
||||
return {
|
||||
total: totalRow[0]?.cnt ?? 0,
|
||||
expired: expiredRow[0]?.cnt ?? 0,
|
||||
bySource,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get text cache stats",
|
||||
);
|
||||
return { total: 0, expired: 0, bySource: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media / Vision analysis cache helpers (reuses text_analysis_cache table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a deterministic cache key for a sticker.
|
||||
* Same sticker name → same key across sessions and servers.
|
||||
*/
|
||||
export function makeStickerCacheKey(stickerName: string): string {
|
||||
return `sticker:${stickerName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic cache key for a custom emoji by its Discord ID.
|
||||
*/
|
||||
export function makeCustomEmojiCacheKey(emojiId: string): string {
|
||||
return `emoji:${emojiId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic cache key for an image data URL.
|
||||
* Hashes the first 128 chars of the data URL (enough to identify the image
|
||||
* without storing the full base64 string as the key).
|
||||
*/
|
||||
export function makeImageCacheKey(dataUrl: string): string {
|
||||
const prefix = dataUrl.slice(0, 128);
|
||||
const hash = createHash("sha256").update(prefix).digest("hex").slice(0, 16);
|
||||
return `image:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a cached media analysis result.
|
||||
* Returns the full cached text (the analysis summary string) or null.
|
||||
*/
|
||||
export async function getCachedMediaAnalysis(
|
||||
cacheKey: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const row = await executeGet(
|
||||
`SELECT flags, hit_count
|
||||
FROM text_analysis_cache
|
||||
WHERE text = $1 AND expires_at > $2`,
|
||||
[cacheKey, Date.now()],
|
||||
);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
// flags stores the analysis result for media entries
|
||||
const result = JSON.parse(row.flags) as string;
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to get cached media analysis",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a media analysis result in the cache.
|
||||
*/
|
||||
export async function upsertCachedMediaAnalysis(
|
||||
cacheKey: string,
|
||||
analysisResult: string,
|
||||
source: "vision_llm",
|
||||
expiresAt: number,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
await executeAll(
|
||||
`INSERT INTO text_analysis_cache (text, flags, source, analyzed_at, expires_at, hit_count)
|
||||
VALUES ($1, $2, $3, $4, $5, 0)
|
||||
ON CONFLICT (text) DO UPDATE SET
|
||||
flags = EXCLUDED.flags,
|
||||
source = EXCLUDED.source,
|
||||
analyzed_at = EXCLUDED.analyzed_at,
|
||||
expires_at = EXCLUDED.expires_at`,
|
||||
[cacheKey, JSON.stringify(analysisResult), source, now, expiresAt],
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to upsert cached media analysis",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import type {
|
||||
BroadcasterClient,
|
||||
ModerationBroadcaster,
|
||||
} from "./broadcaster.js";
|
||||
|
||||
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
export type AIRecommendedAction =
|
||||
| "none"
|
||||
| "monitor"
|
||||
| "warn"
|
||||
| "review"
|
||||
| "delete"
|
||||
| "escalate";
|
||||
|
||||
export type { BroadcasterClient, ModerationBroadcaster };
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
edited_content: string | null;
|
||||
created_at: number;
|
||||
edited_at: number | null;
|
||||
deleted_at: number | null;
|
||||
type: "text" | "edited" | "deleted";
|
||||
metadata: string | null;
|
||||
ai_status?: AIStatus | null;
|
||||
ai_moderation_flags?: string | null;
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null;
|
||||
ai_severity?: AISeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: string;
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id: string | null;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
discord_url: string;
|
||||
uploaded_url: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
}
|
||||
|
||||
export interface VoiceSegmentRecord {
|
||||
id: string;
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
filename: string;
|
||||
duration_ms: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface DashboardMessage {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
created_at: number;
|
||||
type: "text" | "image" | "voice";
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
threadId?: string;
|
||||
status?: AIStatus[];
|
||||
userId?: string;
|
||||
q?: string;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
messageId: string;
|
||||
status: Exclude<AIStatus, "pending">;
|
||||
flags: string[];
|
||||
score: number;
|
||||
analysis: string;
|
||||
categories?: string[];
|
||||
severity?: AISeverity;
|
||||
confidence?: number;
|
||||
recommendedAction?: AIRecommendedAction;
|
||||
policyVersion?: string;
|
||||
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[] }
|
||||
| { type: "message_created"; data: MessageRecord }
|
||||
| { type: "message_updated"; data: Partial<MessageRecord> & { id: string } }
|
||||
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
||||
| { type: "message_analyzed"; data: MessageRecord }
|
||||
| { type: "attachment_created"; data: AttachmentRecord }
|
||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
||||
| { type: "media_state"; state: MediaState }
|
||||
| { type: "voice_recording_uploaded"; data: any };
|
||||
|
||||
export interface AnalysisQueueStatus {
|
||||
queuedConversations: number;
|
||||
activeRequests: number;
|
||||
/** Number of single-message fallback calls currently awaiting the LLM. */
|
||||
activeIndividualRequests: number;
|
||||
/** Number of message IDs sitting in the dedup set (in-flight or about to start). */
|
||||
individualInFlightCount: number;
|
||||
/** True when the individual-fallback circuit breaker is tripped. */
|
||||
individualCircuitBreakerActive: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated";
|
||||
|
||||
export interface MessageReview {
|
||||
id: string;
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
reviewer_id: string | null;
|
||||
status: ReviewStatus;
|
||||
notes: string | null;
|
||||
created_at: number;
|
||||
reviewed_at: number | null;
|
||||
}
|
||||
|
||||
export type ModerationActionType =
|
||||
| "delete_message"
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user";
|
||||
|
||||
export interface ModerationAction {
|
||||
id: string;
|
||||
message_id: string | null;
|
||||
user_id: string | null;
|
||||
guild_id: string;
|
||||
action_type: ModerationActionType;
|
||||
reason: string | null;
|
||||
executed_by: string | null;
|
||||
status: "pending" | "executed" | "failed";
|
||||
error: string | null;
|
||||
created_at: number;
|
||||
executed_at: number | null;
|
||||
}
|
||||
|
||||
export interface RetentionPolicy {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string | null;
|
||||
retention_days: number;
|
||||
apply_to_media: boolean;
|
||||
apply_to_voice: boolean;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { resolve } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const log = createChildLogger("urlFetcher");
|
||||
|
||||
export interface FetchedUrlContext {
|
||||
url: string;
|
||||
type: "image" | "text" | "error";
|
||||
data?: Buffer;
|
||||
mimeType?: string;
|
||||
textContent?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const MAX_FETCH_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const URL_REGEX = /https?:\/\/[^\s<]+[^<.,:;"')\]\s]/gi;
|
||||
|
||||
/**
|
||||
* Basic SSRF protection.
|
||||
* Note: A sophisticated attacker could still use DNS rebinding.
|
||||
*/
|
||||
async function isSafeUrl(urlStr: string): Promise<boolean> {
|
||||
try {
|
||||
const parsed = new URL(urlStr);
|
||||
const host = parsed.hostname;
|
||||
|
||||
// Block obvious local IPs/hostnames
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "::1" ||
|
||||
host.startsWith("192.168.") ||
|
||||
host.startsWith("10.") ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try resolving to check if it resolves to a local IP
|
||||
if (!isIP(host)) {
|
||||
try {
|
||||
const addresses = await resolve(host);
|
||||
for (const ip of addresses) {
|
||||
if (
|
||||
ip === "127.0.0.1" ||
|
||||
ip.startsWith("192.168.") ||
|
||||
ip.startsWith("10.") ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If DNS fails, we can't fetch it anyway
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractOgImage(html: string): string | null {
|
||||
// Look for <meta ... property="og:image" ... content="..."> or <meta ... name="twitter:image" ... content="...">
|
||||
const ogRegex =
|
||||
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
|
||||
const match = html.match(ogRegex);
|
||||
if (match && match[1]) {
|
||||
// Unescape basic HTML entities
|
||||
return match[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Try reversed attribute order: <meta ... content="..." ... property="og:image">
|
||||
const ogRegexRev =
|
||||
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
|
||||
const matchRev = html.match(ogRegexRev);
|
||||
if (matchRev && matchRev[1]) {
|
||||
return matchRev[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function truncateAndCleanHtml(html: string, maxLen = 1000): string {
|
||||
// Strip <script> and <style> entirely
|
||||
let text = html.replace(
|
||||
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
|
||||
" ",
|
||||
);
|
||||
text = text.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ");
|
||||
// Strip all other HTML tags
|
||||
text = text.replace(/<[^>]+>/g, " ");
|
||||
// Replace multiple spaces/newlines
|
||||
text = text.replace(/\s+/g, " ").trim();
|
||||
|
||||
return text.substring(0, maxLen);
|
||||
}
|
||||
|
||||
export async function fetchUrlSafely(
|
||||
url: string,
|
||||
depth = 0,
|
||||
): Promise<FetchedUrlContext> {
|
||||
if (depth > 1) {
|
||||
return { url, type: "error", error: "Max redirect/meta depth reached" };
|
||||
}
|
||||
|
||||
if (!(await isSafeUrl(url))) {
|
||||
return { url, type: "error", error: "Unsafe URL blocked" };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 DiscordBot/2.0",
|
||||
Accept: "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
},
|
||||
// Do not follow more than a few redirects natively, fetch handles up to 20 by default
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { url, type: "error", error: `HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const contentLength = parseInt(
|
||||
response.headers.get("content-length") || "0",
|
||||
10,
|
||||
);
|
||||
|
||||
if (contentLength > MAX_FETCH_SIZE) {
|
||||
return { url, type: "error", error: "Content too large" };
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_FETCH_SIZE) {
|
||||
return { url, type: "error", error: "Downloaded content too large" };
|
||||
}
|
||||
|
||||
if (contentType.startsWith("image/")) {
|
||||
return {
|
||||
url,
|
||||
type: "image",
|
||||
data: Buffer.from(buffer),
|
||||
mimeType: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
contentType.startsWith("text/html") ||
|
||||
contentType.startsWith("text/plain")
|
||||
) {
|
||||
const text = Buffer.from(buffer).toString("utf-8");
|
||||
|
||||
// If it's HTML, try to find an og:image first (for Tenor/Giphy etc)
|
||||
if (contentType.startsWith("text/html")) {
|
||||
const ogImage = extractOgImage(text);
|
||||
if (ogImage && ogImage.startsWith("http")) {
|
||||
// Fetch the og:image instead
|
||||
return fetchUrlSafely(ogImage, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to text content
|
||||
const cleaned = truncateAndCleanHtml(text, 1000);
|
||||
return {
|
||||
url,
|
||||
type: "text",
|
||||
textContent: cleaned,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
type: "error",
|
||||
error: `Unsupported content type: ${contentType}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
url,
|
||||
type: "error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractUrlsFromText(text: string): string[] {
|
||||
const matches = text.match(URL_REGEX);
|
||||
if (!matches) return [];
|
||||
// Deduplicate and filter out things that obviously aren't valid
|
||||
return Array.from(new Set(matches)).filter((url) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess.js";
|
||||
|
||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||
|
||||
interface EventMetadata {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
bot?: boolean;
|
||||
roles?: Array<{ id: string; name: string; position: number }>;
|
||||
highestRole?: { id: string; name: string; position: number } | null;
|
||||
joinedTimestamp?: number | null;
|
||||
sessionId?: string;
|
||||
sessionStartTime?: number;
|
||||
segmentIndex?: number;
|
||||
segmentMs?: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface ClipInfo {
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
meta: EventMetadata;
|
||||
}
|
||||
|
||||
async function startMuxingToAup3() {
|
||||
console.log("[muxer-aup3] Scanning recordings directory...");
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
console.error("[muxer-aup3] Recordings directory not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clips: ClipInfo[] = [];
|
||||
|
||||
// Scan user directories
|
||||
const items = fs.readdirSync(recordingsDir);
|
||||
console.log(`[muxer-aup3] Found ${items.length} directories to scan...`);
|
||||
|
||||
let processedDirs = 0;
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(recordingsDir, item);
|
||||
if (fs.statSync(itemPath).isDirectory()) {
|
||||
const files = fs.readdirSync(itemPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const jsonPath = path.join(itemPath, file);
|
||||
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||
if (fs.existsSync(oggPath)) {
|
||||
try {
|
||||
// Check if OGG file is valid (not empty and has reasonable size)
|
||||
const oggStats = fs.statSync(oggPath);
|
||||
if (oggStats.size === 0) {
|
||||
console.warn(
|
||||
`[muxer-aup3] Skipping empty OGG file: ${oggPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files that are too small (less than 1KB likely corrupted)
|
||||
if (oggStats.size < 1024) {
|
||||
console.warn(
|
||||
`[muxer-aup3] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if OGG file has valid header (starts with "OggS")
|
||||
const oggBuffer = fs.readFileSync(oggPath);
|
||||
const oggHeader = oggBuffer.subarray(0, 4).toString();
|
||||
if (oggHeader !== "OggS") {
|
||||
console.warn(
|
||||
`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta: EventMetadata = JSON.parse(
|
||||
fs.readFileSync(jsonPath, "utf-8"),
|
||||
);
|
||||
clips.push({ oggPath, jsonPath, meta });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[muxer-aup3] Failed to read/parse JSON: ${jsonPath}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processedDirs++;
|
||||
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||
console.log(
|
||||
`[muxer-aup3] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (clips.length === 0) {
|
||||
console.log("[muxer-aup3] No recording clips found to mux.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by startTime so chronologically they are in order
|
||||
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||
|
||||
// Find the global start time
|
||||
const globalStartTime = clips[0].meta.startTime;
|
||||
console.log(
|
||||
`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||
);
|
||||
|
||||
const filterParts: string[] = [];
|
||||
|
||||
console.log(
|
||||
`[muxer-aup3] Creating audio filters for ${clips.length} clips...`,
|
||||
);
|
||||
clips.forEach((clip, index) => {
|
||||
// Calculate delay relative to the global start time
|
||||
const delayMs = clip.meta.startTime - globalStartTime;
|
||||
|
||||
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||
// Setting adelay multiple times covers stereo channels.
|
||||
// We ensure all multiple channels get delayed.
|
||||
const inputSpecifier = `[${index}:a]`;
|
||||
const outputSpecifier = `[pad${index}]`;
|
||||
|
||||
filterParts.push(
|
||||
`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`,
|
||||
);
|
||||
|
||||
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||
console.log(
|
||||
`[muxer-aup3] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`,
|
||||
);
|
||||
});
|
||||
|
||||
// Merge them using amix
|
||||
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||
filterParts.push(
|
||||
`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`,
|
||||
);
|
||||
|
||||
const timestamp = Date.now();
|
||||
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
||||
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
||||
const inputs = clips.map((clip) => clip.oggPath);
|
||||
|
||||
console.log(
|
||||
`[muxer-aup3] Combining clips to WAV. This might take a while...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const args = buildMuxFfmpegArgs({
|
||||
inputs,
|
||||
filter: filterParts.join(";"),
|
||||
output: wavFilename,
|
||||
codec: "pcm_s16le",
|
||||
audioFrequency: 44100,
|
||||
audioChannels: 2,
|
||||
});
|
||||
|
||||
await runFfmpeg(args);
|
||||
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
||||
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
||||
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
||||
} catch (err) {
|
||||
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function createAup3Project(
|
||||
wavFilename: string,
|
||||
aup3Filename: string,
|
||||
clips: ClipInfo[],
|
||||
globalStartTime: number,
|
||||
) {
|
||||
try {
|
||||
console.log(`[muxer-aup3] AUP3 Progress: Reading WAV file...`);
|
||||
|
||||
// Read WAV file to get duration
|
||||
const wavStats = fs.statSync(wavFilename);
|
||||
const wavSize = wavStats.size;
|
||||
|
||||
// Calculate approximate duration (assuming 44.1kHz, 16-bit, stereo)
|
||||
// Duration = (file_size - 44) / (44100 * 2 * 2) for WAV
|
||||
const duration = (wavSize - 44) / (44100 * 4);
|
||||
|
||||
console.log(
|
||||
`[muxer-aup3] AUP3 Progress: Calculating duration... ${duration.toFixed(2)}s`,
|
||||
);
|
||||
console.log(`[muxer-aup3] AUP3 Progress: Creating XML structure...`);
|
||||
|
||||
// Create AUP3 project XML structure
|
||||
const aup3Content = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<audacityproject xmlns="http://audacity.sourceforge.net/xml/" projname="muxed" version="1.3.0" audacityversion="3.5.1">
|
||||
<tags>
|
||||
<tag name="GENRE" value=""/>
|
||||
<tag name="ARTIST" value=""/>
|
||||
<tag name="ALBUM" value=""/>
|
||||
<tag name="TRACKNUMBER" value=""/>
|
||||
<tag name="YEAR" value=""/>
|
||||
<tag name="TITLE" value="Muxed Recording"/>
|
||||
</tags>
|
||||
<wavetrack name="Muxed Audio" channel="2" linked="0" mute="0" solo="0" height="150" minimized="0" isSelected="0" rate="44100">
|
||||
<waveclip offset="0.0">
|
||||
<sequence maxsamples="262144" sampleformat="262159" numsamples="${Math.floor(duration * 44100)}">
|
||||
<waveblock start="0">
|
||||
<simpleblockfile filename="${path.basename(wavFilename)}" len="${Math.floor(duration * 44100)}" min="-1.0" max="1.0" rms="0.1"/>
|
||||
</waveblock>
|
||||
</sequence>
|
||||
<envelope numpoints="0"/>
|
||||
</waveclip>
|
||||
</wavetrack>
|
||||
<timetrack name="Time Track" height="150" minimized="0" isSelected="0">
|
||||
<envelope numpoints="0"/>
|
||||
</timetrack>
|
||||
</audacityproject>`;
|
||||
|
||||
console.log(`[muxer-aup3] AUP3 Progress: Writing AUP3 file...`);
|
||||
|
||||
// Write AUP3 file
|
||||
fs.writeFileSync(aup3Filename, aup3Content, "utf-8");
|
||||
|
||||
console.log(`[muxer-aup3] AUP3 Progress: Creating clip info file...`);
|
||||
|
||||
// Create a simple info file with clip details
|
||||
const infoFilename = aup3Filename.replace(".aup3", "-info.txt");
|
||||
const infoContent = clips
|
||||
.map((clip, index) => {
|
||||
const delayMs = clip.meta.startTime - globalStartTime;
|
||||
return `Clip ${index + 1}:
|
||||
User: ${clip.meta.username} (${clip.meta.userId})
|
||||
Tag: ${clip.meta.tag}
|
||||
Start Time: ${new Date(clip.meta.startTime).toISOString()}
|
||||
Delay: ${delayMs}ms
|
||||
Duration: ${clip.meta.durationMs}ms
|
||||
File: ${path.basename(clip.oggPath)}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
fs.writeFileSync(infoFilename, infoContent, "utf-8");
|
||||
|
||||
console.log(`[muxer-aup3] AUP3 Progress: 100% - Complete!`);
|
||||
console.log(`[muxer-aup3] Successfully created AUP3 project!`);
|
||||
console.log(`[muxer-aup3] WAV file: ${wavFilename}`);
|
||||
console.log(`[muxer-aup3] AUP3 file: ${aup3Filename}`);
|
||||
console.log(`[muxer-aup3] Clip info saved to: ${infoFilename}`);
|
||||
console.log(`[muxer-aup3] Total clips processed: ${clips.length}`);
|
||||
console.log(`[muxer-aup3] Duration: ${duration.toFixed(2)} seconds`);
|
||||
} catch (error) {
|
||||
console.error(`[muxer-aup3] Error creating AUP3 project:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
startMuxingToAup3();
|
||||
@@ -1,405 +0,0 @@
|
||||
import Redis from "ioredis";
|
||||
import { config } from "./config.js";
|
||||
import { createChildLogger } from "./logger.js";
|
||||
|
||||
const logger = createChildLogger("muxer-queue");
|
||||
|
||||
// ── Redis client (lazy singleton) ──────────────────────────────────────────
|
||||
|
||||
let redis: Redis | null = null;
|
||||
let redisReady = false;
|
||||
let redisAttempted = false;
|
||||
|
||||
/**
|
||||
* Try to create a Redis connection. Does NOT block if unreachable —
|
||||
* sets lazyConnect + logs a single warning, then lets callers fall back
|
||||
* to in-memory KV.
|
||||
*/
|
||||
function tryGetRedis(): Redis | null {
|
||||
if (redis !== null) return redisReady ? redis : null;
|
||||
if (redisAttempted) return null;
|
||||
redisAttempted = true;
|
||||
|
||||
redis = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
if (times > 5) return null; // stop retrying
|
||||
return Math.min(times * 200, 2000);
|
||||
},
|
||||
lazyConnect: true, // don't block startup on Redis
|
||||
});
|
||||
|
||||
redis.on("error", (err) => {
|
||||
logger.warn({ err }, "Redis connection failed — using in-memory fallback");
|
||||
});
|
||||
|
||||
redis.on("connect", () => {
|
||||
logger.info({ url: config.REDIS_URL }, "Redis connected");
|
||||
redisReady = true;
|
||||
});
|
||||
|
||||
redis.on("reconnecting", () => {
|
||||
redisReady = false;
|
||||
});
|
||||
|
||||
return redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a Redis operation. Returns the result on success, or null if
|
||||
* Redis is unavailable (caller should fall back).
|
||||
*/
|
||||
async function redisOp<T>(fn: (r: Redis) => Promise<T>): Promise<T | null> {
|
||||
const r = tryGetRedis();
|
||||
if (!r) return null;
|
||||
try {
|
||||
return await fn(r);
|
||||
} catch {
|
||||
// Redis failed mid-operation — mark as not ready so next call falls back
|
||||
redisReady = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── In-memory KV fallback (volatile, survives only process lifetime) ──────
|
||||
|
||||
const memKV = new Map<string, string>();
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MuxerJobData {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
recordingsDir: string;
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
// ── Database backward compatibility ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use Redis functions directly. Kept for backward compat with old
|
||||
* tests that import getDatabase from muxer-queue.
|
||||
*/
|
||||
export function getDatabase() {
|
||||
logger.warn(
|
||||
"getDatabase() is deprecated — queue now uses Redis. Returning a stub.",
|
||||
);
|
||||
return undefined as unknown as never;
|
||||
}
|
||||
|
||||
// ── Persistent KV store ────────────────────────────────────────────────────
|
||||
|
||||
const KV_PREFIX = "kv:";
|
||||
|
||||
export async function getPersistedValue<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
): Promise<T> {
|
||||
const raw = await redisOp((r) => r.get(`${KV_PREFIX}${key}`));
|
||||
if (raw !== null && raw !== undefined) {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
logger.warn({ key }, "Failed to parse persisted value from Redis");
|
||||
}
|
||||
}
|
||||
// Fallback to in-memory KV, then default
|
||||
const memVal = memKV.get(`${KV_PREFIX}${key}`);
|
||||
if (memVal !== undefined) {
|
||||
try {
|
||||
return JSON.parse(memVal) as T;
|
||||
} catch {
|
||||
// ignore corrupted in-memory data
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function setPersistedValue(
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const serialized = JSON.stringify(value);
|
||||
// Always store in in-memory KV so it works even without Redis
|
||||
memKV.set(`${KV_PREFIX}${key}`, serialized);
|
||||
const saved = await redisOp((r) => r.set(`${KV_PREFIX}${key}`, serialized));
|
||||
if (!saved) {
|
||||
logger.verbose(
|
||||
{ key },
|
||||
"Persisted value stored in-memory only (Redis unavailable)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Job queue ──────────────────────────────────────────────────────────────
|
||||
|
||||
const JOB_PREFIX = "job:";
|
||||
const QUEUE_PENDING = "queue:pending";
|
||||
const QUEUE_PROCESSING = "queue:processing";
|
||||
const QUEUE_COMPLETED = "queue:completed";
|
||||
const QUEUE_FAILED = "queue:failed";
|
||||
|
||||
function queueKey(status: string): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return QUEUE_PENDING;
|
||||
case "processing":
|
||||
return QUEUE_PROCESSING;
|
||||
case "completed":
|
||||
return QUEUE_COMPLETED;
|
||||
case "failed":
|
||||
return QUEUE_FAILED;
|
||||
default:
|
||||
return QUEUE_PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
|
||||
const result = await redisOp(async (r) => {
|
||||
const jobId = `${data.userId}-${data.sessionId}`;
|
||||
const now = Date.now();
|
||||
|
||||
const jobKey = `${JOB_PREFIX}${jobId}`;
|
||||
|
||||
// Use a pipeline for atomicity
|
||||
const pipeline = r.pipeline();
|
||||
pipeline.hset(jobKey, {
|
||||
data: JSON.stringify(data),
|
||||
status: "pending",
|
||||
attempts: "0",
|
||||
maxAttempts: "3",
|
||||
createdAt: String(now),
|
||||
updatedAt: String(now),
|
||||
error: "",
|
||||
});
|
||||
pipeline.lpush(QUEUE_PENDING, jobId);
|
||||
await pipeline.exec();
|
||||
|
||||
logger.info(
|
||||
{ jobId, userId: data.userId, sessionId: data.sessionId },
|
||||
"Muxer job enqueued",
|
||||
);
|
||||
|
||||
return jobId;
|
||||
});
|
||||
if (!result) {
|
||||
logger.warn(
|
||||
{ userId: data.userId },
|
||||
"Failed to enqueue muxer job (Redis unavailable)",
|
||||
);
|
||||
}
|
||||
return result ?? "";
|
||||
}
|
||||
|
||||
export async function getPendingJobs(): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
data: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}>
|
||||
> {
|
||||
const jobs = await redisOp(async (r) => {
|
||||
// Get up to 10 pending job IDs from the left (oldest first)
|
||||
const jobIds = await r.lrange(QUEUE_PENDING, 0, 9);
|
||||
if (jobIds.length === 0) return [];
|
||||
|
||||
// Batch fetch all job hashes
|
||||
const pipeline = r.pipeline();
|
||||
for (const id of jobIds) {
|
||||
pipeline.hgetall(`${JOB_PREFIX}${id}`);
|
||||
}
|
||||
const results = await pipeline.exec();
|
||||
if (!results) return [];
|
||||
|
||||
const out: Array<{
|
||||
id: string;
|
||||
data: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < jobIds.length; i++) {
|
||||
const [err, fields] = results[i];
|
||||
if (err || !fields) continue;
|
||||
|
||||
const raw = fields as Record<string, string>;
|
||||
out.push({
|
||||
id: jobIds[i],
|
||||
data: raw.data || "",
|
||||
status: (raw.status as "pending") || "pending",
|
||||
attempts: Number(raw.attempts) || 0,
|
||||
maxAttempts: Number(raw.maxAttempts) || 3,
|
||||
createdAt: Number(raw.createdAt) || 0,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
error: raw.error || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
if (!jobs) {
|
||||
logger.verbose("No pending jobs (Redis unavailable)");
|
||||
}
|
||||
return jobs ?? [];
|
||||
}
|
||||
|
||||
export async function updateJobStatus(
|
||||
jobId: string,
|
||||
status: "processing" | "completed" | "failed",
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
const ok = await redisOp(async (r) => {
|
||||
const jobKey = `${JOB_PREFIX}${jobId}`;
|
||||
const now = Date.now();
|
||||
|
||||
const exists = await r.exists(jobKey);
|
||||
if (!exists) {
|
||||
logger.warn({ jobId }, "Job not found for status update");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStatus = await r.hget(jobKey, "status");
|
||||
|
||||
const pipeline = r.pipeline();
|
||||
|
||||
if (status === "failed") {
|
||||
pipeline.hincrby(jobKey, "attempts", 1);
|
||||
pipeline.hset(jobKey, "error", error || "");
|
||||
}
|
||||
|
||||
pipeline.hset(jobKey, "status", status);
|
||||
pipeline.hset(jobKey, "updatedAt", String(now));
|
||||
|
||||
// Move job ID between queue lists
|
||||
if (currentStatus) {
|
||||
pipeline.lrem(queueKey(currentStatus), 0, jobId);
|
||||
}
|
||||
pipeline.lpush(queueKey(status), jobId);
|
||||
|
||||
await pipeline.exec();
|
||||
|
||||
logger.info({ jobId, status, error }, "Job status updated");
|
||||
});
|
||||
if (!ok) {
|
||||
logger.verbose(
|
||||
{ jobId, status },
|
||||
"Job status update skipped (Redis unavailable)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryFailedJob(jobId: string): Promise<boolean> {
|
||||
const result = await redisOp(async (r) => {
|
||||
const jobKey = `${JOB_PREFIX}${jobId}`;
|
||||
|
||||
const [attemptsStr, maxAttemptsStr] = await r.hmget(
|
||||
jobKey,
|
||||
"attempts",
|
||||
"maxAttempts",
|
||||
);
|
||||
|
||||
const attempts = Number(attemptsStr) || 0;
|
||||
const maxAttempts = Number(maxAttemptsStr) || 3;
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
logger.warn(
|
||||
{ jobId, attempts, maxAttempts },
|
||||
"Max retry attempts reached",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pipeline = r.pipeline();
|
||||
pipeline.hset(jobKey, "status", "pending");
|
||||
pipeline.hset(jobKey, "updatedAt", String(Date.now()));
|
||||
pipeline.lrem(QUEUE_FAILED, 0, jobId);
|
||||
pipeline.lpush(QUEUE_PENDING, jobId);
|
||||
await pipeline.exec();
|
||||
|
||||
logger.info({ jobId, attempt: attempts + 1 }, "Job retried");
|
||||
return true;
|
||||
});
|
||||
if (!result) {
|
||||
logger.verbose({ jobId }, "Job retry skipped (Redis unavailable)");
|
||||
}
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
export async function cleanupCompletedJobs(
|
||||
olderThanMs: number = 24 * 60 * 60 * 1000,
|
||||
): Promise<number> {
|
||||
const deletedCount = await redisOp(async (r) => {
|
||||
const cutoffTime = Date.now() - olderThanMs;
|
||||
|
||||
const jobIds = await r.lrange(QUEUE_COMPLETED, 0, -1);
|
||||
if (jobIds.length === 0) return 0;
|
||||
|
||||
const pipeline = r.pipeline();
|
||||
for (const id of jobIds) {
|
||||
pipeline.hget(`${JOB_PREFIX}${id}`, "updatedAt");
|
||||
}
|
||||
const results = await pipeline.exec();
|
||||
if (!results) return 0;
|
||||
|
||||
let count = 0;
|
||||
const deletePipeline = r.pipeline();
|
||||
|
||||
for (let i = 0; i < jobIds.length; i++) {
|
||||
const [err, updatedAtStr] = results[i];
|
||||
if (err) continue;
|
||||
|
||||
const updatedAt = Number(updatedAtStr) || 0;
|
||||
if (updatedAt < cutoffTime) {
|
||||
deletePipeline.del(`${JOB_PREFIX}${jobIds[i]}`);
|
||||
deletePipeline.lrem(QUEUE_COMPLETED, 0, jobIds[i]);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await deletePipeline.exec();
|
||||
}
|
||||
|
||||
logger.info({ deletedCount: count }, "Cleaned up completed jobs");
|
||||
return count;
|
||||
});
|
||||
if (deletedCount === null) {
|
||||
logger.verbose("Cleanup skipped (Redis unavailable)");
|
||||
}
|
||||
return deletedCount ?? 0;
|
||||
}
|
||||
|
||||
export async function getJobStats(): Promise<{
|
||||
pending: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}> {
|
||||
const stats = await redisOp(async (r) => {
|
||||
const [pending, processing, completed, failed] = await Promise.all([
|
||||
r.llen(QUEUE_PENDING),
|
||||
r.llen(QUEUE_PROCESSING),
|
||||
r.llen(QUEUE_COMPLETED),
|
||||
r.llen(QUEUE_FAILED),
|
||||
]);
|
||||
return { pending, processing, completed, failed };
|
||||
});
|
||||
return stats ?? { pending: 0, processing: 0, completed: 0, failed: 0 };
|
||||
}
|
||||
|
||||
export async function closeQueue(): Promise<void> {
|
||||
if (redis !== null) {
|
||||
await redis.quit();
|
||||
redis = null;
|
||||
}
|
||||
logger.info("Muxer queue (Redis) closed");
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { buildMuxFfmpegArgs, runFfmpeg } from "./audio/ffmpegProcess.js";
|
||||
|
||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||
|
||||
interface EventMetadata {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
bot?: boolean;
|
||||
roles?: Array<{ id: string; name: string; position: number }>;
|
||||
highestRole?: { id: string; name: string; position: number } | null;
|
||||
joinedTimestamp?: number | null;
|
||||
sessionId?: string;
|
||||
sessionStartTime?: number;
|
||||
segmentIndex?: number;
|
||||
segmentMs?: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface ClipInfo {
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
meta: EventMetadata;
|
||||
}
|
||||
|
||||
async function startMuxing() {
|
||||
console.log("[muxer] Scanning recordings directory...");
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
console.error("[muxer] Recordings directory not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clips: ClipInfo[] = [];
|
||||
|
||||
// Scan user directories
|
||||
const items = fs.readdirSync(recordingsDir);
|
||||
console.log(`[muxer] Found ${items.length} directories to scan...`);
|
||||
|
||||
let processedDirs = 0;
|
||||
for (const item of items) {
|
||||
const itemPath = path.join(recordingsDir, item);
|
||||
if (fs.statSync(itemPath).isDirectory()) {
|
||||
const files = fs.readdirSync(itemPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const jsonPath = path.join(itemPath, file);
|
||||
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||
if (fs.existsSync(oggPath)) {
|
||||
try {
|
||||
// Check if OGG file is valid (not empty and has reasonable size)
|
||||
const oggStats = fs.statSync(oggPath);
|
||||
if (oggStats.size === 0) {
|
||||
console.warn(`[muxer] Skipping empty OGG file: ${oggPath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files that are too small (less than 1KB likely corrupted)
|
||||
if (oggStats.size < 1024) {
|
||||
console.warn(
|
||||
`[muxer] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if OGG file has valid header (starts with "OggS")
|
||||
const oggBuffer = fs.readFileSync(oggPath);
|
||||
const oggHeader = oggBuffer.subarray(0, 4).toString();
|
||||
if (oggHeader !== "OggS") {
|
||||
console.warn(
|
||||
`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta: EventMetadata = JSON.parse(
|
||||
fs.readFileSync(jsonPath, "utf-8"),
|
||||
);
|
||||
clips.push({ oggPath, jsonPath, meta });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[muxer] Failed to read/parse JSON: ${jsonPath}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processedDirs++;
|
||||
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||
console.log(
|
||||
`[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (clips.length === 0) {
|
||||
console.log("[muxer] No recording clips found to mux.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by startTime so chronologically they are in order
|
||||
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||
|
||||
// Find the global start time
|
||||
const globalStartTime = clips[0].meta.startTime;
|
||||
console.log(
|
||||
`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||
);
|
||||
|
||||
const filterParts: string[] = [];
|
||||
|
||||
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
||||
clips.forEach((clip, index) => {
|
||||
// Calculate delay relative to the global start time
|
||||
const delayMs = clip.meta.startTime - globalStartTime;
|
||||
|
||||
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||
// Setting adelay multiple times covers stereo channels.
|
||||
// We ensure all multiple channels get delayed.
|
||||
const inputSpecifier = `[${index}:a]`;
|
||||
const outputSpecifier = `[pad${index}]`;
|
||||
|
||||
filterParts.push(
|
||||
`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`,
|
||||
);
|
||||
|
||||
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||
console.log(
|
||||
`[muxer] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`,
|
||||
);
|
||||
});
|
||||
|
||||
// Merge them using amix
|
||||
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||
filterParts.push(
|
||||
`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`,
|
||||
);
|
||||
|
||||
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
||||
const inputs = clips.map((clip) => clip.oggPath);
|
||||
|
||||
console.log(`[muxer] Combining clips. This might take a while...`);
|
||||
|
||||
try {
|
||||
const args = buildMuxFfmpegArgs({
|
||||
inputs,
|
||||
filter: filterParts.join(";"),
|
||||
output: outputFilename,
|
||||
codec: "libmp3lame",
|
||||
});
|
||||
|
||||
await runFfmpeg(args);
|
||||
console.log(
|
||||
`[muxer] Successfully muxed! Output saved to: ${outputFilename}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[muxer] FFmpeg Error:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
startMuxing();
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Transform, TransformCallback } from "node:stream";
|
||||
|
||||
/**
|
||||
* Transform stream untuk memfilter audio packets yang terlalu kecil
|
||||
* Packet yang terlalu kecil kemungkinan gagal didekripsi oleh Discord
|
||||
*/
|
||||
export class PacketFilter extends Transform {
|
||||
private minPacketSize: number;
|
||||
private filteredCount: number = 0;
|
||||
private totalCount: number = 0;
|
||||
|
||||
constructor(minPacketSize: number = 10) {
|
||||
super();
|
||||
this.minPacketSize = minPacketSize;
|
||||
}
|
||||
|
||||
_transform(
|
||||
chunk: Buffer,
|
||||
encoding: string,
|
||||
callback: TransformCallback,
|
||||
): void {
|
||||
this.totalCount++;
|
||||
|
||||
// Filter packet yang terlalu kecil
|
||||
if (chunk.length >= this.minPacketSize) {
|
||||
this.push(chunk);
|
||||
} else {
|
||||
this.filteredCount++;
|
||||
if (this.filteredCount % 10 === 0) {
|
||||
// console.log(`[packet-filter] Filtered ${this.filteredCount} small packets (size < ${this.minPacketSize} bytes)`);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
|
||||
_flush(callback: TransformCallback): void {
|
||||
// console.log(`[packet-filter] Total packets: ${this.totalCount}, filtered: ${this.filteredCount}, passed: ${this.totalCount - this.filteredCount}`);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
AudioPlayer,
|
||||
AudioPlayerStatus,
|
||||
type AudioResource,
|
||||
createAudioPlayer,
|
||||
createAudioResource,
|
||||
StreamType,
|
||||
VoiceConnection,
|
||||
} from "@discordjs/voice";
|
||||
import type {
|
||||
DiscordPlayerOwner,
|
||||
DiscordPlayOptions,
|
||||
} from "./media/mediaTypes.js";
|
||||
|
||||
export class DiscordPlayer {
|
||||
private player: AudioPlayer;
|
||||
private connection: VoiceConnection | null = null;
|
||||
private owner: DiscordPlayerOwner = "none";
|
||||
private resource: AudioResource | null = null;
|
||||
private musicVolume = 1;
|
||||
|
||||
constructor() {
|
||||
this.player = createAudioPlayer();
|
||||
|
||||
this.player.on(AudioPlayerStatus.Playing, () => {
|
||||
console.log("[player] Audio player is now playing!");
|
||||
});
|
||||
|
||||
this.player.on("error", (error) => {
|
||||
console.error(`[player] Error: ${error.message}`);
|
||||
this.owner = "none";
|
||||
this.resource = null;
|
||||
});
|
||||
}
|
||||
|
||||
public setConnection(connection: VoiceConnection) {
|
||||
this.connection = connection;
|
||||
this.connection.subscribe(this.player);
|
||||
}
|
||||
|
||||
public getOwner(): DiscordPlayerOwner {
|
||||
return this.owner;
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connection !== null;
|
||||
}
|
||||
|
||||
public playStream(
|
||||
stream: Readable,
|
||||
owner: DiscordPlayerOwner,
|
||||
options: DiscordPlayOptions = {},
|
||||
) {
|
||||
if (owner === "none") {
|
||||
throw new Error("Discord audio player owner is required");
|
||||
}
|
||||
this.assertOwnerAvailable(owner);
|
||||
|
||||
const resource = createAudioResource(stream, {
|
||||
inputType: options.inputType ?? StreamType.OggOpus,
|
||||
inlineVolume: options.inlineVolume ?? false,
|
||||
});
|
||||
|
||||
if (this.owner === owner) {
|
||||
this.player.stop();
|
||||
}
|
||||
this.resource = resource;
|
||||
this.owner = owner;
|
||||
if (owner === "music") {
|
||||
const nextVolume =
|
||||
options.volume !== undefined
|
||||
? this.normalizeVolume(options.volume)
|
||||
: this.musicVolume;
|
||||
this.musicVolume = nextVolume;
|
||||
this.setResourceVolume(nextVolume);
|
||||
}
|
||||
this.player.play(resource);
|
||||
this.unpause(owner);
|
||||
this.connection?.subscribe(this.player);
|
||||
}
|
||||
|
||||
public getStatus(): AudioPlayerStatus {
|
||||
return this.player.state.status;
|
||||
}
|
||||
|
||||
public pause(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.pause(true);
|
||||
}
|
||||
|
||||
public unpause(owner?: DiscordPlayerOwner): boolean {
|
||||
if (!this.canControl(owner)) return false;
|
||||
return this.player.unpause();
|
||||
}
|
||||
|
||||
public stop(owner?: DiscordPlayerOwner) {
|
||||
if (!this.canControl(owner)) return;
|
||||
this.player.stop();
|
||||
this.owner = "none";
|
||||
this.resource = null;
|
||||
}
|
||||
|
||||
public getMusicVolume(): number {
|
||||
return this.musicVolume;
|
||||
}
|
||||
|
||||
public setMusicVolume(volume: number): void {
|
||||
const nextVolume = this.normalizeVolume(volume);
|
||||
this.musicVolume = nextVolume;
|
||||
if (this.owner === "music") {
|
||||
this.setResourceVolume(nextVolume);
|
||||
}
|
||||
}
|
||||
|
||||
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||
if (this.owner !== "none" && this.owner !== owner) {
|
||||
throw new Error(`Discord audio player is owned by ${this.owner}`);
|
||||
}
|
||||
}
|
||||
|
||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||
return !owner || this.owner === "none" || this.owner === owner;
|
||||
}
|
||||
|
||||
private normalizeVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume)) return this.musicVolume;
|
||||
return Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
private setResourceVolume(volume: number): void {
|
||||
if (!this.resource?.volume) return;
|
||||
this.resource.volume.setVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
export const discordPlayer = new DiscordPlayer();
|
||||
-326
@@ -1,326 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
type DiscordGatewayAdapterCreator,
|
||||
EndBehaviorType,
|
||||
entersState,
|
||||
getVoiceConnection,
|
||||
joinVoiceChannel,
|
||||
type VoiceConnection,
|
||||
VoiceConnectionStatus,
|
||||
} from "@discordjs/voice";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "./config.js";
|
||||
import { createChildLogger } from "./logger.js";
|
||||
import { PacketFilter } from "./packetFilter.js";
|
||||
import { subscribeToAudioStream } from "./recorder/audioStream.js";
|
||||
import { OpusDecoder } from "./recorder/decoder.js";
|
||||
import {
|
||||
collectUserMetadata,
|
||||
createSegmentMetadata,
|
||||
} from "./recorder/metadata.js";
|
||||
import { SegmentManager } from "./recorder/segment.js";
|
||||
import {
|
||||
createRecordingSession,
|
||||
finalizeRecordingSession,
|
||||
type RecordingSession,
|
||||
} from "./recorder/sessionRecording.js";
|
||||
import { uploadRecordingSegment } from "./recorder/uploader.js";
|
||||
import { retryWithBackoff } from "./retry.js";
|
||||
import type { PcmBroadcaster } from "./types.js";
|
||||
|
||||
const logger = createChildLogger("recorder");
|
||||
|
||||
const recordingsDir = config.RECORDINGS_DIR;
|
||||
|
||||
// Pastikan folder recordings ada
|
||||
if (!fs.existsSync(recordingsDir)) {
|
||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, RecordingSession>();
|
||||
|
||||
export function resetActiveSessions(): void {
|
||||
activeSessions.clear();
|
||||
}
|
||||
|
||||
function finalizeActiveRecordingSession(guildId: string): void {
|
||||
const session = activeSessions.get(guildId);
|
||||
if (!session) return;
|
||||
activeSessions.delete(guildId);
|
||||
finalizeRecordingSession(session).catch((error) => {
|
||||
logger.error({ error }, "Failed to finalize recording session");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||
*/
|
||||
export async function startRecording(
|
||||
client: Client,
|
||||
channel: VoiceChannel,
|
||||
): Promise<VoiceConnection | null> {
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guild.id,
|
||||
adapterCreator: channel.guild
|
||||
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
|
||||
selfDeaf: false,
|
||||
selfMute: false,
|
||||
debug: true,
|
||||
});
|
||||
|
||||
logger.info({ channelName: channel.name }, "Joining voice channel");
|
||||
|
||||
connection.on("debug", (msg) => {
|
||||
if (config.VERBOSE) {
|
||||
logger.debug({ message: msg }, "Voice debug");
|
||||
}
|
||||
});
|
||||
|
||||
connection.on("error", (err) => {
|
||||
logger.error({ error: err }, "Voice connection error");
|
||||
});
|
||||
|
||||
// Tunggu sampai benar-benar terhubung dengan retry logic
|
||||
try {
|
||||
await retryWithBackoff(
|
||||
() =>
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Ready,
|
||||
config.VOICE_CONNECTION_TIMEOUT_MS,
|
||||
),
|
||||
{
|
||||
retries: 0,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
logger.info("Connected to voice channel. Recording started");
|
||||
|
||||
// Create recording session after connection is ready
|
||||
const sessionStartTime = Date.now();
|
||||
const session = createRecordingSession({
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
startTime: sessionStartTime,
|
||||
recordingsDir,
|
||||
});
|
||||
activeSessions.set(channel.guild.id, session);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
||||
connection.destroy();
|
||||
return null;
|
||||
}
|
||||
|
||||
const receiver = connection.receiver;
|
||||
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
|
||||
|
||||
// Dengarkan siapapun yang mulai bicara
|
||||
receiver.speaking.on("start", async (userId) => {
|
||||
if (userId === client.user?.id) return;
|
||||
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
if (userMetadata.bot) return;
|
||||
|
||||
logger.debug(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
);
|
||||
|
||||
// Notify webserver
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: true,
|
||||
});
|
||||
|
||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||
if (receiver.subscriptions.has(userId)) return;
|
||||
|
||||
const userDir = path.join(recordingsDir, userId);
|
||||
if (!fs.existsSync(userDir)) {
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// --- OGG file recording with segment rotation ---
|
||||
const packetFilterForOgg = new PacketFilter(
|
||||
config.PACKET_FILTER_MIN_SIZE,
|
||||
);
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||
const segmentManager = new SegmentManager(
|
||||
userDir,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
|
||||
// --- Web broadcast: prism decoder with safe restart and cooldown ---
|
||||
const decoder = new OpusDecoder({
|
||||
cooldownMs: config.DECODER_COOLDOWN_MS,
|
||||
rotateMs: config.DECODER_ROTATE_MS,
|
||||
onData: (pcm) => {
|
||||
if (!broadcaster.broadcastPcmToWeb) return;
|
||||
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
|
||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||
}
|
||||
broadcaster.broadcastPcmToWeb(outBuf, userId);
|
||||
},
|
||||
});
|
||||
|
||||
const activeSession = activeSessions.get(channel.guild.id);
|
||||
let currentSegment = segmentManager.open(oggPacketStream);
|
||||
currentSegment.out.on("finish", () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||
}
|
||||
const endTime = currentSegment.endTime ?? Date.now();
|
||||
if (activeSession) {
|
||||
activeSession.registerSegment({
|
||||
user: userMetadata,
|
||||
oggPath: currentSegment.filename,
|
||||
jsonPath: currentSegment.jsonFilename,
|
||||
startTime: currentSegment.startTime,
|
||||
endTime,
|
||||
});
|
||||
}
|
||||
const metadata = createSegmentMetadata(
|
||||
userMetadata,
|
||||
currentSegment,
|
||||
activeSession?.sessionId ?? `${userId}-0`,
|
||||
activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
|
||||
activeSession?.startTime ?? 0,
|
||||
config.RECORDING_SEGMENT_MS,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
currentSegment.jsonFilename,
|
||||
JSON.stringify(metadata, null, 2),
|
||||
);
|
||||
if (config.VERBOSE) {
|
||||
logger.info(
|
||||
{ jsonFile: currentSegment.jsonFilename },
|
||||
"Metadata saved",
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger async voice segment upload
|
||||
const segmentId = `${userId}-${currentSegment.startTime}`;
|
||||
uploadRecordingSegment({
|
||||
id: segmentId,
|
||||
oggPath: currentSegment.filename,
|
||||
userId: userMetadata.userId,
|
||||
username: userMetadata.username,
|
||||
avatarUrl: userMetadata.avatarUrl,
|
||||
guildId: channel.guild.id,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
}).catch((err) => {
|
||||
logger.error(
|
||||
{ segmentId, error: err.message },
|
||||
"Upload segment trigger failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
currentSegment.out.on("error", (err) => {
|
||||
logger.error({ userId, error: err.message }, "File write error");
|
||||
});
|
||||
|
||||
// Feed Opus packets one-by-one
|
||||
subscribeToAudioStream(receiver, userId, {
|
||||
onPacket: (chunk) => {
|
||||
if (chunk.length < 8) return;
|
||||
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||
if (!broadcaster.broadcastPcmToWeb) return;
|
||||
decoder.rotateIfNeeded();
|
||||
decoder.write(chunk);
|
||||
},
|
||||
onEnd: () => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
broadcaster.updateActiveUser?.(userId, {
|
||||
username: userMetadata.username,
|
||||
avatar: userMetadata.avatarUrl,
|
||||
speaking: false,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
decoder.destroy();
|
||||
logger.error({ userId, error: error.message }, "Audio stream error");
|
||||
},
|
||||
});
|
||||
|
||||
packetFilterForOgg.on("error", (err) => {
|
||||
segmentManager.close(oggPacketStream);
|
||||
logger.error({ userId, error: err.message }, "PacketFilter error");
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{ userId, error: e instanceof Error ? e.message : String(e) },
|
||||
"Failed to create stream",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle disconnect yang tidak disengaja
|
||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||
if (config.VERBOSE) {
|
||||
logger.warn("Disconnected from voice channel. Reconnecting...");
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Signalling,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
entersState(
|
||||
connection,
|
||||
VoiceConnectionStatus.Connecting,
|
||||
config.RECONNECT_TIMEOUT_MS,
|
||||
),
|
||||
]);
|
||||
// Berhasil reconnect
|
||||
} catch {
|
||||
logger.error("Could not reconnect. Destroying connection");
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||
finalizeActiveRecordingSession(channel.guild.id);
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Voice connection destroyed");
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hentikan recording dan disconnect dari voice channel.
|
||||
*/
|
||||
export function stopRecording(guildId: string): void {
|
||||
const connection = getVoiceConnection(guildId);
|
||||
if (connection) {
|
||||
connection.destroy();
|
||||
if (config.VERBOSE) {
|
||||
logger.info("Recording stopped and disconnected");
|
||||
}
|
||||
} else {
|
||||
logger.warn("No active connection to stop");
|
||||
}
|
||||
|
||||
finalizeActiveRecordingSession(guildId);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export interface AudioStreamHandlers {
|
||||
onPacket: (chunk: Buffer) => void;
|
||||
onEnd: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function subscribeToAudioStream(
|
||||
receiver: VoiceReceiver,
|
||||
userId: string,
|
||||
handlers: AudioStreamHandlers,
|
||||
): NodeJS.ReadableStream {
|
||||
const audioStream = receiver.subscribe(userId, {
|
||||
end: {
|
||||
behavior: EndBehaviorType.AfterSilence,
|
||||
duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
|
||||
},
|
||||
});
|
||||
|
||||
audioStream.on("data", handlers.onPacket);
|
||||
audioStream.on("end", handlers.onEnd);
|
||||
audioStream.on("error", handlers.onError);
|
||||
|
||||
return audioStream;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as prism from "prism-media";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
interface OpusDecoderRuntime {
|
||||
isBun: boolean;
|
||||
canLoadNativeOpus: boolean;
|
||||
}
|
||||
|
||||
export function shouldEnableDefaultOpusDecoder(
|
||||
runtime: OpusDecoderRuntime,
|
||||
): boolean {
|
||||
return !runtime.isBun || runtime.canLoadNativeOpus;
|
||||
}
|
||||
|
||||
function canLoadNativeOpus(): boolean {
|
||||
try {
|
||||
require("@discordjs/opus");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDecoderEnabled = shouldEnableDefaultOpusDecoder({
|
||||
isBun: Boolean(process.versions.bun),
|
||||
canLoadNativeOpus: canLoadNativeOpus(),
|
||||
});
|
||||
|
||||
export interface OpusDecoderOptions {
|
||||
cooldownMs: number;
|
||||
rotateMs: number;
|
||||
createDecoder?: () => prism.opus.Decoder;
|
||||
onData: (pcm: Buffer) => void;
|
||||
}
|
||||
|
||||
export class OpusDecoder {
|
||||
private decoder: prism.opus.Decoder | null = null;
|
||||
private disabledUntil = 0;
|
||||
private createdAt = 0;
|
||||
private readonly cooldownMs: number;
|
||||
private readonly rotateMs: number;
|
||||
private readonly createDecoderFn: () => prism.opus.Decoder;
|
||||
private readonly onData: (pcm: Buffer) => void;
|
||||
|
||||
constructor(options: OpusDecoderOptions) {
|
||||
this.cooldownMs = options.cooldownMs;
|
||||
this.rotateMs = options.rotateMs;
|
||||
this.onData = options.onData;
|
||||
this.createDecoderFn =
|
||||
options.createDecoder ??
|
||||
(() => {
|
||||
if (!defaultDecoderEnabled) {
|
||||
throw new Error(
|
||||
"Native @discordjs/opus is unavailable under Bun; web PCM decode disabled to avoid opusscript aborts",
|
||||
);
|
||||
}
|
||||
|
||||
return new prism.opus.Decoder({
|
||||
frameSize: config.OPUS_FRAME_SIZE,
|
||||
channels: config.AUDIO_CHANNELS as 1 | 2,
|
||||
rate: config.AUDIO_SAMPLE_RATE as
|
||||
| 8000
|
||||
| 12000
|
||||
| 16000
|
||||
| 24000
|
||||
| 48000,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
rotateIfNeeded(): void {
|
||||
if (!this.decoder || this.rotateMs <= 0) return;
|
||||
if (Date.now() - this.createdAt < this.rotateMs) return;
|
||||
this.destroy();
|
||||
this.ensureDecoder();
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
const decoder = this.ensureDecoder();
|
||||
if (!decoder) return;
|
||||
try {
|
||||
decoder.write(chunk);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[recorder] Opus decoder write failed, cooling down:",
|
||||
error,
|
||||
);
|
||||
this.coolDown();
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (!this.decoder) return;
|
||||
this.decoder.removeAllListeners();
|
||||
this.decoder.destroy();
|
||||
this.decoder = null;
|
||||
this.createdAt = 0;
|
||||
}
|
||||
|
||||
private ensureDecoder(): prism.opus.Decoder | null {
|
||||
if (this.decoder) return this.decoder;
|
||||
if (Date.now() < this.disabledUntil) return null;
|
||||
try {
|
||||
const decoder = this.createDecoderFn();
|
||||
decoder.on("data", this.onData);
|
||||
decoder.on("error", (error) => {
|
||||
console.warn("[recorder] Opus decoder error, cooling down:", error);
|
||||
this.coolDown();
|
||||
});
|
||||
this.decoder = decoder;
|
||||
this.createdAt = Date.now();
|
||||
return decoder;
|
||||
} catch (error) {
|
||||
console.warn("[recorder] Opus decoder init failed, cooling down:", error);
|
||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private coolDown(): void {
|
||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { config } from "../config.js";
|
||||
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types.js";
|
||||
|
||||
export async function collectUserMetadata(
|
||||
client: Client,
|
||||
userId: string,
|
||||
channel: VoiceChannel,
|
||||
): Promise<UserMetadata> {
|
||||
const user =
|
||||
client.users.cache.get(userId) ||
|
||||
(await client.users.fetch(userId).catch(() => null));
|
||||
const member =
|
||||
channel.guild.members.cache.get(userId) ||
|
||||
(await channel.guild.members.fetch(userId).catch(() => null));
|
||||
const username = user?.username ?? "Unknown User";
|
||||
const roles =
|
||||
member?.roles.cache
|
||||
.filter((role) => role.id !== channel.guild.id)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
position: role.position,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
tag: user?.tag ?? "Unknown#0000",
|
||||
displayName: member?.displayName ?? username,
|
||||
avatarUrl:
|
||||
user?.displayAvatarURL({
|
||||
format: "png",
|
||||
size: config.AVATAR_SIZE as
|
||||
| 16
|
||||
| 32
|
||||
| 64
|
||||
| 128
|
||||
| 256
|
||||
| 512
|
||||
| 1024
|
||||
| 2048
|
||||
| 4096,
|
||||
}) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||
bot: user?.bot ?? false,
|
||||
roles,
|
||||
highestRole: roles[0] ?? null,
|
||||
joinedTimestamp: member?.joinedTimestamp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSegmentMetadata(
|
||||
user: UserMetadata,
|
||||
segment: SegmentState,
|
||||
sessionId: string,
|
||||
recordingSessionId: string,
|
||||
sessionStartTime: number,
|
||||
recordingSegmentMs: number,
|
||||
): SegmentMetadata {
|
||||
const endTime = segment.endTime ?? Date.now();
|
||||
return {
|
||||
...user,
|
||||
sessionId,
|
||||
recordingSessionId,
|
||||
sessionStartTime,
|
||||
segmentIndex: segment.index,
|
||||
segmentMs: recordingSegmentMs,
|
||||
startTime: segment.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - segment.startTime,
|
||||
filename: path.basename(segment.filename),
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as prism from "prism-media";
|
||||
import type { SegmentState } from "../types.js";
|
||||
|
||||
export function buildSegmentPaths(
|
||||
userDir: string,
|
||||
startTime: number,
|
||||
): { filename: string; jsonFilename: string } {
|
||||
return {
|
||||
filename: path.join(userDir, `${startTime}.ogg`),
|
||||
jsonFilename: path.join(userDir, `${startTime}.json`),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRotateSegment(
|
||||
startTime: number,
|
||||
now: number,
|
||||
recordingSegmentMs: number,
|
||||
): boolean {
|
||||
return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs;
|
||||
}
|
||||
|
||||
export class SegmentManager {
|
||||
private currentSegment: SegmentState | null = null;
|
||||
private segmentIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly userDir: string,
|
||||
private readonly recordingSegmentMs: number,
|
||||
) {}
|
||||
|
||||
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
||||
const index = this.segmentIndex++;
|
||||
const startTime = Date.now();
|
||||
const { filename, jsonFilename } = buildSegmentPaths(
|
||||
this.userDir,
|
||||
startTime,
|
||||
);
|
||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
||||
pageSizeControl: { maxPackets: 10 },
|
||||
crc: true,
|
||||
});
|
||||
const out = fs.createWriteStream(filename);
|
||||
oggPacketStream.pipe(oggStream).pipe(out);
|
||||
|
||||
this.currentSegment = {
|
||||
index,
|
||||
startTime,
|
||||
endTime: null,
|
||||
filename,
|
||||
jsonFilename,
|
||||
oggStream,
|
||||
out,
|
||||
};
|
||||
return this.currentSegment;
|
||||
}
|
||||
|
||||
close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||
if (!this.currentSegment) return null;
|
||||
const segment = this.currentSegment;
|
||||
segment.endTime = Date.now();
|
||||
oggPacketStream.unpipe(segment.oggStream);
|
||||
segment.oggStream.end();
|
||||
this.currentSegment = null;
|
||||
return segment;
|
||||
}
|
||||
|
||||
rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||
if (!this.currentSegment) return null;
|
||||
if (
|
||||
!shouldRotateSegment(
|
||||
this.currentSegment.startTime,
|
||||
Date.now(),
|
||||
this.recordingSegmentMs,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
this.close(oggPacketStream);
|
||||
return this.open(oggPacketStream);
|
||||
}
|
||||
|
||||
getCurrent(): SegmentState | null {
|
||||
return this.currentSegment;
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildMuxFfmpegArgs,
|
||||
runFfmpeg as defaultRunFfmpeg,
|
||||
} from "../audio/ffmpegProcess.js";
|
||||
import type { UserMetadata } from "../types.js";
|
||||
|
||||
export type SessionRecordingStatus =
|
||||
| "pending"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "empty";
|
||||
|
||||
export interface RecordingSessionOptions {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
recordingsDir: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentInput {
|
||||
user: UserMetadata;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SessionParticipant {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface SessionSegmentRef {
|
||||
userId: string;
|
||||
oggPath: string;
|
||||
jsonPath: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
offsetMs: number;
|
||||
}
|
||||
|
||||
export interface SessionRecordingMetadata {
|
||||
sessionId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
status: SessionRecordingStatus;
|
||||
outputFile: string | null;
|
||||
participants: SessionParticipant[];
|
||||
segments: SessionSegmentRef[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecordingSession {
|
||||
readonly sessionId: string;
|
||||
readonly recordingsDir: string;
|
||||
readonly startTime: number;
|
||||
registerSegment(input: SessionSegmentInput): void;
|
||||
snapshot(endTime: number): SessionRecordingMetadata;
|
||||
}
|
||||
|
||||
export interface FinalizeRecordingSessionDependencies {
|
||||
endTime?: number;
|
||||
mkdir?: (dir: string) => void;
|
||||
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createRecordingSession(
|
||||
options: RecordingSessionOptions,
|
||||
): RecordingSession {
|
||||
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||
const participants = new Map<string, SessionParticipant>();
|
||||
const segments: SessionSegmentRef[] = [];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
recordingsDir: options.recordingsDir,
|
||||
startTime: options.startTime,
|
||||
|
||||
registerSegment(input: SessionSegmentInput): void {
|
||||
participants.set(input.user.userId, {
|
||||
userId: input.user.userId,
|
||||
username: input.user.username,
|
||||
tag: input.user.tag,
|
||||
displayName: input.user.displayName,
|
||||
avatarUrl: input.user.avatarUrl,
|
||||
});
|
||||
segments.push({
|
||||
userId: input.user.userId,
|
||||
oggPath: input.oggPath,
|
||||
jsonPath: input.jsonPath,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
durationMs: input.endTime - input.startTime,
|
||||
offsetMs: input.startTime - options.startTime,
|
||||
});
|
||||
},
|
||||
|
||||
snapshot(endTime: number): SessionRecordingMetadata {
|
||||
return {
|
||||
sessionId,
|
||||
guildId: options.guildId,
|
||||
channelId: options.channelId,
|
||||
channelName: options.channelName,
|
||||
startTime: options.startTime,
|
||||
endTime,
|
||||
durationMs: endTime - options.startTime,
|
||||
status: "pending",
|
||||
outputFile: null,
|
||||
participants: Array.from(participants.values()),
|
||||
segments: [...segments],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionMuxFilter(
|
||||
segments: Array<{ startTime: number }>,
|
||||
sessionStartTime: number,
|
||||
): string {
|
||||
const filters = segments.map((segment, index) => {
|
||||
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||
});
|
||||
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||
filters.push(
|
||||
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
|
||||
);
|
||||
return filters.join(";");
|
||||
}
|
||||
|
||||
export async function finalizeRecordingSession(
|
||||
session: RecordingSession,
|
||||
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||
): Promise<void> {
|
||||
const endTime = dependencies.endTime ?? Date.now();
|
||||
const sessionDir = path.join(
|
||||
session.recordingsDir,
|
||||
"sessions",
|
||||
session.sessionId,
|
||||
);
|
||||
const outputFile = path.join(sessionDir, "full.ogg");
|
||||
const metadataFile = path.join(sessionDir, "session.json");
|
||||
const mkdir =
|
||||
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||
const writeJson =
|
||||
dependencies.writeJson ??
|
||||
((file, metadata) =>
|
||||
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||
|
||||
mkdir(sessionDir);
|
||||
const metadata = session.snapshot(endTime);
|
||||
|
||||
if (metadata.segments.length === 0) {
|
||||
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runFfmpeg(
|
||||
buildMuxFfmpegArgs({
|
||||
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||
output: outputFile,
|
||||
codec: "libopus",
|
||||
}),
|
||||
);
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "completed",
|
||||
outputFile,
|
||||
});
|
||||
} catch (error) {
|
||||
writeJson(metadataFile, {
|
||||
...metadata,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
insertVoiceRecording,
|
||||
updateVoiceRecordingAsFailed,
|
||||
updateVoiceRecordingAsUploaded,
|
||||
} from "../database/voiceRecordingRepo.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { uploadToTele } from "../uploader/teleUpload.js";
|
||||
|
||||
const logger = createChildLogger("recording-uploader");
|
||||
|
||||
/**
|
||||
* Uploads a recorded segment OGG file to external server and registers in database
|
||||
*/
|
||||
export async function uploadRecordingSegment(input: {
|
||||
id: string;
|
||||
oggPath: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
channelName: string | null;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
id,
|
||||
oggPath,
|
||||
userId,
|
||||
username,
|
||||
avatarUrl,
|
||||
guildId,
|
||||
channelId,
|
||||
channelName,
|
||||
} = input;
|
||||
const fileName = path.basename(oggPath);
|
||||
|
||||
try {
|
||||
// 1. Get file size and insert initial pending state to DB
|
||||
const stats = await fs.promises.stat(oggPath);
|
||||
await insertVoiceRecording({
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
upload_status: "pending",
|
||||
created_at: Date.now(),
|
||||
});
|
||||
|
||||
// 2. Perform async upload with retry logic
|
||||
const fileBuffer = await fs.promises.readFile(oggPath);
|
||||
const uploadResult = await uploadToTele({
|
||||
buffer: fileBuffer,
|
||||
filename: fileName,
|
||||
contentType: "audio/ogg",
|
||||
uploadUrl: config.TELE_UPLOAD_URL,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
const downloadUrl = uploadResult.url;
|
||||
|
||||
// 3. Update DB to uploaded state
|
||||
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now());
|
||||
logger.info({ id, downloadUrl }, "Recording segment uploaded successfully");
|
||||
|
||||
// 4. Broadcast via WebSocket if broadcaster exists globally
|
||||
const broadcaster = (globalThis as any).moderationBroadcaster;
|
||||
if (broadcaster) {
|
||||
const payload = JSON.stringify({
|
||||
type: "voice_recording_uploaded",
|
||||
data: {
|
||||
id,
|
||||
user_id: userId,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
filename: fileName,
|
||||
size_bytes: stats.size,
|
||||
download_url: downloadUrl,
|
||||
upload_status: "uploaded",
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
broadcaster.getClients().forEach((client: any) => {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Failed to send recording upload event to client",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ id, error: errorMsg }, "Failed to upload voice recording");
|
||||
await updateVoiceRecordingAsFailed(id, errorMsg).catch((err) => {
|
||||
logger.error({ id, err }, "Failed to write failure state to DB");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import pRetry from "p-retry";
|
||||
import type { CustomLogger } from "./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 = 0,
|
||||
maxTimeout = 0,
|
||||
factor = 1,
|
||||
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",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import {
|
||||
getAnalysisQueueStatus,
|
||||
queueMessageAnalysis,
|
||||
} from "../moderation/aiAnalyzer.js";
|
||||
import {
|
||||
searchMessages,
|
||||
getMessageById,
|
||||
updateMessageAIAnalysis,
|
||||
} from "../moderation/messageStore.js";
|
||||
import type { MessageRecord } from "../moderation/types.js";
|
||||
|
||||
export function createAnalysisRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/analysis/status - Get current analysis queue status
|
||||
router.get("/analysis/status", (_req, res, next) => {
|
||||
try {
|
||||
const status = getAnalysisQueueStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analysis/search - Search for message IDs by query
|
||||
router.get("/analysis/search", async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
q,
|
||||
channelId,
|
||||
limit = "20",
|
||||
} = req.query as {
|
||||
q?: string;
|
||||
channelId?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
if (!q) {
|
||||
throw new AppError(
|
||||
"Query parameter 'q' is required",
|
||||
"MISSING_QUERY",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const limitNum = Math.min(parseInt(limit) || 20, 100);
|
||||
|
||||
const results = await searchMessages({
|
||||
query: q,
|
||||
guildId: config.MONITOR_GUILD_ID,
|
||||
channelId,
|
||||
limit: limitNum,
|
||||
});
|
||||
|
||||
res.json({
|
||||
query: q,
|
||||
count: results.length,
|
||||
results: results.map((msg: MessageRecord) => ({
|
||||
id: msg.id,
|
||||
content: msg.edited_content ?? msg.content,
|
||||
username: msg.username,
|
||||
created_at: msg.created_at,
|
||||
ai_status: msg.ai_status,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/messages/:id/reanalyze - Queue a message for re-analysis
|
||||
router.post("/messages/:id/reanalyze", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new AppError("Message ID is required", "MISSING_MESSAGE_ID", 400);
|
||||
}
|
||||
|
||||
const existing = await getMessageById(id);
|
||||
if (!existing) {
|
||||
throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
if (existing.guild_id !== config.MONITOR_GUILD_ID) {
|
||||
throw new AppError(
|
||||
"Message is outside the monitor guild",
|
||||
"INVALID_GUILD",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// P3: Single UPDATE + RETURNING instead of GET + UPDATE + GET
|
||||
const updated = await updateMessageAIAnalysis(id, {
|
||||
status: "pending",
|
||||
flags: null,
|
||||
score: null,
|
||||
analysis: null,
|
||||
analyzedAt: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
// Queue for analysis
|
||||
await queueMessageAnalysis(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
messageId: id,
|
||||
queued: true,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import {
|
||||
getActivityHeatmap,
|
||||
getAnalyticsOverview,
|
||||
getDailyTrend,
|
||||
getHourlyStats,
|
||||
getModerationStats,
|
||||
getTopicTrends,
|
||||
getTopViolators,
|
||||
getUserLeaderboard,
|
||||
} from "../moderation/analyticsStore.js";
|
||||
|
||||
export function createAnalyticsRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
function assertMonitorGuild(guildId?: string): string {
|
||||
if (!config.MONITOR_GUILD_ID) {
|
||||
throw new AppError(
|
||||
"MONITOR_GUILD_ID is required for analytics",
|
||||
"MISSING_MONITOR_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (guildId && guildId !== config.MONITOR_GUILD_ID) {
|
||||
throw new AppError(
|
||||
"Analytics are restricted to the monitor guild",
|
||||
"INVALID_GUILD",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
return config.MONITOR_GUILD_ID;
|
||||
}
|
||||
|
||||
// GET /api/analytics/overview - Full analytics dashboard data
|
||||
// Query params: guildId (required), channelId, hours (default 24)
|
||||
router.get("/analytics/overview", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const overview = await getAnalyticsOverview({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(overview);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/hourly - Hourly message stats
|
||||
// Query params: guildId (required), channelId, hours (default 24)
|
||||
router.get("/analytics/hourly", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const stats = await getHourlyStats({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/topics - Topic trends
|
||||
// Query params: guildId (required), channelId, hours (default 24)
|
||||
router.get("/analytics/topics", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const topics = await getTopicTrends({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(topics);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/leaderboard - User leaderboard
|
||||
// Query params: guildId (required), channelId, hours (default 24), limit (default 20)
|
||||
router.get("/analytics/leaderboard", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours, limit } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const users = await getUserLeaderboard({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/stats - Moderation stats breakdown
|
||||
// Query params: guildId (required), channelId, hours (default 24)
|
||||
router.get("/analytics/stats", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const stats = await getModerationStats({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/violators - Top violators leaderboard
|
||||
// Query params: guildId (required), channelId, hours (default 24), limit (default 20)
|
||||
router.get("/analytics/violators", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours, limit } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24;
|
||||
const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const violators = await getTopViolators({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
|
||||
res.json(violators);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/trend - Daily trend data (for line chart)
|
||||
// Query params: guildId (required), channelId, hours (default 168)
|
||||
router.get("/analytics/trend", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const trend = await getDailyTrend({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(trend);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/analytics/heatmap - Activity heatmap (day × hour)
|
||||
// Query params: guildId (required), channelId, hours (default 168)
|
||||
router.get("/analytics/heatmap", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, hours } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
hours?: string;
|
||||
};
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError(
|
||||
"guildId query parameter is required",
|
||||
"MISSING_GUILD_ID",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168;
|
||||
const monitorGuildId = assertMonitorGuild(guildId);
|
||||
|
||||
const heatmap = await getActivityHeatmap({
|
||||
guildId: monitorGuildId,
|
||||
channelId,
|
||||
hours: hoursNum,
|
||||
});
|
||||
|
||||
res.json(heatmap);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../config.js";
|
||||
|
||||
export function createAppConfigRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/config", (_req, res) => {
|
||||
res.json({
|
||||
monitorGuildId: config.MONITOR_GUILD_ID ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { NextFunction, Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors.js";
|
||||
import type { MediaController } from "../media/mediaController.js";
|
||||
import type { MediaMode } from "../media/mediaTypes.js";
|
||||
|
||||
export type MediaRouteController = Pick<
|
||||
MediaController,
|
||||
"getState" | "queue" | "skip" | "stop" | "setMusicVolume"
|
||||
>;
|
||||
|
||||
export interface MediaRouteOptions {
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export function createMediaRoutes(
|
||||
controller: MediaRouteController,
|
||||
options: MediaRouteOptions = {},
|
||||
): Router {
|
||||
const router = express.Router();
|
||||
const { adminPassword } = options;
|
||||
|
||||
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!adminPassword) return next();
|
||||
const authHeader = req.headers["x-admin-password"];
|
||||
if (authHeader === adminPassword) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||
}
|
||||
};
|
||||
|
||||
// Apply admin auth as router-level middleware so route stack ordering
|
||||
// remains predictable for tests that inspect route handlers.
|
||||
router.use(adminAuth);
|
||||
|
||||
router.get(
|
||||
"/media/status",
|
||||
(_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(controller.getState());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/media/queue",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { source, mode = "music" } = req.body as {
|
||||
source?: string;
|
||||
mode?: MediaMode;
|
||||
};
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
"Media source is required",
|
||||
"MISSING_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (mode !== "music" && mode !== "screen") {
|
||||
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
|
||||
}
|
||||
res.json(await controller.queue(source, { mode }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/media/skip",
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.skip());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/media/stop",
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.stop());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/media/volume",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { volume } = req.body as { volume?: number };
|
||||
if (typeof volume !== "number" || Number.isNaN(volume)) {
|
||||
throw new AppError("Volume is required", "INVALID_VOLUME", 400);
|
||||
}
|
||||
if (volume < 0 || volume > 1) {
|
||||
throw new AppError(
|
||||
"Volume must be between 0 and 1",
|
||||
"INVALID_VOLUME",
|
||||
400,
|
||||
);
|
||||
}
|
||||
res.json(await controller.setMusicVolume(volume));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { config } from "../config.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import {
|
||||
getAttachmentsByChannel,
|
||||
getMessageById,
|
||||
getMessagesByChannel,
|
||||
listMessages,
|
||||
listReviewMessages,
|
||||
} from "../moderation/messageStore.js";
|
||||
import type { AIStatus, MessageQuery } from "../moderation/types.js";
|
||||
|
||||
const aiStatuses = new Set<AIStatus>([
|
||||
"pending",
|
||||
"clean",
|
||||
"warn",
|
||||
"flagged",
|
||||
"error",
|
||||
]);
|
||||
|
||||
function parseStatuses(value?: string): AIStatus[] | undefined {
|
||||
if (!value) return undefined;
|
||||
return value
|
||||
.split(",")
|
||||
.filter((item): item is AIStatus => aiStatuses.has(item as AIStatus));
|
||||
}
|
||||
|
||||
export function createMessageRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/messages - List messages by channel (backward compatible)
|
||||
// Query params: channel (required), type (text|image), limit, offset
|
||||
// Also supports new params: channelId, status, cursor, limit
|
||||
router.get("/messages", async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
channel,
|
||||
channelId,
|
||||
type,
|
||||
limit = "50",
|
||||
offset = "0",
|
||||
status,
|
||||
cursor,
|
||||
} = req.query as {
|
||||
channel?: string;
|
||||
channelId?: string;
|
||||
type?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
status?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
|
||||
const targetChannel = channelId || channel;
|
||||
const monitorGuildId = config.MONITOR_GUILD_ID;
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
if (type === "image") {
|
||||
if (!targetChannel) {
|
||||
throw new AppError(
|
||||
"channel or channelId query parameter is required",
|
||||
"MISSING_CHANNEL",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const attachments = await getAttachmentsByChannel(
|
||||
targetChannel,
|
||||
limitNum,
|
||||
offsetNum,
|
||||
monitorGuildId,
|
||||
);
|
||||
res.json({
|
||||
type: "image",
|
||||
data: attachments,
|
||||
count: attachments.length,
|
||||
nextCursor: null,
|
||||
});
|
||||
} else if (channelId || cursor || status) {
|
||||
const result = await listMessages({
|
||||
guildId: monitorGuildId,
|
||||
channelId: targetChannel,
|
||||
cursor,
|
||||
limit: limitNum,
|
||||
status: parseStatuses(status),
|
||||
});
|
||||
res.json({
|
||||
type: "text",
|
||||
data: result.data,
|
||||
count: result.data.length,
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} else {
|
||||
if (!targetChannel) {
|
||||
throw new AppError(
|
||||
"channel or channelId query parameter is required",
|
||||
"MISSING_CHANNEL",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await getMessagesByChannel(
|
||||
targetChannel,
|
||||
limitNum,
|
||||
offsetNum,
|
||||
monitorGuildId,
|
||||
);
|
||||
res.json({
|
||||
type: "text",
|
||||
data: messages,
|
||||
count: messages.length,
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/messages/:id - Get a specific message
|
||||
router.get("/messages/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
throw new AppError("Message ID is required", "MISSING_MESSAGE_ID", 400);
|
||||
}
|
||||
|
||||
const message = await getMessageById(id);
|
||||
|
||||
if (!message) {
|
||||
throw new AppError("Message not found", "MESSAGE_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
if (message.guild_id !== config.MONITOR_GUILD_ID) {
|
||||
throw new AppError(
|
||||
"Message is outside the monitor guild",
|
||||
"INVALID_GUILD",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
res.json(message);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/review - List messages flagged for review
|
||||
// Query params: guildId, channelId, threadId, userId, cursor, limit
|
||||
router.get("/review", async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
guildId,
|
||||
channelId,
|
||||
threadId,
|
||||
userId,
|
||||
cursor,
|
||||
limit = "50",
|
||||
} = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
threadId?: string;
|
||||
userId?: string;
|
||||
cursor?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
|
||||
const query: Omit<MessageQuery, "status"> = {
|
||||
guildId: guildId || config.MONITOR_GUILD_ID,
|
||||
channelId,
|
||||
threadId,
|
||||
userId,
|
||||
cursor,
|
||||
limit: limitNum,
|
||||
};
|
||||
|
||||
const result = await listReviewMessages(query);
|
||||
|
||||
res.json({
|
||||
data: result.data,
|
||||
nextCursor: result.nextCursor,
|
||||
count: result.data.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/attachments - List attachments by channel
|
||||
// Query params: channel (required), limit, offset
|
||||
router.get("/attachments", async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
channel,
|
||||
limit = "50",
|
||||
offset = "0",
|
||||
} = req.query as {
|
||||
channel?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
};
|
||||
|
||||
if (!channel) {
|
||||
throw new AppError(
|
||||
"channel query parameter is required",
|
||||
"MISSING_CHANNEL",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
const attachments = await getAttachmentsByChannel(
|
||||
channel,
|
||||
limitNum,
|
||||
offsetNum,
|
||||
);
|
||||
|
||||
res.json({
|
||||
data: attachments,
|
||||
count: attachments.length,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
Router,
|
||||
} from "express";
|
||||
import { listVoiceRecordings } from "../database/voiceRecordingRepo.js";
|
||||
import { AppError } from "../errors.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
|
||||
const logger = createChildLogger("recordings-routes");
|
||||
|
||||
export function createRecordingsRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/recordings",
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const recordings = await listVoiceRecordings(100);
|
||||
res.json(recordings);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to list recordings",
|
||||
);
|
||||
next(
|
||||
new AppError(
|
||||
"DATABASE_ERROR",
|
||||
"Failed to retrieve voice recordings",
|
||||
500,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors.js";
|
||||
import {
|
||||
createMessageReview,
|
||||
createModerationAction,
|
||||
getMessageReview,
|
||||
getModerationAction,
|
||||
listMessageReviews,
|
||||
listModerationActions,
|
||||
updateMessageReview,
|
||||
updateModerationAction,
|
||||
} from "../moderation/messageStore.js";
|
||||
import type {
|
||||
MessageReview,
|
||||
ModerationAction,
|
||||
ReviewStatus,
|
||||
} from "../moderation/types.js";
|
||||
|
||||
function parseLimit(value?: string): number {
|
||||
return Math.max(1, Math.min(value ? parseInt(value) : 50, 100));
|
||||
}
|
||||
|
||||
function parseStatuses(value?: string): string[] | undefined {
|
||||
if (!value) return undefined;
|
||||
return value.split(",").filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
export function createReviewRoutes(): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// Message Reviews
|
||||
// ===============
|
||||
|
||||
// GET /api/reviews - List message reviews
|
||||
router.get("/reviews", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId, status, cursor, limit } = req.query as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
status?: string;
|
||||
cursor?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
const result = await listMessageReviews({
|
||||
guildId,
|
||||
channelId,
|
||||
status: parseStatuses(status),
|
||||
cursor,
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reviews/:id - Get a specific review
|
||||
router.get("/reviews/:id", async (req, res, next) => {
|
||||
try {
|
||||
const review = await getMessageReview(req.params.id);
|
||||
if (!review) {
|
||||
throw new AppError("Review not found", "REVIEW_NOT_FOUND", 404);
|
||||
}
|
||||
res.json(review);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reviews - Create a new review
|
||||
router.post("/reviews", async (req, res, next) => {
|
||||
try {
|
||||
const { message_id, guild_id, channel_id, reviewer_id, status, notes } =
|
||||
req.body as {
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
reviewer_id?: string;
|
||||
status?: ReviewStatus;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
if (!message_id || !guild_id || !channel_id) {
|
||||
throw new AppError(
|
||||
"message_id, guild_id, and channel_id are required",
|
||||
"MISSING_REVIEW_FIELDS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const review = await createMessageReview({
|
||||
message_id,
|
||||
guild_id,
|
||||
channel_id,
|
||||
reviewer_id: reviewer_id || null,
|
||||
status: status || "pending",
|
||||
notes: notes || null,
|
||||
reviewed_at: null,
|
||||
});
|
||||
|
||||
res.status(201).json(review);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/reviews/:id - Update a review
|
||||
router.patch("/reviews/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { status, notes, reviewer_id } = req.body as {
|
||||
status?: ReviewStatus;
|
||||
notes?: string;
|
||||
reviewer_id?: string;
|
||||
};
|
||||
|
||||
const updates: Partial<MessageReview> = {};
|
||||
if (status) updates.status = status;
|
||||
if (notes !== undefined) updates.notes = notes;
|
||||
if (reviewer_id !== undefined) updates.reviewer_id = reviewer_id;
|
||||
if (status && status !== "pending") {
|
||||
updates.reviewed_at = Date.now();
|
||||
}
|
||||
|
||||
const review = await updateMessageReview(req.params.id, updates);
|
||||
if (!review) {
|
||||
throw new AppError("Review not found", "REVIEW_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
res.json(review);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Moderation Actions
|
||||
// ==================
|
||||
|
||||
// GET /api/actions - List moderation actions
|
||||
router.get("/actions", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, status, cursor, limit } = req.query as {
|
||||
guildId?: string;
|
||||
status?: string;
|
||||
cursor?: string;
|
||||
limit?: string;
|
||||
};
|
||||
|
||||
const result = await listModerationActions({
|
||||
guildId,
|
||||
status: parseStatuses(status),
|
||||
cursor,
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/actions/:id - Get a specific action
|
||||
router.get("/actions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const action = await getModerationAction(req.params.id);
|
||||
if (!action) {
|
||||
throw new AppError("Action not found", "ACTION_NOT_FOUND", 404);
|
||||
}
|
||||
res.json(action);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/actions - Create a new moderation action
|
||||
router.post("/actions", async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
message_id,
|
||||
user_id,
|
||||
guild_id,
|
||||
action_type,
|
||||
reason,
|
||||
executed_by,
|
||||
} = req.body as {
|
||||
message_id?: string;
|
||||
user_id?: string;
|
||||
guild_id: string;
|
||||
action_type: string;
|
||||
reason?: string;
|
||||
executed_by?: string;
|
||||
};
|
||||
|
||||
if (!guild_id || !action_type) {
|
||||
throw new AppError(
|
||||
"guild_id and action_type are required",
|
||||
"MISSING_ACTION_FIELDS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const validTypes = [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
];
|
||||
if (!validTypes.includes(action_type)) {
|
||||
throw new AppError(
|
||||
`Invalid action_type. Must be one of: ${validTypes.join(", ")}`,
|
||||
"INVALID_ACTION_TYPE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const action = await createModerationAction({
|
||||
message_id: message_id || null,
|
||||
user_id: user_id || null,
|
||||
guild_id,
|
||||
action_type: action_type as any,
|
||||
reason: reason || null,
|
||||
executed_by: executed_by || null,
|
||||
status: "pending",
|
||||
error: null,
|
||||
executed_at: null,
|
||||
});
|
||||
|
||||
res.status(201).json(action);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/actions/:id - Update an action
|
||||
router.patch("/actions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { status, error, executed_by } = req.body as {
|
||||
status?: "pending" | "executed" | "failed";
|
||||
error?: string;
|
||||
executed_by?: string;
|
||||
};
|
||||
|
||||
const updates: Partial<ModerationAction> = {};
|
||||
if (status) {
|
||||
updates.status = status;
|
||||
if (status === "executed") {
|
||||
updates.executed_at = Date.now();
|
||||
}
|
||||
}
|
||||
if (error !== undefined) updates.error = error;
|
||||
if (executed_by !== undefined) updates.executed_by = executed_by;
|
||||
|
||||
const action = await updateModerationAction(req.params.id, updates);
|
||||
if (!action) {
|
||||
throw new AppError("Action not found", "ACTION_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
res.json(action);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Client } from "discord.js-selfbot-v13";
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import { syncSelectedChannelBacklog } from "../moderation/backlogSync.js";
|
||||
|
||||
const logger = createChildLogger("sync-routes");
|
||||
const BACKLOG_SYNC_COOLDOWN_MS = 0;
|
||||
const MAX_CONCURRENT_SYNCS = 3; // P3: cap concurrent backlogs
|
||||
|
||||
const recentBacklogSyncs = new Map<string, number>();
|
||||
let activeSyncCount = 0;
|
||||
|
||||
export function shouldSkipRecentBacklogSync(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const key = `${guildId}:${channelId}`;
|
||||
const lastSync = recentBacklogSyncs.get(key);
|
||||
if (lastSync && now - lastSync < BACKLOG_SYNC_COOLDOWN_MS) return true;
|
||||
recentBacklogSyncs.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function clearRecentBacklogSyncs(): void {
|
||||
recentBacklogSyncs.clear();
|
||||
}
|
||||
|
||||
export function createSyncRoutes(client: Client): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/backlog-sync - Sync message backlog for a channel
|
||||
router.post("/backlog-sync", async (req, res, next) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
if (!guildId || !channelId) {
|
||||
throw new AppError(
|
||||
"guildId and channelId are required",
|
||||
"MISSING_BACKLOG_PARAMS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldSkipRecentBacklogSync(guildId, channelId)) {
|
||||
res.json({
|
||||
success: true,
|
||||
channelId,
|
||||
messagesSync: 0,
|
||||
queued: false,
|
||||
skipped: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// P3: backpressure - reject if too many concurrent syncs
|
||||
if (activeSyncCount >= MAX_CONCURRENT_SYNCS) {
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: "TOO_MANY_SYNCS",
|
||||
message: `Too many backlog syncs in progress (${activeSyncCount}/${MAX_CONCURRENT_SYNCS}). Try again later.`,
|
||||
activeSyncCount,
|
||||
maxConcurrentSyncs: MAX_CONCURRENT_SYNCS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
activeSyncCount++;
|
||||
syncSelectedChannelBacklog(client, guildId, channelId)
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Backlog sync failed",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
activeSyncCount--;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
channelId,
|
||||
messagesSync: 0,
|
||||
queued: true,
|
||||
skipped: false,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/backlog-sync/status - Get current backlog sync status
|
||||
router.get("/backlog-sync/status", (_req, res) => {
|
||||
res.json({
|
||||
activeSyncCount,
|
||||
maxConcurrentSyncs: MAX_CONCURRENT_SYNCS,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import type { SharedUIState, SharedUIStatePatch } from "../state/uiState.js";
|
||||
|
||||
export { SharedUIState, SharedUIStatePatch };
|
||||
|
||||
export interface UIStateRouteOptions {
|
||||
getSharedUIState: () => SharedUIState;
|
||||
patchSharedUIState: (
|
||||
patch: SharedUIStatePatch,
|
||||
) => Promise<SharedUIState> | SharedUIState;
|
||||
}
|
||||
|
||||
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||
const router = express.Router();
|
||||
const { getSharedUIState, patchSharedUIState } = options;
|
||||
|
||||
// GET /api/ui-state - Get current UI state
|
||||
router.get("/ui-state", (_req, res, next) => {
|
||||
try {
|
||||
const state = getSharedUIState();
|
||||
res.json(state);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/ui-state - Update UI state
|
||||
router.post("/ui-state", async (req, res, next) => {
|
||||
try {
|
||||
const patch = req.body as SharedUIStatePatch;
|
||||
const updated = await patchSharedUIState(patch);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { NextFunction, Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors.js";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import type { ModerationBroadcaster } from "../moderation/broadcaster.js";
|
||||
import type { VoiceController } from "../voiceController.js";
|
||||
import type { SharedUIState } from "./uiStateRoutes.js";
|
||||
|
||||
const logger = createChildLogger("voice-routes");
|
||||
|
||||
export interface VoiceRouteOptions {
|
||||
voiceController: VoiceController;
|
||||
patchSharedUIState: (
|
||||
patch: Partial<SharedUIState>,
|
||||
) => Promise<SharedUIState> | SharedUIState;
|
||||
broadcaster: ModerationBroadcaster;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export function createVoiceRoutes(
|
||||
options: VoiceRouteOptions | VoiceController,
|
||||
): Router {
|
||||
const router = express.Router();
|
||||
|
||||
// Support both old signature (VoiceController) and new signature (options object)
|
||||
let voiceController: VoiceController;
|
||||
let patchSharedUIState:
|
||||
| ((
|
||||
patch: Partial<SharedUIState>,
|
||||
) => Promise<SharedUIState> | SharedUIState)
|
||||
| undefined;
|
||||
let broadcaster: ModerationBroadcaster | undefined;
|
||||
let adminPassword: string | undefined;
|
||||
|
||||
if ("connect" in options && "disconnect" in options) {
|
||||
// Old signature: just VoiceController
|
||||
voiceController = options as VoiceController;
|
||||
} else {
|
||||
// New signature: options object
|
||||
const opts = options as VoiceRouteOptions;
|
||||
voiceController = opts.voiceController;
|
||||
patchSharedUIState = opts.patchSharedUIState;
|
||||
broadcaster = opts.broadcaster;
|
||||
adminPassword = opts.adminPassword;
|
||||
}
|
||||
|
||||
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!adminPassword) return next();
|
||||
const authHeader = req.headers["x-admin-password"];
|
||||
if (authHeader === adminPassword) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: "Unauthorized access to admin features" });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /api/status - Get voice connection status
|
||||
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = voiceController.getStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/guilds - List available guilds
|
||||
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const guilds = voiceController.listGuilds();
|
||||
res.json(guilds);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
||||
router.get(
|
||||
"/guilds/:guildId/voice-channels",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listVoiceChannels(
|
||||
guildId as string,
|
||||
);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
||||
router.get(
|
||||
"/guilds/:guildId/channels",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listWatchableChannels(
|
||||
guildId as string,
|
||||
);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/connect - Connect to a voice channel
|
||||
router.post(
|
||||
"/connect",
|
||||
adminAuth,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
if (!guildId || !channelId) {
|
||||
throw new AppError(
|
||||
"guildId and channelId are required",
|
||||
"MISSING_CONNECT_FIELDS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ guildId, channelId }, "Connecting to voice channel");
|
||||
|
||||
const status = await voiceController.connect(guildId, channelId);
|
||||
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = await patchSharedUIState({
|
||||
selectedVoiceGuild: guildId,
|
||||
selectedVoiceChannel: channelId,
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/disconnect - Disconnect from voice channel
|
||||
router.post(
|
||||
"/disconnect",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
logger.info("Disconnecting from voice channel");
|
||||
|
||||
const status = await voiceController.disconnect();
|
||||
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = await patchSharedUIState({
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { getPersistedValue, setPersistedValue } from "../muxer-queue.js";
|
||||
|
||||
export interface MediaSettings {
|
||||
musicVolume: number;
|
||||
}
|
||||
|
||||
export const defaultMediaSettings: MediaSettings = {
|
||||
musicVolume: 1,
|
||||
};
|
||||
|
||||
export async function initializeMediaSettings(): Promise<MediaSettings> {
|
||||
const stored = await getPersistedValue(
|
||||
"media-settings",
|
||||
defaultMediaSettings,
|
||||
);
|
||||
return {
|
||||
...defaultMediaSettings,
|
||||
...(stored as MediaSettings),
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistMediaSettings(
|
||||
settings: MediaSettings,
|
||||
): Promise<void> {
|
||||
await setPersistedValue("media-settings", settings);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { getPersistedValue, setPersistedValue } from "../muxer-queue.js";
|
||||
|
||||
export type ActiveTab =
|
||||
| "voice"
|
||||
| "messages"
|
||||
| "media"
|
||||
| "review"
|
||||
| "recordings"
|
||||
| "analytics";
|
||||
|
||||
export interface SharedUIState {
|
||||
selectedVoiceGuild: string;
|
||||
selectedVoiceChannel: string;
|
||||
selectedTextGuild: string;
|
||||
selectedTextChannel: string;
|
||||
activeTab: ActiveTab;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||
selectedGuild?: string;
|
||||
};
|
||||
|
||||
const activeTabs: ActiveTab[] = [
|
||||
"voice",
|
||||
"messages",
|
||||
"media",
|
||||
"review",
|
||||
"recordings",
|
||||
"analytics",
|
||||
];
|
||||
|
||||
export const defaultSharedUIState: SharedUIState = {
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
selectedTextGuild: "",
|
||||
selectedTextChannel: "",
|
||||
activeTab: "voice",
|
||||
isListening: false,
|
||||
isStreaming: false,
|
||||
};
|
||||
|
||||
export function normalizeSharedUIState(
|
||||
value: SharedUIStatePatch,
|
||||
): SharedUIState {
|
||||
const guild = value.selectedGuild ?? "";
|
||||
return {
|
||||
selectedVoiceGuild: value.selectedVoiceGuild ?? guild,
|
||||
selectedVoiceChannel: value.selectedVoiceChannel ?? "",
|
||||
selectedTextGuild: value.selectedTextGuild ?? guild,
|
||||
selectedTextChannel: value.selectedTextChannel ?? "",
|
||||
activeTab: activeTabs.includes(value.activeTab as ActiveTab)
|
||||
? (value.activeTab as ActiveTab)
|
||||
: "voice",
|
||||
isListening: value.isListening ?? false,
|
||||
isStreaming: value.isStreaming ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSharedUIStateStore() {
|
||||
let sharedUIState = normalizeSharedUIState(
|
||||
await getPersistedValue("web-ui-state", defaultSharedUIState),
|
||||
);
|
||||
|
||||
function getSharedUIState(): SharedUIState {
|
||||
return { ...sharedUIState };
|
||||
}
|
||||
|
||||
async function patchSharedUIState(
|
||||
patch: SharedUIStatePatch,
|
||||
): Promise<SharedUIState> {
|
||||
if (typeof patch.selectedGuild === "string") {
|
||||
sharedUIState.selectedVoiceGuild = patch.selectedGuild;
|
||||
sharedUIState.selectedTextGuild = patch.selectedGuild;
|
||||
}
|
||||
if (typeof patch.selectedVoiceGuild === "string") {
|
||||
sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild;
|
||||
}
|
||||
if (typeof patch.selectedVoiceChannel === "string") {
|
||||
sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel;
|
||||
}
|
||||
if (typeof patch.selectedTextGuild === "string") {
|
||||
sharedUIState.selectedTextGuild = patch.selectedTextGuild;
|
||||
}
|
||||
if (typeof patch.selectedTextChannel === "string") {
|
||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||
}
|
||||
if (activeTabs.includes(patch.activeTab as ActiveTab)) {
|
||||
sharedUIState.activeTab = patch.activeTab as ActiveTab;
|
||||
}
|
||||
if (typeof patch.isListening === "boolean") {
|
||||
sharedUIState.isListening = patch.isListening;
|
||||
}
|
||||
if (typeof patch.isStreaming === "boolean") {
|
||||
sharedUIState.isStreaming = patch.isStreaming;
|
||||
}
|
||||
|
||||
await setPersistedValue("web-ui-state", sharedUIState);
|
||||
return getSharedUIState();
|
||||
}
|
||||
|
||||
return { getSharedUIState, patchSharedUIState };
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Readable } from "node:stream";
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
Streamer as DankStreamer,
|
||||
playStream as dankPlayStream,
|
||||
prepareStream as dankPrepareStream,
|
||||
Encoders,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
|
||||
type VoiceConnectionLike = any;
|
||||
type StreamConnectionLike = any;
|
||||
|
||||
export interface StreamPlayOptions {
|
||||
fps?: number;
|
||||
bitrate?: number | string;
|
||||
includeAudio?: boolean;
|
||||
presetH26x?: string;
|
||||
}
|
||||
|
||||
export interface StreamSession {
|
||||
connection: VoiceConnectionLike;
|
||||
stream: StreamConnectionLike;
|
||||
play(source: string | Readable, options?: StreamPlayOptions): Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export const UtilsAPI = {
|
||||
normalizeVideoCodec: (c: string) => c.toUpperCase?.() ?? c,
|
||||
};
|
||||
|
||||
export class Streamer {
|
||||
client: Client;
|
||||
dankStreamer: DankStreamer;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
this.dankStreamer = new DankStreamer(client);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<StreamSession> {
|
||||
await this.dankStreamer.joinVoice(guildId, channelId);
|
||||
|
||||
let stopped = false;
|
||||
let currentCommand: any = null;
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
if (currentCommand?.kill) currentCommand.kill("SIGKILL");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
this.dankStreamer.stopStream();
|
||||
this.dankStreamer.leaveVoice();
|
||||
};
|
||||
|
||||
return {
|
||||
connection: {} as any,
|
||||
stream: {} as any,
|
||||
play: async (
|
||||
source: string | Readable,
|
||||
options: StreamPlayOptions = {},
|
||||
) => {
|
||||
if (stopped) return;
|
||||
|
||||
let targetSource: string | Readable = source;
|
||||
if (typeof source === "string" && source.includes("\n")) {
|
||||
const urls = source.split("\n").filter((u) => u.trim());
|
||||
targetSource = urls[0] ?? source;
|
||||
}
|
||||
|
||||
const fps = options.fps ?? 60;
|
||||
const bitrateStr = String(options.bitrate ?? 8000).replace(/k$/i, "");
|
||||
const bitrateVideo = parseInt(bitrateStr, 10) || 8000;
|
||||
|
||||
console.log(
|
||||
"[Streamer] Starting screen share for source:",
|
||||
typeof targetSource === "string"
|
||||
? targetSource.slice(0, 50) + "..."
|
||||
: "ReadableStream",
|
||||
);
|
||||
const { command, output } = dankPrepareStream(targetSource, {
|
||||
encoder: Encoders.software({
|
||||
x264: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||
x265: { preset: (options.presetH26x as any) ?? "ultrafast" },
|
||||
}),
|
||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
bitrateVideo: bitrateVideo,
|
||||
frameRate: fps,
|
||||
includeAudio: options.includeAudio !== false,
|
||||
minimizeLatency: false,
|
||||
customInputOptions: ["-fflags nobuffer"],
|
||||
customHeaders: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
|
||||
currentCommand = command;
|
||||
|
||||
const webOutput = new PassThrough();
|
||||
const discordOutput = new PassThrough();
|
||||
|
||||
output.pipe(webOutput);
|
||||
output.pipe(discordOutput);
|
||||
|
||||
const globalAny: any = globalThis;
|
||||
const onData = (chunk: Buffer) => {
|
||||
try {
|
||||
globalAny.broadcastVideoToWeb?.(chunk);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
webOutput.on("data", onData);
|
||||
|
||||
command.on("error", (err: Error) => {
|
||||
console.error("[Streamer] Transcoder error:", err);
|
||||
});
|
||||
command.on("stderr", (stderrLine: string) => {
|
||||
console.error("[Streamer] FFMPEG:", stderrLine);
|
||||
});
|
||||
command.on("end", () => {
|
||||
console.log("[Streamer] FFMPEG process ended naturally.");
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("[Streamer] Calling dankPlayStream...");
|
||||
await dankPlayStream(discordOutput, this.dankStreamer, undefined);
|
||||
console.log("[Streamer] dankPlayStream completed successfully.");
|
||||
} catch (err) {
|
||||
console.error("[Streamer] dankPlayStream error:", err);
|
||||
} finally {
|
||||
console.log("[Streamer] Cleaning up stream resources.");
|
||||
webOutput.off("data", onData);
|
||||
stop();
|
||||
}
|
||||
},
|
||||
stop,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareStream(source: string, _options: any): any {
|
||||
return { command: null, output: new PassThrough() };
|
||||
}
|
||||
|
||||
export async function playStream(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function createStreamSession(
|
||||
client: Client,
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<StreamSession> {
|
||||
return new Streamer(client).createSession(guildId, channelId);
|
||||
}
|
||||
|
||||
export async function playPreparedStream(
|
||||
source: string | Readable,
|
||||
session: StreamSession,
|
||||
options: StreamPlayOptions = {},
|
||||
): Promise<void> {
|
||||
await session.play(source, options);
|
||||
}
|
||||
|
||||
export async function playTranscodedPreparedStream(
|
||||
source: string | Readable,
|
||||
session: StreamSession,
|
||||
options: StreamPlayOptions = {},
|
||||
): Promise<void> {
|
||||
await session.play(source, options);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { ChildProcess, spawn } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { createChildLogger } from "../logger.js";
|
||||
import {
|
||||
transcoderRestartsCounter,
|
||||
transcoderRunningGauge,
|
||||
} from "../metrics.js";
|
||||
import { retryWithBackoff } from "../retry.js";
|
||||
|
||||
const logger = createChildLogger("transcoder");
|
||||
|
||||
export interface TranscoderOptions {
|
||||
fps?: number;
|
||||
bitrate?: string | number;
|
||||
preset?: string;
|
||||
}
|
||||
|
||||
export class Transcoder {
|
||||
proc: ChildProcess | null = null;
|
||||
output: Readable | null = null;
|
||||
stopping = false;
|
||||
restartAttempts = 0;
|
||||
restartTimer: NodeJS.Timeout | null = null;
|
||||
maxRestarts = 6;
|
||||
|
||||
constructor(
|
||||
private source: string,
|
||||
private opts: TranscoderOptions = {},
|
||||
) {}
|
||||
|
||||
start(): { command: ChildProcess; output: Readable } {
|
||||
const fps = this.opts.fps ?? 30;
|
||||
const bitrate = String(this.opts.bitrate ?? "2500k");
|
||||
const preset = this.opts.preset ?? "superfast";
|
||||
|
||||
const args = ["-hide_banner", "-loglevel", "warning"];
|
||||
|
||||
if (
|
||||
this.source.startsWith("http://") ||
|
||||
this.source.startsWith("https://")
|
||||
) {
|
||||
args.push(
|
||||
"-user_agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
|
||||
);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"-i",
|
||||
this.source,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
preset,
|
||||
"-r",
|
||||
String(fps),
|
||||
"-s",
|
||||
"1280x720",
|
||||
"-b:v",
|
||||
String(bitrate),
|
||||
"-maxrate",
|
||||
"4000k",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-f",
|
||||
"matroska",
|
||||
"-",
|
||||
);
|
||||
|
||||
const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const out = cmd.stdout ?? new PassThrough();
|
||||
|
||||
this.proc = cmd;
|
||||
this.output = out;
|
||||
|
||||
cmd.on("error", (err) => {
|
||||
logger.error({ err }, "transcoder process error");
|
||||
});
|
||||
cmd.on("exit", (code, signal) => {
|
||||
logger.info({ code, signal }, "transcoder exited");
|
||||
transcoderRunningGauge.set(0);
|
||||
// If we didn't explicitly stop, attempt restart with backoff
|
||||
if (!this.stopping) {
|
||||
this.scheduleRestart();
|
||||
}
|
||||
});
|
||||
|
||||
transcoderRunningGauge.set(1);
|
||||
|
||||
return { command: cmd, output: out };
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopping = true;
|
||||
if (this.restartTimer) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
try {
|
||||
if (this.proc && !this.proc.killed) this.proc.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
logger.warn({ e }, "failed to terminate transcoder gracefully");
|
||||
try {
|
||||
if (this.proc && !this.proc.killed) this.proc.kill("SIGKILL");
|
||||
} catch (e2) {
|
||||
logger.warn({ e2 }, "failed to kill transcoder forcefully");
|
||||
}
|
||||
}
|
||||
this.proc = null;
|
||||
this.output = null;
|
||||
transcoderRunningGauge.set(0);
|
||||
}
|
||||
|
||||
scheduleRestart() {
|
||||
if (this.restartAttempts >= this.maxRestarts) {
|
||||
logger.error(
|
||||
{ attempts: this.restartAttempts },
|
||||
"transcoder reached max restart attempts",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(30000, 1000 * Math.pow(2, this.restartAttempts));
|
||||
this.restartAttempts += 1;
|
||||
transcoderRestartsCounter.inc();
|
||||
logger.info(
|
||||
{ delay, attempt: this.restartAttempts },
|
||||
"scheduling transcoder restart",
|
||||
);
|
||||
this.restartTimer = setTimeout(() => {
|
||||
try {
|
||||
this.start();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "transcoder restart failed");
|
||||
this.scheduleRestart();
|
||||
}
|
||||
}, delay) as unknown as NodeJS.Timeout;
|
||||
}
|
||||
|
||||
async startWithRetry(retries = 2) {
|
||||
return retryWithBackoff(() => Promise.resolve(this.start()), {
|
||||
retries,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.stopping = true;
|
||||
if (this.restartTimer) {
|
||||
clearTimeout(this.restartTimer);
|
||||
this.restartTimer = null;
|
||||
}
|
||||
if (this.proc && !this.proc.killed) {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.proc?.once("exit", () => resolve());
|
||||
try {
|
||||
this.proc?.kill("SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
this.proc?.kill("SIGKILL");
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
setTimeout(() => resolve(), 5000);
|
||||
}).then(() => {
|
||||
this.proc = null;
|
||||
this.output = null;
|
||||
transcoderRunningGauge.set(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareTranscoder(
|
||||
source: string,
|
||||
options: TranscoderOptions = {},
|
||||
) {
|
||||
const t = new Transcoder(source, options);
|
||||
const { command, output } = t.start();
|
||||
return { transcoder: t, command, output };
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type fs from "node:fs";
|
||||
import type prism from "prism-media";
|
||||
|
||||
export interface RoleMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface UserMetadata {
|
||||
userId: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bot: boolean;
|
||||
roles: RoleMetadata[];
|
||||
highestRole: RoleMetadata | null;
|
||||
joinedTimestamp: number | null;
|
||||
}
|
||||
|
||||
export interface SegmentState {
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number | null;
|
||||
filename: string;
|
||||
jsonFilename: string;
|
||||
oggStream: prism.opus.OggLogicalBitstream;
|
||||
out: fs.WriteStream;
|
||||
}
|
||||
|
||||
export interface SegmentMetadata extends UserMetadata {
|
||||
recordingSessionId: string;
|
||||
sessionId: string;
|
||||
sessionStartTime: number;
|
||||
segmentIndex: number;
|
||||
segmentMs: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
durationMs: number;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface PcmBroadcaster {
|
||||
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||
updateActiveUser?: (
|
||||
userId: string,
|
||||
data: { username: string; avatar: string; speaking: boolean },
|
||||
) => void;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { CustomLogger } from "../logger.js";
|
||||
import { retryWithBackoff } from "../retry.js";
|
||||
|
||||
export interface TeleUploadResponse {
|
||||
download_url: string;
|
||||
public_id?: string;
|
||||
file_name?: string;
|
||||
size_bytes?: number;
|
||||
}
|
||||
|
||||
export interface TeleUploadResult {
|
||||
url: string;
|
||||
publicId?: string;
|
||||
filename?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export function parseTeleUploadResponse(
|
||||
response: TeleUploadResponse,
|
||||
): TeleUploadResult {
|
||||
if (!response.download_url) {
|
||||
throw new Error("Missing download_url in response");
|
||||
}
|
||||
|
||||
return {
|
||||
url: response.download_url,
|
||||
publicId: response.public_id,
|
||||
filename: response.file_name,
|
||||
sizeBytes: response.size_bytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadToTele(input: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
uploadUrl: string;
|
||||
timeoutMs?: number;
|
||||
retries: number;
|
||||
logger: CustomLogger;
|
||||
}): Promise<TeleUploadResult> {
|
||||
const {
|
||||
buffer,
|
||||
filename,
|
||||
contentType,
|
||||
uploadUrl,
|
||||
timeoutMs,
|
||||
retries,
|
||||
logger,
|
||||
} = input;
|
||||
|
||||
const response = await retryWithBackoff(
|
||||
async () => {
|
||||
const fileBlob = new Blob([new Uint8Array(buffer)], {
|
||||
type: contentType,
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileBlob, filename);
|
||||
formData.append("fileName", filename);
|
||||
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
body: formData,
|
||||
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: Status ${res.status}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as TeleUploadResponse;
|
||||
},
|
||||
{
|
||||
retries,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
return parseTeleUploadResponse(response);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const userStateUpdateSchema = z.object({
|
||||
userId: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string(),
|
||||
speaking: z.boolean(),
|
||||
});
|
||||
|
||||
export type UserStateUpdate = z.infer<typeof userStateUpdateSchema>;
|
||||
|
||||
export interface AudioMessage {
|
||||
data: Buffer;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export async function validateUserStateUpdate(
|
||||
data: unknown,
|
||||
): Promise<UserStateUpdate | null> {
|
||||
const result = userStateUpdateSchema.safeParse(data);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
|
||||
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
|
||||
import { AppError } from "./errors.js";
|
||||
import { createChildLogger } from "./logger.js";
|
||||
import { discordPlayer } from "./player.js";
|
||||
import { startRecording, stopRecording } from "./recorder.js";
|
||||
|
||||
const logger = createChildLogger("voice-controller");
|
||||
|
||||
export interface VoiceStatus {
|
||||
ready: boolean;
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
activeChannelName: string | null;
|
||||
}
|
||||
|
||||
export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VoiceChannelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ChannelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class VoiceController {
|
||||
private activeGuildId: string | null = null;
|
||||
private activeChannelId: string | null = null;
|
||||
private activeChannelName: string | null = null;
|
||||
private connecting = false;
|
||||
|
||||
constructor(private readonly client: Client) {}
|
||||
|
||||
getStatus(): VoiceStatus {
|
||||
const connection = this.activeGuildId
|
||||
? getVoiceConnection(this.activeGuildId)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ready: this.client.isReady(),
|
||||
connected: Boolean(connection),
|
||||
activeGuildId: this.activeGuildId,
|
||||
activeChannelId: this.activeChannelId,
|
||||
activeChannelName: this.activeChannelName,
|
||||
};
|
||||
}
|
||||
|
||||
listGuilds(): GuildSummary[] {
|
||||
return this.client.guilds.cache
|
||||
.map((guild) => ({ id: guild.id, name: guild.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async listVoiceChannels(guildId: string): Promise<VoiceChannelSummary[]> {
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
return guild.channels.cache
|
||||
.filter((channel) => channel.type === "GUILD_VOICE")
|
||||
.map((channel) => ({ id: channel.id, name: channel.name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async listWatchableChannels(guildId: string): Promise<ChannelSummary[]> {
|
||||
const guild = this.getGuild(guildId);
|
||||
await guild.channels.fetch().catch(() => null);
|
||||
|
||||
return guild.channels.cache
|
||||
.filter((channel) => channel.type === "GUILD_TEXT")
|
||||
.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
|
||||
if (!this.client.isReady()) {
|
||||
throw new AppError(
|
||||
"Discord client is not ready",
|
||||
"CLIENT_NOT_READY",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
throw new AppError(
|
||||
"Voice connection is already in progress",
|
||||
"CONNECT_IN_PROGRESS",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
this.connecting = true;
|
||||
|
||||
try {
|
||||
await this.disconnect();
|
||||
|
||||
const guild = this.getGuild(guildId);
|
||||
const channel =
|
||||
guild.channels.cache.get(channelId) ??
|
||||
(await guild.channels.fetch(channelId).catch(() => null));
|
||||
|
||||
if (!channel) {
|
||||
throw new AppError(
|
||||
"Voice channel not found",
|
||||
"VOICE_CHANNEL_NOT_FOUND",
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
if (channel.type !== "GUILD_VOICE") {
|
||||
throw new AppError(
|
||||
"Selected channel is not a voice channel",
|
||||
"INVALID_CHANNEL_TYPE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const connection = await startRecording(
|
||||
this.client,
|
||||
channel as VoiceChannel,
|
||||
);
|
||||
if (!connection) {
|
||||
throw new AppError(
|
||||
"Failed to connect to voice channel",
|
||||
"VOICE_CONNECT_FAILED",
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
discordPlayer.setConnection(connection as VoiceConnection);
|
||||
this.activeGuildId = guildId;
|
||||
this.activeChannelId = channelId;
|
||||
this.activeChannelName = channel.name;
|
||||
|
||||
logger.info(
|
||||
{ guildId, channelId, channelName: channel.name },
|
||||
"Voice connected",
|
||||
);
|
||||
|
||||
return this.getStatus();
|
||||
} finally {
|
||||
this.connecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<VoiceStatus> {
|
||||
if (this.activeGuildId) {
|
||||
stopRecording(this.activeGuildId);
|
||||
}
|
||||
|
||||
discordPlayer.stop();
|
||||
this.activeGuildId = null;
|
||||
this.activeChannelId = null;
|
||||
this.activeChannelName = null;
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
private getGuild(guildId: string): Guild {
|
||||
const guild = this.client.guilds.cache.get(guildId);
|
||||
|
||||
if (!guild) {
|
||||
throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404);
|
||||
}
|
||||
|
||||
return guild;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { startWebserver } from "./http/server.js";
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { createChildLogger } from "../logger.js";
|
||||
import type {
|
||||
BroadcasterClient,
|
||||
ModerationBroadcaster,
|
||||
} from "../moderation/types.js";
|
||||
|
||||
type Logger = ReturnType<typeof createChildLogger>;
|
||||
|
||||
type ActiveUsers = Map<
|
||||
string,
|
||||
{ username: string; avatar: string; speaking: boolean }
|
||||
>;
|
||||
|
||||
type VoiceGlobals = typeof globalThis & {
|
||||
ADMIN_PASSWORD?: string;
|
||||
moderationBroadcaster?: ModerationBroadcaster;
|
||||
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||
broadcastVideoToWeb?: (chunk: Buffer) => void;
|
||||
updateActiveUser?: (
|
||||
userId: string,
|
||||
data: { username: string; avatar: string; speaking: boolean },
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
||||
return (globalThis as VoiceGlobals).moderationBroadcaster;
|
||||
}
|
||||
|
||||
export function exposeModerationGlobals(
|
||||
broadcaster: ModerationBroadcaster,
|
||||
adminPassword: string,
|
||||
): void {
|
||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||
(globalThis as VoiceGlobals).ADMIN_PASSWORD = adminPassword;
|
||||
}
|
||||
|
||||
export function exposePcmBroadcastGlobal(
|
||||
broadcaster: ModerationBroadcaster,
|
||||
): void {
|
||||
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
||||
chunk: Buffer,
|
||||
userId: string,
|
||||
) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeInt32LE(hash, 0);
|
||||
const packet = Buffer.concat([header, chunk]);
|
||||
|
||||
for (const client of broadcaster.getClients()) {
|
||||
if (client.readyState === 1) client.send(packet);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function exposeVideoBroadcastGlobal(
|
||||
clients: () => BroadcasterClient[],
|
||||
logger: Logger,
|
||||
): void {
|
||||
(globalThis as VoiceGlobals).broadcastVideoToWeb = (chunk: Buffer) => {
|
||||
for (const client of clients()) {
|
||||
if (client.readyState === 1) {
|
||||
try {
|
||||
client.send(chunk);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send video chunk");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function exposeActiveUserGlobal(
|
||||
activeUsers: ActiveUsers,
|
||||
broadcastUserState: () => void,
|
||||
): void {
|
||||
(globalThis as VoiceGlobals).updateActiveUser = (
|
||||
userId: string,
|
||||
data: { username: string; avatar: string; speaking: boolean },
|
||||
) => {
|
||||
activeUsers.set(userId, data);
|
||||
broadcastUserState();
|
||||
};
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import type { createChildLogger } from "../logger.js";
|
||||
import type { MediaController } from "../media/mediaController.js";
|
||||
import type { ModerationBroadcaster } from "../moderation/types.js";
|
||||
import { createVoiceAudioBridge } from "./voiceAudioBridge.js";
|
||||
|
||||
type Logger = ReturnType<typeof createChildLogger>;
|
||||
|
||||
type ActiveUsers = Map<
|
||||
string,
|
||||
{ username: string; avatar: string; speaking: boolean }
|
||||
>;
|
||||
|
||||
export interface WebSocketServerOptions {
|
||||
server: HttpServer;
|
||||
port: number;
|
||||
wsPath: string;
|
||||
broadcaster: ModerationBroadcaster;
|
||||
activeUsers: ActiveUsers;
|
||||
getSharedUIState: () => unknown;
|
||||
mediaController: MediaController;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export function startWebSocketServer(options: WebSocketServerOptions) {
|
||||
const wss = new WebSocketServer({
|
||||
server: options.server,
|
||||
path: options.wsPath,
|
||||
});
|
||||
const voiceAudioBridge = createVoiceAudioBridge(options.logger);
|
||||
|
||||
options.logger.info(
|
||||
{ port: options.port, wsPath: options.wsPath },
|
||||
"WebSocket server listening",
|
||||
);
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
options.logger.info(
|
||||
{ port: options.port, wsPath: options.wsPath },
|
||||
"New WebSocket connection",
|
||||
);
|
||||
options.broadcaster.addClient(ws);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "user_state",
|
||||
users: Array.from(options.activeUsers.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
...data,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "ui_state", state: options.getSharedUIState() }),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "media_state",
|
||||
state: options.mediaController.getState(),
|
||||
}),
|
||||
);
|
||||
|
||||
ws.on("message", (data: Buffer | ArrayBuffer | Buffer[]) => {
|
||||
if (!Buffer.isBuffer(data)) return;
|
||||
voiceAudioBridge.handleBrowserAudio(data);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
options.broadcaster.removeClient(ws);
|
||||
});
|
||||
ws.on("error", () => {
|
||||
options.broadcaster.removeClient(ws);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||
import * as prism from "prism-media";
|
||||
import { rmsDb, upsample24kMonoTo48kStereo } from "../audio/pcm.js";
|
||||
import type { createChildLogger } from "../logger.js";
|
||||
import { discordPlayer } from "../player.js";
|
||||
|
||||
type Logger = ReturnType<typeof createChildLogger>;
|
||||
|
||||
const RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
const FRAME_SIZE = 960;
|
||||
const BYTES_PER_FRAME = FRAME_SIZE * CHANNELS * 2;
|
||||
const SILENCE_TAIL_MS = 300;
|
||||
const MAX_BUF_BYTES = BYTES_PER_FRAME * 50;
|
||||
const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0);
|
||||
|
||||
export interface VoiceAudioBridge {
|
||||
handleBrowserAudio(data: Buffer): void;
|
||||
}
|
||||
|
||||
export function createVoiceAudioBridge(logger: Logger): VoiceAudioBridge {
|
||||
let opusEncoder: prism.opus.Encoder | null = null;
|
||||
let bridgePlayerPaused = true;
|
||||
let pcmBuffer = Buffer.alloc(0);
|
||||
let lastBrowserAudioTime = 0;
|
||||
let dbAccum = 0;
|
||||
let dbCount = 0;
|
||||
|
||||
function startBrowserAudioBridge(): void {
|
||||
if (opusEncoder) return;
|
||||
|
||||
opusEncoder = new prism.opus.Encoder({
|
||||
rate: RATE,
|
||||
channels: CHANNELS,
|
||||
frameSize: FRAME_SIZE,
|
||||
});
|
||||
const oggBitstream = new prism.opus.OggLogicalBitstream({
|
||||
opusHead: new prism.opus.OpusHead({
|
||||
channelCount: CHANNELS,
|
||||
sampleRate: RATE,
|
||||
}),
|
||||
pageSizeControl: { maxPackets: 1 },
|
||||
crc: true,
|
||||
});
|
||||
|
||||
opusEncoder.on("error", () => {});
|
||||
opusEncoder.pipe(oggBitstream);
|
||||
opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0));
|
||||
discordPlayer.playStream(oggBitstream, "browser-bridge");
|
||||
discordPlayer.pause("browser-bridge");
|
||||
bridgePlayerPaused = true;
|
||||
}
|
||||
|
||||
function ensureBrowserAudioBridge(): boolean {
|
||||
const owner = discordPlayer.getOwner();
|
||||
if (owner !== "none" && owner !== "browser-bridge") return false;
|
||||
if (
|
||||
owner === "none" ||
|
||||
discordPlayer.getStatus() === AudioPlayerStatus.Idle
|
||||
) {
|
||||
startBrowserAudioBridge();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (dbCount > 0) {
|
||||
const avg = dbAccum / dbCount;
|
||||
logger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level");
|
||||
dbAccum = 0;
|
||||
dbCount = 0;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
setInterval(() => {
|
||||
const msSinceAudio = Date.now() - lastBrowserAudioTime;
|
||||
let frame: Buffer | null = null;
|
||||
|
||||
if (pcmBuffer.length >= BYTES_PER_FRAME) {
|
||||
frame = pcmBuffer.subarray(0, BYTES_PER_FRAME);
|
||||
pcmBuffer = pcmBuffer.subarray(BYTES_PER_FRAME);
|
||||
dbAccum += rmsDb(frame);
|
||||
dbCount++;
|
||||
|
||||
if (!ensureBrowserAudioBridge()) {
|
||||
pcmBuffer = Buffer.alloc(0);
|
||||
return;
|
||||
}
|
||||
if (bridgePlayerPaused) {
|
||||
const unpaused = discordPlayer.unpause("browser-bridge");
|
||||
bridgePlayerPaused = false;
|
||||
logger.info({ unpaused }, "Transmitting — Discord indicator ON");
|
||||
}
|
||||
} else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) {
|
||||
frame = SILENCE_FRAME;
|
||||
} else if (!bridgePlayerPaused && msSinceAudio >= SILENCE_TAIL_MS) {
|
||||
discordPlayer.pause("browser-bridge");
|
||||
bridgePlayerPaused = true;
|
||||
logger.info("Stopped — Discord indicator OFF");
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opusEncoder) return;
|
||||
const ok = opusEncoder.write(frame);
|
||||
if (!ok) {
|
||||
opusEncoder.once("drain", () => {});
|
||||
}
|
||||
}, 20);
|
||||
|
||||
return {
|
||||
handleBrowserAudio(data: Buffer): void {
|
||||
lastBrowserAudioTime = Date.now();
|
||||
const upsampled = upsample24kMonoTo48kStereo(data);
|
||||
if (pcmBuffer.length < MAX_BUF_BYTES) {
|
||||
pcmBuffer = Buffer.concat([pcmBuffer, upsampled]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./frontend/index.html", "./frontend/src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
|
||||
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
|
||||
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
|
||||
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
|
||||
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
|
||||
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildMuxFfmpegArgs, runFfmpeg } from "../../src/audio/ffmpegProcess";
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
|
||||
describe("buildMuxFfmpegArgs", () => {
|
||||
it("builds args with multiple inputs and libmp3lame codec", () => {
|
||||
const args = buildMuxFfmpegArgs({
|
||||
inputs: ["a.ogg", "b.ogg"],
|
||||
filter:
|
||||
"[0:a]adelay=0|0[pad0];[1:a]adelay=1000|1000[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||
output: "out.mp3",
|
||||
codec: "libmp3lame",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"-y",
|
||||
"-i",
|
||||
"a.ogg",
|
||||
"-i",
|
||||
"b.ogg",
|
||||
"-filter_complex",
|
||||
"[0:a]adelay=0|0[pad0];[1:a]adelay=1000|1000[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||
"-map",
|
||||
"[out]",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"out.mp3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes audio frequency and channel options when provided", () => {
|
||||
const args = buildMuxFfmpegArgs({
|
||||
inputs: ["a.ogg"],
|
||||
filter:
|
||||
"[0:a]adelay=0|0[pad0];[pad0]amix=inputs=1:dropout_transition=0[out]",
|
||||
output: "out.wav",
|
||||
codec: "pcm_s16le",
|
||||
audioFrequency: 44100,
|
||||
audioChannels: 2,
|
||||
});
|
||||
|
||||
expect(args).toContain("-ar");
|
||||
expect(args).toContain("44100");
|
||||
expect(args).toContain("-ac");
|
||||
expect(args).toContain("2");
|
||||
});
|
||||
|
||||
it("does not include -ar or -ac when audioFrequency and audioChannels are not provided", () => {
|
||||
const args = buildMuxFfmpegArgs({
|
||||
inputs: ["a.ogg"],
|
||||
filter:
|
||||
"[0:a]adelay=0|0[pad0];[pad0]amix=inputs=1:dropout_transition=0[out]",
|
||||
output: "out.mp3",
|
||||
codec: "libmp3lame",
|
||||
});
|
||||
|
||||
expect(args).not.toContain("-ar");
|
||||
expect(args).not.toContain("-ac");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFfmpeg", () => {
|
||||
it("spawns ffmpeg and resolves on exit code 0", async () => {
|
||||
const proc = new EventEmitter();
|
||||
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||
|
||||
const result = runFfmpeg(["-version"]);
|
||||
proc.emit("close", 0);
|
||||
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenCalledWith("ffmpeg", ["-version"], {
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects on non-zero exit code", async () => {
|
||||
const proc = new EventEmitter();
|
||||
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||
|
||||
const result = runFfmpeg(["-bad"]);
|
||||
proc.emit("close", 1);
|
||||
|
||||
await expect(result).rejects.toThrow("ffmpeg exited with code 1");
|
||||
});
|
||||
|
||||
it("rejects on spawn error", async () => {
|
||||
const proc = new EventEmitter();
|
||||
spawnMock.mockReturnValue(proc as ReturnType<typeof spawn>);
|
||||
|
||||
const result = runFfmpeg(["-version"]);
|
||||
proc.emit("error", new Error("spawn ffmpeg ENOENT"));
|
||||
|
||||
await expect(result).rejects.toThrow("spawn ffmpeg ENOENT");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rmsDb, upsample24kMonoTo48kStereo } from "../../src/audio/pcm";
|
||||
|
||||
describe("PCM helpers", () => {
|
||||
it("upsamples 24kHz mono s16le to 48kHz stereo s16le", () => {
|
||||
const input = Buffer.alloc(4);
|
||||
input.writeInt16LE(1000, 0);
|
||||
input.writeInt16LE(-1000, 2);
|
||||
|
||||
const output = upsample24kMonoTo48kStereo(input);
|
||||
|
||||
expect(output.length).toBe(16);
|
||||
expect(output.readInt16LE(0)).toBe(1000);
|
||||
expect(output.readInt16LE(2)).toBe(1000);
|
||||
expect(output.readInt16LE(4)).toBe(1000);
|
||||
expect(output.readInt16LE(6)).toBe(1000);
|
||||
expect(output.readInt16LE(8)).toBe(-1000);
|
||||
expect(output.readInt16LE(10)).toBe(-1000);
|
||||
expect(output.readInt16LE(12)).toBe(-1000);
|
||||
expect(output.readInt16LE(14)).toBe(-1000);
|
||||
});
|
||||
|
||||
it("calculates RMS dB for non-silent PCM", () => {
|
||||
const input = Buffer.alloc(4);
|
||||
input.writeInt16LE(32767, 0);
|
||||
input.writeInt16LE(32767, 2);
|
||||
|
||||
expect(rmsDb(input)).toBeCloseTo(0, 1);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const originalEnv = process.env;
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
it("loads required values and coerces optional values", async () => {
|
||||
// dotenv/config loads from .env when config.ts is imported, so
|
||||
// override the relevant env vars that would be inherited from there
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
GUILD_ID: undefined as unknown as string,
|
||||
VOICE_CHANNEL_ID: undefined as unknown as string,
|
||||
MONITOR_GUILD_ID: undefined as unknown as string,
|
||||
VERBOSE: "true",
|
||||
WEBSERVER_PORT: "4000",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.DISCORD_TOKEN).toBe("token");
|
||||
expect(config.GUILD_ID).toBeUndefined();
|
||||
expect(config.VOICE_CHANNEL_ID).toBeUndefined();
|
||||
expect(config.VERBOSE).toBe(true);
|
||||
expect(config.WEBSERVER_PORT).toBe(4000);
|
||||
expect(config.RECORDINGS_DIR).toBe("./recordings");
|
||||
expect(config.NODE_ENV).toBe("test");
|
||||
expect(config.TELE_UPLOAD_URL).toBe(
|
||||
"https://upload.asepharyana.tech/api/upload",
|
||||
);
|
||||
expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(500);
|
||||
expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(15000);
|
||||
expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(30000);
|
||||
expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(200);
|
||||
expect(config.AI_ANALYSIS_MAX_CONTEXT_TOKENS).toBe(8000);
|
||||
expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(20);
|
||||
expect(config.AUTO_MIGRATE_ON_STARTUP).toBe(true);
|
||||
});
|
||||
|
||||
it("coerces AI analysis tuning values", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
AI_ANALYSIS_DEBOUNCE_MS: "750",
|
||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: "20000",
|
||||
AI_ANALYSIS_ERROR_COOLDOWN_MS: "45000",
|
||||
AI_ANALYSIS_MAX_BATCH_SIZE: "40",
|
||||
AI_ANALYSIS_MAX_CONTEXT_TOKENS: "12000",
|
||||
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: "35",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.AI_ANALYSIS_DEBOUNCE_MS).toBe(750);
|
||||
expect(config.AI_ANALYSIS_RECOVERY_INTERVAL_MS).toBe(20000);
|
||||
expect(config.AI_ANALYSIS_ERROR_COOLDOWN_MS).toBe(45000);
|
||||
expect(config.AI_ANALYSIS_MAX_BATCH_SIZE).toBe(40);
|
||||
expect(config.AI_ANALYSIS_MAX_CONTEXT_TOKENS).toBe(12000);
|
||||
expect(config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT).toBe(35);
|
||||
});
|
||||
|
||||
it("derives split text and voice guild defaults from legacy config", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "legacy-text-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.TEXT_GUILD_ID).toBeUndefined();
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("legacy-text-guild");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("legacy-voice-guild");
|
||||
expect(config.VOICE_CHANNEL_ID).toBe("voice-channel");
|
||||
});
|
||||
|
||||
it("pins text capture to the monitor guild even when legacy text config is present", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
MONITOR_GUILD_ID: "monitor-guild",
|
||||
GUILD_ID: "legacy-voice-guild",
|
||||
TEXT_GUILD_ID: "text-guild",
|
||||
TEXT_CHANNEL_ID: "text-channel",
|
||||
VOICE_GUILD_ID: "voice-guild",
|
||||
VOICE_CHANNEL_ID: "voice-channel",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.EFFECTIVE_TEXT_GUILD_ID).toBe("monitor-guild");
|
||||
expect(config.TEXT_CHANNEL_ID).toBe("text-channel");
|
||||
expect(config.EFFECTIVE_VOICE_GUILD_ID).toBe("voice-guild");
|
||||
});
|
||||
|
||||
it("allows disabling startup migrations explicitly", async () => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "token",
|
||||
AUTO_MIGRATE_ON_STARTUP: "false",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
|
||||
const { loadConfig } = await import("../src/config");
|
||||
const config = loadConfig(process.env);
|
||||
|
||||
expect(config.AUTO_MIGRATE_ON_STARTUP).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { assertSafeTestDatabaseUrl } from "./helpers/testDatabase";
|
||||
|
||||
const originalEnv = process.env;
|
||||
|
||||
describe("Drizzle ORM Database", () => {
|
||||
let drizzle: typeof import("../src/database/drizzle");
|
||||
let logger: ReturnType<typeof import("../src/logger").createChildLogger>;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up environment for config loading
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
DISCORD_TOKEN: "test-token",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
if (originalEnv.TEST_DATABASE_URL) {
|
||||
process.env.DATABASE_URL = originalEnv.TEST_DATABASE_URL;
|
||||
}
|
||||
assertSafeTestDatabaseUrl();
|
||||
|
||||
// Reset modules to pick up new environment
|
||||
vi.resetModules();
|
||||
|
||||
// Import after environment is set
|
||||
await import("../src/config");
|
||||
const drizzleModule = await import("../src/database/drizzle");
|
||||
const loggerModule = await import("../src/logger");
|
||||
|
||||
drizzle = drizzleModule;
|
||||
logger = loggerModule.createChildLogger("database.test");
|
||||
|
||||
logger.info("Testing PostgreSQL database initialization");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await drizzle.closeDatabase();
|
||||
} catch (error) {
|
||||
if (logger) {
|
||||
logger.error(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Error closing database in afterAll",
|
||||
);
|
||||
}
|
||||
}
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should initialize database connection", async () => {
|
||||
const db = await drizzle.initializeDatabase();
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(db).toHaveProperty("query");
|
||||
expect(db).toHaveProperty("select");
|
||||
});
|
||||
|
||||
it("should return same instance on subsequent calls", async () => {
|
||||
const db1 = await drizzle.initializeDatabase();
|
||||
const db2 = await drizzle.initializeDatabase();
|
||||
|
||||
expect(db1).toBe(db2);
|
||||
});
|
||||
|
||||
it("should get database instance", async () => {
|
||||
await drizzle.initializeDatabase();
|
||||
const db = drizzle.getDatabase();
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(db).toHaveProperty("query");
|
||||
});
|
||||
|
||||
it("should throw error if database not initialized", async () => {
|
||||
// Reset the database state
|
||||
vi.resetModules();
|
||||
|
||||
const drizzleModule = await import("../src/database/drizzle");
|
||||
|
||||
expect(() => {
|
||||
drizzleModule.getDatabase();
|
||||
}).toThrow("Database not initialized");
|
||||
});
|
||||
|
||||
it("should close database connection", async () => {
|
||||
await drizzle.initializeDatabase();
|
||||
await drizzle.closeDatabase();
|
||||
|
||||
expect(() => {
|
||||
drizzle.getDatabase();
|
||||
}).toThrow("Database not initialized");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const migrateSpy = vi.fn();
|
||||
const initializeDatabaseSpy = vi.fn();
|
||||
const closeDatabaseSpy = vi.fn();
|
||||
const withDatabaseClientSpy = vi.fn();
|
||||
const lockQuerySpy = vi.fn();
|
||||
const unlockQuerySpy = vi.fn();
|
||||
const drizzleSpy = vi.fn();
|
||||
|
||||
vi.mock("drizzle-orm/node-postgres", () => ({
|
||||
drizzle: drizzleSpy,
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm/node-postgres/migrator", () => ({
|
||||
migrate: migrateSpy,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/config", () => ({
|
||||
config: {
|
||||
AUTO_MIGRATE_ON_STARTUP: true,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/database/drizzle", () => ({
|
||||
initializeDatabase: initializeDatabaseSpy,
|
||||
closeDatabase: closeDatabaseSpy,
|
||||
withDatabaseClient: withDatabaseClientSpy,
|
||||
}));
|
||||
|
||||
describe("runMigrations", () => {
|
||||
beforeEach(() => {
|
||||
migrateSpy.mockResolvedValue(undefined);
|
||||
initializeDatabaseSpy.mockResolvedValue({} as never);
|
||||
closeDatabaseSpy.mockResolvedValue(undefined);
|
||||
drizzleSpy.mockReturnValue({} as never);
|
||||
withDatabaseClientSpy.mockImplementation(
|
||||
async (callback: () => Promise<unknown>) =>
|
||||
callback({
|
||||
query: vi.fn().mockImplementation((sql: string) => {
|
||||
if (sql.includes("pg_advisory_lock")) {
|
||||
return lockQuerySpy(sql);
|
||||
}
|
||||
if (sql.includes("pg_advisory_unlock")) {
|
||||
return unlockQuerySpy(sql);
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
} as never),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exports the PostgreSQL migration runner", async () => {
|
||||
const { runMigrations } = await import("../../src/database/migrate");
|
||||
|
||||
expect(runMigrations).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("uses a session lock around the migration batch", async () => {
|
||||
const { runMigrations } = await import("../../src/database/migrate");
|
||||
await runMigrations();
|
||||
|
||||
expect(initializeDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(withDatabaseClientSpy).toHaveBeenCalledTimes(1);
|
||||
expect(migrateSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ migrationsFolder: "./drizzle/migrations" }),
|
||||
);
|
||||
expect(lockQuerySpy).toHaveBeenCalledWith(
|
||||
"SELECT pg_advisory_lock($1, $2)",
|
||||
);
|
||||
expect(unlockQuerySpy).toHaveBeenCalledWith(
|
||||
"SELECT pg_advisory_unlock($1, $2)",
|
||||
);
|
||||
expect(closeDatabaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...process.env,
|
||||
DISCORD_TOKEN: "token",
|
||||
NODE_ENV: "test",
|
||||
};
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("shouldEnableDefaultOpusDecoder", () => {
|
||||
it("disables default decoder on Bun when native opus is unavailable", async () => {
|
||||
const { shouldEnableDefaultOpusDecoder } = await import(
|
||||
"../src/recorder/decoder"
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldEnableDefaultOpusDecoder({ isBun: true, canLoadNativeOpus: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enables default decoder when native opus is available", async () => {
|
||||
const { shouldEnableDefaultOpusDecoder } = await import(
|
||||
"../src/recorder/decoder"
|
||||
);
|
||||
|
||||
expect(
|
||||
shouldEnableDefaultOpusDecoder({ isBun: true, canLoadNativeOpus: true }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import process from "node:process";
|
||||
import { Pool } from "pg";
|
||||
import { getDatabase, initializeDatabase } from "../../src/database/drizzle";
|
||||
|
||||
interface RunnableDatabase {
|
||||
run(sql: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
const SAFE_TEST_DATABASE_NAME = /(^|[_-])(test|testing)([_-]|$)|gmw_test/i;
|
||||
const DEFAULT_TEST_SCHEMA = "gmw_test";
|
||||
const SAFE_TEST_SCHEMA_NAME =
|
||||
/^[a-zA-Z_][a-zA-Z0-9_]*(test|testing)[a-zA-Z0-9_]*$/i;
|
||||
|
||||
function getDatabaseNameFromUrl(databaseUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(databaseUrl);
|
||||
return parsed.pathname.replace(/^\//, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredDatabaseName(): string {
|
||||
if (process.env.DATABASE_URL) {
|
||||
return getDatabaseNameFromUrl(process.env.DATABASE_URL);
|
||||
}
|
||||
return process.env.POSTGRES_DB ?? "";
|
||||
}
|
||||
|
||||
function getTestSchemaName(): string {
|
||||
const schemaName = process.env.TEST_DATABASE_SCHEMA ?? DEFAULT_TEST_SCHEMA;
|
||||
if (!SAFE_TEST_SCHEMA_NAME.test(schemaName)) {
|
||||
throw new Error(
|
||||
`Refusing to use unsafe test schema "${schemaName}". Schema name must contain "test" and use identifier-safe characters only.`,
|
||||
);
|
||||
}
|
||||
return schemaName;
|
||||
}
|
||||
|
||||
function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
async function ensureTestSchemaExists(): Promise<void> {
|
||||
assertSafeTestDatabaseUrl();
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const schemaName = getTestSchemaName();
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schemaName)}`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function configureTestSearchPath(): Promise<void> {
|
||||
const db = getTestDatabase();
|
||||
await db.run(
|
||||
`SET search_path TO ${quoteIdentifier(getTestSchemaName())}, public`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertSafeTestDatabaseUrl(): void {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
throw new Error(
|
||||
`Refusing to run destructive database test outside NODE_ENV=test (got ${process.env.NODE_ENV ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.TEST_DATABASE_URL) {
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
}
|
||||
|
||||
const databaseName = getConfiguredDatabaseName();
|
||||
const hasSafeSchema = Boolean(process.env.TEST_DATABASE_SCHEMA);
|
||||
if (!SAFE_TEST_DATABASE_NAME.test(databaseName) && !hasSafeSchema) {
|
||||
throw new Error(
|
||||
`Refusing to run destructive database test against non-test database "${databaseName || "unknown"}" without TEST_DATABASE_SCHEMA. Set TEST_DATABASE_SCHEMA to a safe test schema name or use a database whose name contains "test" (for example hub_test).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSafeSchema) {
|
||||
getTestSchemaName();
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeTestDatabase() {
|
||||
assertSafeTestDatabaseUrl();
|
||||
await ensureTestSchemaExists();
|
||||
const database = await initializeDatabase();
|
||||
await configureTestSearchPath();
|
||||
return database;
|
||||
}
|
||||
|
||||
export function getTestDatabase(): RunnableDatabase {
|
||||
assertSafeTestDatabaseUrl();
|
||||
return getDatabase() as unknown as RunnableDatabase;
|
||||
}
|
||||
|
||||
export async function clearTestTables(...tableNames: string[]): Promise<void> {
|
||||
assertSafeTestDatabaseUrl();
|
||||
const db = getTestDatabase();
|
||||
for (const tableName of tableNames) {
|
||||
await db.run(`DELETE FROM "${tableName}"`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user