chore: auto-commit task -

This commit is contained in:
MythEclipse
2026-06-02 18:31:32 +07:00
parent 363676b608
commit 5520094725
137 changed files with 0 additions and 22008 deletions
-20
View File
@@ -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"
}
}
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-113
View File
@@ -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");
});
}
-56
View File
@@ -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);
}
};
}
-61
View File
@@ -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);
});
});
}
-28
View File
@@ -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
View File
@@ -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();
-129
View File
@@ -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");
}
-54
View File
@@ -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;
}
}
-14
View File
@@ -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;
-464
View File
@@ -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;
-115
View File
@@ -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;
}
}
-21
View File
@@ -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,
};
}
-43
View File
@@ -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
View File
@@ -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;
}
-39
View File
@@ -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;
}
-114
View File
@@ -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");
});
}
-13
View File
@@ -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
View File
@@ -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;
-110
View File
@@ -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),
]),
);
};
-280
View File
@@ -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));
}
-64
View File
@@ -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 })),
};
}
}
-126
View File
@@ -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;
}
-80
View File
@@ -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;
}
-128
View File
@@ -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;
}
-60
View File
@@ -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);
},
};
}
-166
View File
@@ -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,
);
}
},
};
}
-104
View File
@@ -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()}`));
});
});
}
-89
View File
@@ -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();
}
-36
View File
@@ -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",
});
}
-48
View File
@@ -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 {};
-295
View File
@@ -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;
}
-145
View File
@@ -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 };
}
}
-918
View File
@@ -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);
}
-929
View File
@@ -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,
};
}
-133
View File
@@ -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");
}
}
-355
View File
@@ -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;
}
}
-113
View File
@@ -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;
}
}
-82
View File
@@ -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>;
-14
View File
@@ -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);
}
-77
View File
@@ -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;
}
-58
View File
@@ -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" };
}
}
-222
View File
@@ -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(" ");
}
-276
View File
@@ -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
-296
View File
@@ -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",
);
}
});
}
-375
View File
@@ -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
-220
View File
@@ -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