diff --git a/src/config.ts b/src/config.ts index be4bd2d..889284f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,82 +1,102 @@ import { z } from "zod"; import { ConfigError } from "./errors"; -const configSchema = z.object({ - DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), - VOICE_CHANNEL_ID: z.string().min(1).optional(), - GUILD_ID: z.string().min(1).optional(), - VERBOSE: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - RECORDINGS_DIR: z.string().default("./recordings"), - RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), - DECODER_ROTATE_MS: z.coerce.number().positive().default(5000), - DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), - WEBSERVER_PORT: z.coerce.number().positive().default(3000), - VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), - RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), - AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000), - PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), - OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), - AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), - AUDIO_CHANNELS: z.coerce.number().positive().default(2), - AVATAR_SIZE: z.coerce.number().positive().default(64), - LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - MONITOR_GUILD_ID: z.string().min(1).optional(), - PICSER_UPLOAD_URL: z.string().url().default("https://picser.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.tech/v1"), - AI_LLM_MODEL: z.string().default("free"), - AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), - DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), - 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), -}).superRefine((value, ctx) => { - if (!value.AI_ANALYSIS_ENABLED) { - // Continue to database validation - } 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_TYPE === "postgres") { - if (!value.DATABASE_URL && !value.POSTGRES_HOST) { +const configSchema = z + .object({ + DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), + VOICE_CHANNEL_ID: z.string().min(1).optional(), + GUILD_ID: z.string().min(1).optional(), + VERBOSE: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + RECORDINGS_DIR: z.string().default("./recordings"), + RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), + DECODER_ROTATE_MS: z.coerce.number().positive().default(5000), + DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), + WEBSERVER_PORT: z.coerce.number().positive().default(3000), + VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), + RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), + AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce + .number() + .positive() + .default(3000), + PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), + OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), + AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), + AUDIO_CHANNELS: z.coerce.number().positive().default(2), + AVATAR_SIZE: z.coerce.number().positive().default(64), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + MONITOR_GUILD_ID: z.string().min(1).optional(), + PICSER_UPLOAD_URL: z + .string() + .url() + .default("https://picser.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.tech/v1"), + AI_LLM_MODEL: z.string().default("free"), + AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), + DATABASE_TYPE: z.enum(["sqlite", "postgres"]).default("sqlite"), + 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), + }) + .superRefine((value, ctx) => { + if (!value.AI_ANALYSIS_ENABLED) { + // Continue to database validation + } else if (!value.AI_LLM_API_KEY) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["DATABASE_URL"], - message: "Either DATABASE_URL or POSTGRES_HOST must be provided when DATABASE_TYPE=postgres", + path: ["AI_LLM_API_KEY"], + message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true", }); } - } -}); + + // Validate PostgreSQL configuration + if (value.DATABASE_TYPE === "postgres") { + 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 when DATABASE_TYPE=postgres", + }); + } + } + }); export type AppConfig = z.infer; diff --git a/src/database/adapter.ts b/src/database/adapter.ts index 37b4711..d0f8559 100644 --- a/src/database/adapter.ts +++ b/src/database/adapter.ts @@ -1,7 +1,7 @@ import path from "node:path"; import Database from "better-sqlite3"; -import { createChildLogger } from "../logger"; import { config } from "../config"; +import { createChildLogger } from "../logger"; import * as postgres from "./postgres"; const logger = createChildLogger("db-adapter"); diff --git a/src/database/postgres.ts b/src/database/postgres.ts index f8d198e..107f6d2 100644 --- a/src/database/postgres.ts +++ b/src/database/postgres.ts @@ -1,6 +1,6 @@ import { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; -import { createChildLogger } from "../logger"; import { config } from "../config"; +import { createChildLogger } from "../logger"; const logger = createChildLogger("postgres"); diff --git a/src/index.ts b/src/index.ts index aecae9f..5fb052f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,19 +4,22 @@ import "@snazzah/davey"; import "dotenv/config"; import { Client } from "discord.js-selfbot-v13"; import { config } from "./config"; +import { getDatabase } from "./database/adapter"; import { createChildLogger } from "./logger"; +import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer"; +import { syncBacklogMessages } from "./moderation/backlogSync"; +import { registerMessageCapture } from "./moderation/messageCapture"; import { discordPlayer } from "./player"; import { VoiceController } from "./voiceController"; import { startWebserver } from "./webserver"; -import { registerMessageCapture } from "./moderation/messageCapture"; -import { syncBacklogMessages } from "./moderation/backlogSync"; -import { getDatabase } from "./database/adapter"; -import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer"; const logger = createChildLogger("bot"); const token = config.DISCORD_TOKEN; -logger.info({ hasToken: token.length > 0, tokenLength: token.length }, "Config loaded"); +logger.info( + { hasToken: token.length > 0, tokenLength: token.length }, + "Config loaded", +); logger.info("Creating Discord client"); const client = new Client(); @@ -105,11 +108,14 @@ async function initializeApp() { }); 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"); - }); + client + .login(token) + .then(() => { + logger.info("Discord client.login resolved"); + }) + .catch((error) => { + logger.error({ error }, "Discord client.login failed"); + }); } initializeApp().catch((error) => { diff --git a/src/logger.ts b/src/logger.ts index c0e7c7f..a4ffbeb 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,7 +7,8 @@ export const logger = pino({ serializers: { error: pino.stdSerializers.err, err: pino.stdSerializers.err, - reason: (value) => value instanceof Error ? pino.stdSerializers.err(value) : value, + reason: (value) => + value instanceof Error ? pino.stdSerializers.err(value) : value, }, transport: isDev ? { diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index b96d953..2905807 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -2,7 +2,11 @@ import { config } from "../config"; import { createChildLogger } from "../logger"; import type { SqliteDatabase } from "../muxer-queue"; import { retryWithBackoff } from "../retry"; -import { getMessageById, getPendingAIAnalysisMessages, updateMessageAIAnalysis } from "./messageStore"; +import { + getMessageById, + getPendingAIAnalysisMessages, + updateMessageAIAnalysis, +} from "./messageStore"; import type { MessageRecord } from "./types"; const logger = createChildLogger("ai-analyzer"); @@ -37,7 +41,10 @@ function estimateTokens(text: string): number { return Math.ceil(text.length / 4); } -function formatMessageForAnalysis(message: MessageRecord, index: number): string { +function formatMessageForAnalysis( + message: MessageRecord, + index: number, +): string { const text = getAnalysisText(message); const time = new Date(message.created_at).toISOString(); return `${index + 1}. id=${message.id} time=${time} user=${message.username}: ${text}`; @@ -49,7 +56,10 @@ function estimateMessageTokens(message: MessageRecord): number { async function fetchJson(url: string, init: RequestInit): Promise { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), config.AI_ANALYSIS_TIMEOUT_MS); + const timeout = setTimeout( + () => controller.abort(), + config.AI_ANALYSIS_TIMEOUT_MS, + ); try { const response = await fetch(url, { ...init, signal: controller.signal }); @@ -85,10 +95,16 @@ function parseLLMAnalysis(content: string): LLMAnalysis { if (jsonStart >= 0 && jsonEnd > jsonStart) { try { const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1)); - const status = parsed.status === "flagged" ? "flagged" : parsed.status === "warn" ? "warn" : "clean"; + const status = + parsed.status === "flagged" + ? "flagged" + : parsed.status === "warn" + ? "warn" + : "clean"; const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : []; const score = Math.max(0, Math.min(1, Number(parsed.score) || 0)); - const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content; + const analysis = + typeof parsed.analysis === "string" ? parsed.analysis : content; return { status, flags, score, analysis }; } catch { // Fall through to text-only parsing. @@ -96,27 +112,37 @@ function parseLLMAnalysis(content: string): LLMAnalysis { } return { - status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm|illegal|scam|hacking/i.test(content) ? "flagged" : /warn|provokasi|hinaan|menyerang/i.test(content) ? "warn" : "clean", + status: + /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm|illegal|scam|hacking/i.test( + content, + ) + ? "flagged" + : /warn|provokasi|hinaan|menyerang/i.test(content) + ? "warn" + : "clean", flags: [], score: 0, analysis: content.trim() || "Tidak ada analisis dari LLM.", }; } -async function runLLMAnalysis(messages: MessageRecord[]): Promise<{ results: LLMAnalysis[]; raw: unknown }> { - const response = await retryWithBackoff( - () => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, { - method: "POST", - headers: { - "Authorization": `Bearer ${config.AI_LLM_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: config.AI_LLM_MODEL, - messages: [ - { - role: "system", - content: `Kamu moderator Discord komunitas. Analisis setiap pesan dengan 3 kategori: +async function runLLMAnalysis( + messages: MessageRecord[], +): Promise<{ results: LLMAnalysis[]; raw: unknown }> { + const response = (await retryWithBackoff( + () => + fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${config.AI_LLM_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: config.AI_LLM_MODEL, + messages: [ + { + role: "system", + content: `Kamu moderator Discord komunitas. Analisis setiap pesan dengan 3 kategori: - CLEAN: Pesan normal, tidak melanggar aturan - WARN: Melanggar aturan minor yang menarget orang lain (tone menyerang, hinaan ringan, konflik kecil) - butuh peringatan tapi tidak dihapus - FLAGGED: Melanggar aturan berat (NSFW, ilegal, hacking, scam, harassment, violence, SARA, gore, spam, promosi judi) - butuh review moderator untuk penghapusan @@ -166,18 +192,18 @@ PENENTUAN STATUS: Balas JSON array dengan schema: [{"status":"clean|warn|flagged","flags":["..."],"score":0..1,"analysis":"ringkasan Bahasa Indonesia + alasan + aksi disarankan"}] Satu JSON object per pesan dalam array.`, - }, - { - role: "user", - content: `Analisis ${messages.length} pesan berikut sebagai satu alur percakapan. Tetap kembalikan satu hasil per pesan dengan urutan yang sama:\n${messages.map(formatMessageForAnalysis).join("\n")}`, - }, - ], - temperature: 0.2, + }, + { + role: "user", + content: `Analisis ${messages.length} pesan berikut sebagai satu alur percakapan. Tetap kembalikan satu hasil per pesan dengan urutan yang sama:\n${messages.map(formatMessageForAnalysis).join("\n")}`, + }, + ], + temperature: 0.2, + }), + signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS), }), - signal: AbortSignal.timeout(config.AI_ANALYSIS_TIMEOUT_MS), - }), { retries: 2, logger }, - ) as ChatCompletionResponse; + )) as ChatCompletionResponse; const content = response.choices?.[0]?.message?.content?.trim() || ""; @@ -191,12 +217,18 @@ Satu JSON object per pesan dalam array.`, const parsed = JSON.parse(content.substring(jsonStart, jsonEnd + 1)); if (Array.isArray(parsed)) { results = parsed.map((item: any) => { - const status = item.status === "flagged" ? "flagged" : item.status === "warn" ? "warn" : "clean"; + const status = + item.status === "flagged" + ? "flagged" + : item.status === "warn" + ? "warn" + : "clean"; return { status, flags: Array.isArray(item.flags) ? item.flags.map(String) : [], score: Math.max(0, Math.min(1, Number(item.score) || 0)), - analysis: typeof item.analysis === "string" ? item.analysis : content, + analysis: + typeof item.analysis === "string" ? item.analysis : content, }; }); } @@ -213,10 +245,15 @@ Satu JSON object per pesan dalam array.`, return { results, raw: response }; } -async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[]): Promise { +async function analyzeAndStoreBatch( + db: SqliteDatabase, + messages: MessageRecord[], +): Promise { if (messages.length === 0) return; - const analyzableMessages = messages.filter((message) => getAnalysisText(message).length > 0); + const analyzableMessages = messages.filter( + (message) => getAnalysisText(message).length > 0, + ); if (analyzableMessages.length === 0) return; activeRequests++; @@ -228,7 +265,12 @@ async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[ const result = results[i] || parseLLMAnalysis(""); const row = updateMessageAIAnalysis(db, message.id, { - status: result.status as "pending" | "clean" | "warn" | "flagged" | "error", + status: result.status as + | "pending" + | "clean" + | "warn" + | "flagged" + | "error", flags: JSON.stringify(result.flags), score: result.score, raw: JSON.stringify(raw), @@ -242,7 +284,11 @@ async function analyzeAndStoreBatch(db: SqliteDatabase, messages: MessageRecord[ if (analyzableMessages.length > 1) { const midpoint = Math.ceil(analyzableMessages.length / 2); logger.warn( - { count: analyzableMessages.length, nextBatchSizes: [midpoint, analyzableMessages.length - midpoint], error }, + { + count: analyzableMessages.length, + nextBatchSizes: [midpoint, analyzableMessages.length - midpoint], + error, + }, "AI batch failed, splitting into smaller batches", ); await analyzeAndStoreBatch(db, analyzableMessages.slice(0, midpoint)); @@ -288,7 +334,11 @@ async function drainQueue(db: SqliteDatabase): Promise { if (!message) continue; const messageTokens = estimateMessageTokens(message); - if (batch.length > 0 && (batch.length >= MAX_AI_BATCH_MESSAGES || tokenEstimate + messageTokens > batchTokenLimit)) { + if ( + batch.length > 0 && + (batch.length >= MAX_AI_BATCH_MESSAGES || + tokenEstimate + messageTokens > batchTokenLimit) + ) { queuedMessageIds.add(messageId); break; } @@ -298,7 +348,10 @@ async function drainQueue(db: SqliteDatabase): Promise { } if (batch.length > 0) { - logger.info({ count: batch.length, tokenEstimate }, "Processing AI analysis batch"); + logger.info( + { count: batch.length, tokenEstimate }, + "Processing AI analysis batch", + ); await analyzeAndStoreBatch(db, batch); } } @@ -307,12 +360,17 @@ async function drainQueue(db: SqliteDatabase): Promise { } } -export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void { +export function queueMessageAnalysis( + db: SqliteDatabase, + messageId: string, +): void { if (!config.AI_ANALYSIS_ENABLED) return; logger.debug({ messageId }, "Queueing AI analysis"); queuedMessageIds.add(messageId); setImmediate(() => { - drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed")); + drainQueue(db).catch((error) => + logger.error({ error }, "AI analysis queue failed"), + ); }); } @@ -327,10 +385,15 @@ export function startPendingAIAnalysisWorker(db: SqliteDatabase): void { if (isProcessing) return; const pendingMessages = getPendingAIAnalysisMessages(db, 500); if (pendingMessages.length === 0) return; - logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages"); + logger.info( + { count: pendingMessages.length }, + "Queueing pending AI analysis messages", + ); for (const message of pendingMessages) { queuedMessageIds.add(message.id); } - drainQueue(db).catch((error) => logger.error({ error }, "Pending AI analysis worker failed")); + drainQueue(db).catch((error) => + logger.error({ error }, "Pending AI analysis worker failed"), + ); }, 15000); } diff --git a/src/moderation/attachmentUploader.ts b/src/moderation/attachmentUploader.ts index 9e59d9b..8089935 100644 --- a/src/moderation/attachmentUploader.ts +++ b/src/moderation/attachmentUploader.ts @@ -1,8 +1,11 @@ -import { createChildLogger } from "../logger"; import { config } from "../config"; -import { retryWithBackoff } from "../retry"; +import { createChildLogger } from "../logger"; import type { SqliteDatabase } from "../muxer-queue"; -import { updateAttachmentAsUploaded, updateAttachmentAsFailedUpload } from "./messageStore"; +import { retryWithBackoff } from "../retry"; +import { + updateAttachmentAsFailedUpload, + updateAttachmentAsUploaded, +} from "./messageStore"; const logger = createChildLogger("attachment-uploader"); @@ -25,7 +28,9 @@ export interface ParsedUploadResponse { type: string; } -export function parseUploadResponse(response: PicserUploadResponse): ParsedUploadResponse { +export function parseUploadResponse( + response: PicserUploadResponse, +): ParsedUploadResponse { if (!response.success) { throw new Error("Upload failed: success=false"); } @@ -49,7 +54,9 @@ export async function uploadAttachmentToPicser( filename: string, ): Promise { const formData = new FormData(); - const blob = new Blob([new Uint8Array(fileBuffer)], { type: "application/octet-stream" }); + const blob = new Blob([new Uint8Array(fileBuffer)], { + type: "application/octet-stream", + }); formData.append("file", blob, filename); try { @@ -76,11 +83,17 @@ export async function uploadAttachmentToPicser( ); const parsed = parseUploadResponse(response); - logger.info({ filename, url: parsed.url }, "Attachment uploaded successfully"); + logger.info( + { filename, url: parsed.url }, + "Attachment uploaded successfully", + ); return parsed; } catch (error) { logger.error( - { filename, error: error instanceof Error ? error.message : String(error) }, + { + filename, + error: error instanceof Error ? error.message : String(error), + }, "Failed to upload attachment", ); throw error; @@ -121,13 +134,18 @@ export async function processAttachmentUpload( 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`); + throw new Error( + `File size ${sizeMb.toFixed(2)}MB exceeds limit of ${config.ATTACHMENT_MAX_SIZE_MB}MB`, + ); } const result = await uploadAttachmentToPicser(buffer, filename); updateAttachmentAsUploaded(db, attachmentId, result.url, Date.now()); - logger.info({ attachmentId, uploadedUrl: result.url }, "Attachment upload completed"); + logger.info( + { attachmentId, uploadedUrl: result.url }, + "Attachment upload completed", + ); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); updateAttachmentAsFailedUpload(db, attachmentId, errorMsg); diff --git a/src/moderation/backlogSync.ts b/src/moderation/backlogSync.ts index e480c0d..2c7ac29 100644 --- a/src/moderation/backlogSync.ts +++ b/src/moderation/backlogSync.ts @@ -53,11 +53,17 @@ export async function syncBacklogMessages( const guild = client.guilds.cache.get(config.MONITOR_GUILD_ID); if (!guild) { - logger.warn({ guildId: config.MONITOR_GUILD_ID }, "Monitor guild not found, skipping backlog sync"); + logger.warn( + { guildId: config.MONITOR_GUILD_ID }, + "Monitor guild not found, skipping backlog sync", + ); return; } - logger.info({ guildId: guild.id }, "Backlog sync ready (will sync on-demand per selected channel)"); + logger.info( + { guildId: guild.id }, + "Backlog sync ready (will sync on-demand per selected channel)", + ); } export async function syncSelectedChannelBacklog( @@ -86,7 +92,10 @@ export async function syncSelectedChannelBacklog( try { const count = await syncChannelMessages(db, channel as any, cutoffTime); - logger.info({ channelId, count }, "Backlog sync completed for selected channel"); + logger.info( + { channelId, count }, + "Backlog sync completed for selected channel", + ); return count; } catch (error) { logger.warn( diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts index 84067f7..d40bbd6 100644 --- a/src/moderation/messageCapture.ts +++ b/src/moderation/messageCapture.ts @@ -1,11 +1,15 @@ import type { Client, Message } from "discord.js-selfbot-v13"; -import { createChildLogger } from "../logger"; import { config } from "../config"; +import { createChildLogger } from "../logger"; import type { SqliteDatabase } from "../muxer-queue"; -import { insertMessage, insertAttachment } from "./messageStore"; -import { getDisplayContent, getMessageLocation, getMessageMetadata } from "./messageMetadata"; import { queueMessageAnalysis } from "./aiAnalyzer"; -import type { MessageRecord, AttachmentRecord } from "./types"; +import { + getDisplayContent, + getMessageLocation, + getMessageMetadata, +} from "./messageMetadata"; +import { insertAttachment, insertMessage } from "./messageStore"; +import type { AttachmentRecord, MessageRecord } from "./types"; const logger = createChildLogger("message-capture"); @@ -89,7 +93,10 @@ export async function captureMessage( ); } -export function registerMessageCapture(client: Client, db: SqliteDatabase): void { +export function registerMessageCapture( + client: Client, + db: SqliteDatabase, +): void { client.on("messageCreate", async (message) => { if (!message.guildId || message.guildId !== config.MONITOR_GUILD_ID) return; if (message.author?.bot) return; @@ -98,14 +105,18 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void await captureMessage(db, message, "text"); } catch (error) { logger.error( - { messageId: message.id, error: error instanceof Error ? error.message : String(error) }, + { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to capture message", ); } }); client.on("messageUpdate", async (_oldMessage, newMessage) => { - if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID) return; + if (!newMessage.guildId || newMessage.guildId !== config.MONITOR_GUILD_ID) + return; if (newMessage.author?.bot) return; try { @@ -117,7 +128,12 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void if (existing) { const editedAt = Date.now(); - updateMessageAsEdited(db, newMessage.id, getDisplayContent(newMessage as Message), editedAt); + updateMessageAsEdited( + db, + newMessage.id, + getDisplayContent(newMessage as Message), + editedAt, + ); queueMessageAnalysis(db, newMessage.id); const broadcaster = globalThis as any; @@ -133,7 +149,10 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void } } catch (error) { logger.error( - { messageId: newMessage.id, error: error instanceof Error ? error.message : String(error) }, + { + messageId: newMessage.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to capture message update", ); } @@ -159,7 +178,10 @@ export function registerMessageCapture(client: Client, db: SqliteDatabase): void logger.info({ messageId: message.id }, "Message deletion captured"); } catch (error) { logger.error( - { messageId: message.id, error: error instanceof Error ? error.message : String(error) }, + { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to capture message deletion", ); } diff --git a/src/moderation/messageMetadata.ts b/src/moderation/messageMetadata.ts index 17114b7..e4a753f 100644 --- a/src/moderation/messageMetadata.ts +++ b/src/moderation/messageMetadata.ts @@ -1,4 +1,8 @@ -import type { Message, TextChannel, ThreadChannel } from "discord.js-selfbot-v13"; +import type { + Message, + TextChannel, + ThreadChannel, +} from "discord.js-selfbot-v13"; export interface MessageLocation { channelId: string; @@ -8,7 +12,12 @@ export interface MessageLocation { } export interface RichMessageMetadata { - stickers: Array<{ id: string; name: string; url: string; format: string | null }>; + stickers: Array<{ + id: string; + name: string; + url: string; + format: string | null; + }>; embeds: Array<{ title: string | null; description: string | null; @@ -16,7 +25,11 @@ export interface RichMessageMetadata { color: number | null; image: string | null; thumbnail: string | null; - author: { name: string | null; url: string | null; iconURL: string | null } | 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 }>; }>; @@ -66,7 +79,9 @@ export function getMessageLocation(message: Message): MessageLocation { }; } -export function getStickerMetadata(message: Message): RichMessageMetadata["stickers"] { +export function getStickerMetadata( + message: Message, +): RichMessageMetadata["stickers"] { return Array.from(message.stickers.values()).map((sticker) => ({ id: sticker.id, name: sticker.name, @@ -75,7 +90,9 @@ export function getStickerMetadata(message: Message): RichMessageMetadata["stick })); } -export function getAttachmentMetadata(message: Message): RichMessageMetadata["attachments"] { +export function getAttachmentMetadata( + message: Message, +): RichMessageMetadata["attachments"] { return Array.from(message.attachments.values()).map((attachment) => ({ id: attachment.id, name: attachment.name || "unknown", @@ -85,7 +102,9 @@ export function getAttachmentMetadata(message: Message): RichMessageMetadata["at })); } -export function getEmbedMetadata(message: Message): RichMessageMetadata["embeds"] { +export function getEmbedMetadata( + message: Message, +): RichMessageMetadata["embeds"] { return message.embeds.map((embed) => ({ title: embed.title ?? null, description: embed.description ?? null, @@ -130,7 +149,10 @@ export function getMessageMetadata(message: Message): RichMessageMetadata { member: member ? { displayName: member.displayName ?? null, - roles: member.roles.cache.map((role) => ({ id: role.id, name: role.name })), + roles: member.roles.cache.map((role) => ({ + id: role.id, + name: role.name, + })), joinedTimestamp: member.joinedTimestamp ?? null, } : null, @@ -155,12 +177,16 @@ export function getDisplayContent(message: Message): string { const attachments = getAttachmentMetadata(message); if (attachments.length > 0) { - return attachments.map((attachment) => `[Attachment: ${attachment.name}]`).join(" "); + 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 embeds + .map((embed) => embed.title || embed.description || "[Embed]") + .join(" "); } return ""; diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 6e6997d..70305e8 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -1,10 +1,13 @@ -import { createChildLogger } from "../logger"; import type { DatabaseAdapter } from "../database/adapter"; -import type { MessageRecord, AttachmentRecord } from "./types"; +import { createChildLogger } from "../logger"; +import type { AttachmentRecord, MessageRecord } from "./types"; const logger = createChildLogger("message-store"); -export function insertMessage(db: DatabaseAdapter, message: MessageRecord): void { +export function insertMessage( + db: DatabaseAdapter, + message: MessageRecord, +): void { try { const stmt = db.prepare(` INSERT OR IGNORE INTO messages ( @@ -30,10 +33,16 @@ export function insertMessage(db: DatabaseAdapter, message: MessageRecord): void message.metadata, ); - logger.debug({ messageId: message.id, channelId: message.channel_id }, "Message inserted"); + logger.debug( + { messageId: message.id, channelId: message.channel_id }, + "Message inserted", + ); } catch (error) { logger.error( - { messageId: message.id, error: error instanceof Error ? error.message : String(error) }, + { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to insert message", ); throw error; @@ -57,7 +66,10 @@ export function updateMessageAsEdited( logger.debug({ messageId }, "Message marked as edited"); } catch (error) { logger.error( - { messageId, error: error instanceof Error ? error.message : String(error) }, + { + messageId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update message as edited", ); throw error; @@ -80,7 +92,10 @@ export function updateMessageAsDeleted( logger.debug({ messageId }, "Message marked as deleted"); } catch (error) { logger.error( - { messageId, error: error instanceof Error ? error.message : String(error) }, + { + messageId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update message as deleted", ); throw error; @@ -101,18 +116,29 @@ export function getMessagesByChannel( LIMIT ? OFFSET ? `); - const rows = stmt.all(channelId, channelId, limit, offset) as MessageRecord[]; + const rows = stmt.all( + channelId, + channelId, + limit, + offset, + ) as MessageRecord[]; return rows; } catch (error) { logger.error( - { channelId, error: error instanceof Error ? error.message : String(error) }, + { + channelId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to get messages by channel", ); throw error; } } -export function insertAttachment(db: DatabaseAdapter, attachment: AttachmentRecord): void { +export function insertAttachment( + db: DatabaseAdapter, + attachment: AttachmentRecord, +): void { try { const stmt = db.prepare(` INSERT OR IGNORE INTO attachments ( @@ -139,10 +165,16 @@ export function insertAttachment(db: DatabaseAdapter, attachment: AttachmentReco attachment.uploaded_at, ); - logger.debug({ attachmentId: attachment.id, messageId: attachment.message_id }, "Attachment inserted"); + logger.debug( + { attachmentId: attachment.id, messageId: attachment.message_id }, + "Attachment inserted", + ); } catch (error) { logger.error( - { attachmentId: attachment.id, error: error instanceof Error ? error.message : String(error) }, + { + attachmentId: attachment.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to insert attachment", ); throw error; @@ -163,11 +195,19 @@ export function getAttachmentsByChannel( LIMIT ? OFFSET ? `); - const rows = stmt.all(channelId, channelId, limit, offset) as AttachmentRecord[]; + const rows = stmt.all( + channelId, + channelId, + limit, + offset, + ) as AttachmentRecord[]; return rows; } catch (error) { logger.error( - { channelId, error: error instanceof Error ? error.message : String(error) }, + { + channelId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to get attachments by channel", ); throw error; @@ -188,10 +228,16 @@ export function updateAttachmentAsUploaded( `); stmt.run(uploadedUrl, uploadedAt, attachmentId); - logger.debug({ attachmentId, uploadedUrl }, "Attachment marked as uploaded"); + logger.debug( + { attachmentId, uploadedUrl }, + "Attachment marked as uploaded", + ); } catch (error) { logger.error( - { attachmentId, error: error instanceof Error ? error.message : String(error) }, + { + attachmentId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update attachment as uploaded", ); throw error; @@ -214,7 +260,10 @@ export function updateAttachmentAsFailedUpload( logger.debug({ attachmentId, error }, "Attachment marked as failed upload"); } catch (error) { logger.error( - { attachmentId, error: error instanceof Error ? error.message : String(error) }, + { + attachmentId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update attachment as failed", ); throw error; @@ -255,11 +304,16 @@ export function updateMessageAIAnalysis( messageId, ); - const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined; + const row = db + .prepare("SELECT * FROM messages WHERE id = ?") + .get(messageId) as MessageRecord | undefined; return row ?? null; } catch (error) { logger.error( - { messageId, error: error instanceof Error ? error.message : String(error) }, + { + messageId, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update message AI analysis", ); throw error; @@ -289,7 +343,12 @@ export function getPendingAIAnalysisMessages( } } -export function getMessageById(db: DatabaseAdapter, messageId: string): MessageRecord | null { - const row = db.prepare("SELECT * FROM messages WHERE id = ?").get(messageId) as MessageRecord | undefined; +export function getMessageById( + db: DatabaseAdapter, + messageId: string, +): MessageRecord | null { + const row = db + .prepare("SELECT * FROM messages WHERE id = ?") + .get(messageId) as MessageRecord | undefined; return row ?? null; } diff --git a/src/muxer-queue.ts b/src/muxer-queue.ts index cc913f0..5c61f32 100644 --- a/src/muxer-queue.ts +++ b/src/muxer-queue.ts @@ -1,4 +1,7 @@ -import { getDatabase as getDatabaseAdapter, DatabaseAdapter } from "./database/adapter"; +import { + DatabaseAdapter, + getDatabase as getDatabaseAdapter, +} from "./database/adapter"; import { createChildLogger } from "./logger"; const logger = createChildLogger("muxer-queue"); @@ -137,7 +140,10 @@ async function getDatabaseAdapterInternal(): Promise { // Export as getDatabase for backward compatibility export const getDatabase = getDatabaseAdapterInternal; -export async function getPersistedValue(key: string, fallback: T): Promise { +export async function getPersistedValue( + key: string, + fallback: T, +): Promise { const adapter = await getDatabaseAdapterInternal(); const row = adapter .prepare("SELECT value FROM ui_state WHERE key = ?") @@ -150,7 +156,10 @@ export async function getPersistedValue(key: string, fallback: T): Promise } } -export async function setPersistedValue(key: string, value: unknown): Promise { +export async function setPersistedValue( + key: string, + value: unknown, +): Promise { const adapter = await getDatabaseAdapterInternal(); adapter .prepare(` diff --git a/src/voiceController.ts b/src/voiceController.ts index f17fdbc..58917c6 100644 --- a/src/voiceController.ts +++ b/src/voiceController.ts @@ -90,12 +90,19 @@ export class VoiceController { const threads: ChannelSummary[] = []; for (const channel of guild.channels.cache.values()) { const threadParent = channel as typeof channel & { - threads?: { fetch: (options: { archived: boolean; limit: number }) => Promise }; + threads?: { + fetch: (options: { + archived: boolean; + limit: number; + }) => Promise; + }; }; if (!threadParent.threads?.fetch) continue; for (const archived of [false, true]) { - const fetched = await threadParent.threads.fetch({ archived, limit: 100 }).catch(() => null); + const fetched = await threadParent.threads + .fetch({ archived, limit: 100 }) + .catch(() => null); if (!fetched?.threads) continue; for (const thread of fetched.threads.values()) { @@ -108,8 +115,9 @@ export class VoiceController { } } - return Array.from(new Map(threads.map((thread) => [thread.id, thread])).values()) - .sort((a, b) => a.name.localeCompare(b.name)); + return Array.from( + new Map(threads.map((thread) => [thread.id, thread])).values(), + ).sort((a, b) => a.name.localeCompare(b.name)); } async connect(guildId: string, channelId: string): Promise { diff --git a/src/webserver.ts b/src/webserver.ts index 49dffdc..eac8dcb 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -8,11 +8,18 @@ import { WebSocketServer } from "ws"; import { AppError } from "./errors"; import { createChildLogger, logger } from "./logger"; import { getMetrics, uptimeGauge } from "./metrics"; +import { syncSelectedChannelBacklog } from "./moderation/backlogSync"; +import { + getAttachmentsByChannel, + getMessagesByChannel, +} from "./moderation/messageStore"; +import { + getDatabase, + getPersistedValue, + setPersistedValue, +} from "./muxer-queue"; import { discordPlayer } from "./player"; import type { VoiceController } from "./voiceController"; -import { getDatabase, getPersistedValue, setPersistedValue } from "./muxer-queue"; -import { getMessagesByChannel, getAttachmentsByChannel } from "./moderation/messageStore"; -import { syncSelectedChannelBacklog } from "./moderation/backlogSync"; const wsLogger = createChildLogger("webserver"); @@ -139,7 +146,11 @@ export async function startWebserver( if (req.originalUrl === "/favicon.ico") return; if (res.statusCode >= 400) { logger.error( - { method: req.method, url: req.originalUrl, statusCode: res.statusCode }, + { + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + }, "HTTP request failed", ); } @@ -250,7 +261,12 @@ export async function startWebserver( app.get("/api/messages", async (req, res, next) => { try { const db = await getDatabase(); - const { channel, type, limit = "50", offset = "0" } = req.query as { + const { + channel, + type, + limit = "50", + offset = "0", + } = req.query as { channel?: string; type?: string; limit?: string; @@ -258,14 +274,23 @@ export async function startWebserver( }; if (!channel) { - throw new AppError("channel query parameter is required", "MISSING_CHANNEL", 400); + throw new AppError( + "channel query parameter is required", + "MISSING_CHANNEL", + 400, + ); } const limitNum = Math.min(parseInt(limit) || 50, 100); const offsetNum = parseInt(offset) || 0; if (type === "image") { - const attachments = getAttachmentsByChannel(db, channel, limitNum, offsetNum); + const attachments = getAttachmentsByChannel( + db, + channel, + limitNum, + offsetNum, + ); res.json({ type: "image", data: attachments, @@ -299,7 +324,12 @@ export async function startWebserver( ); } - const count = await syncSelectedChannelBacklog(_client, await getDatabase(), guildId, channelId); + const count = await syncSelectedChannelBacklog( + _client, + await getDatabase(), + guildId, + channelId, + ); res.json({ success: true, channelId, diff --git a/tests/moderation/attachmentUploader.test.ts b/tests/moderation/attachmentUploader.test.ts index c5609e4..2e1e7af 100644 --- a/tests/moderation/attachmentUploader.test.ts +++ b/tests/moderation/attachmentUploader.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; beforeEach(() => { process.env = { @@ -11,13 +11,16 @@ beforeEach(() => { describe("attachmentUploader", () => { it("parses picser upload response correctly", async () => { - const { parseUploadResponse } = await import("../../src/moderation/attachmentUploader"); + const { parseUploadResponse } = await import( + "../../src/moderation/attachmentUploader" + ); const response = { success: true, filename: "uploads/abc123.jpg", urls: { - raw_commit: "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", + raw_commit: + "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", }, size: 102400, type: "image/jpeg", @@ -26,12 +29,16 @@ describe("attachmentUploader", () => { const result = parseUploadResponse(response); expect(result.success).toBe(true); - expect(result.url).toBe("https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg"); + expect(result.url).toBe( + "https://raw.githubusercontent.com/user/repo/commit/uploads/abc123.jpg", + ); expect(result.filename).toBe("uploads/abc123.jpg"); }); it("handles upload response with missing raw_commit", async () => { - const { parseUploadResponse } = await import("../../src/moderation/attachmentUploader"); + const { parseUploadResponse } = await import( + "../../src/moderation/attachmentUploader" + ); const response = { success: true,