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 one or more lines are too long
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user