diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index e52ef39..943710f 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -15,7 +15,9 @@ 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."); + logger.error( + "AI_LLM_API_KEY is missing from environment. Force closing application as AI environment is required.", + ); process.exit(1); } diff --git a/src/database/schema.ts b/src/database/schema.ts index 6320771..b04957b 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -493,9 +493,13 @@ export const pgMessageReviewsTable = pgTable( reviewed_at: pgBigint("reviewed_at", { mode: "number" }), }, (table) => ({ - messageIdIdx: pgIndex("idx_message_reviews_message_id").on(table.message_id), + 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), + createdAtIdx: pgIndex("idx_message_reviews_created_at").on( + table.created_at, + ), guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on( table.guild_id, table.status, @@ -553,9 +557,14 @@ export const pgModerationActionsTable = pgTable( 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(), + enum: [ + "delete_message", + "mute_user", + "warn_user", + "kick_user", + "ban_user", + ], + }).notNull(), reason: pgText("reason"), executed_by: pgText("executed_by"), status: pgText("status", { @@ -593,9 +602,14 @@ export const sqliteModerationActionsTable = sqliteTable( user_id: sqliteText("user_id"), guild_id: sqliteText("guild_id").notNull(), action_type: sqliteText("action_type", { - enum: ["delete_message", "mute_user", "warn_user", "kick_user", "ban_user"], - }) - .notNull(), + enum: [ + "delete_message", + "mute_user", + "warn_user", + "kick_user", + "ban_user", + ], + }).notNull(), reason: sqliteText("reason"), executed_by: sqliteText("executed_by"), status: sqliteText("status", { diff --git a/src/moderation/actionExecutor.ts b/src/moderation/actionExecutor.ts index e9f89b0..a6362ef 100644 --- a/src/moderation/actionExecutor.ts +++ b/src/moderation/actionExecutor.ts @@ -1,9 +1,6 @@ import type { Client, Guild, User } from "discord.js-selfbot-v13"; import { createChildLogger } from "../logger.js"; -import { - getModerationAction, - updateModerationAction, -} from "./messageStore.js"; +import { getModerationAction, updateModerationAction } from "./messageStore.js"; import type { ModerationAction, ModerationActionType } from "./types.js"; const logger = createChildLogger("action-executor"); @@ -151,9 +148,7 @@ async function executeWarnUser( } const reason = action.reason || "Warned by moderation system"; - await user.send( - `You have been warned in ${guild.name}. Reason: ${reason}`, - ); + await user.send(`You have been warned in ${guild.name}. Reason: ${reason}`); logger.info( { userId: action.user_id, guildId: guild.id }, @@ -257,10 +252,7 @@ export async function processPendingActions( } } - logger.info( - { guildId, ...result }, - "Processed pending moderation actions", - ); + logger.info({ guildId, ...result }, "Processed pending moderation actions"); return result; } catch (error) { diff --git a/src/moderation/aiAnalysisWorker.ts b/src/moderation/aiAnalysisWorker.ts index 46961e3..9f92634 100644 --- a/src/moderation/aiAnalysisWorker.ts +++ b/src/moderation/aiAnalysisWorker.ts @@ -45,12 +45,15 @@ export default async function processAnalysisRequest({ messages, }: AnalysisWorkerRequest): Promise { 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(), - })); + 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); } diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index c476808..f042ed0 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -7,9 +7,7 @@ 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 { buildConversationContext } from "./conversationContext.js"; import { runModerationAnalysis } from "./llmModerationClient.js"; import { getAttachmentsForMessages, diff --git a/src/moderation/analyticsStore.ts b/src/moderation/analyticsStore.ts index 431e9cb..472effd 100644 --- a/src/moderation/analyticsStore.ts +++ b/src/moderation/analyticsStore.ts @@ -110,7 +110,13 @@ export async function getHourlyStats(input: { // Initialize all hour buckets const buckets = new Map< string, - { count: number; clean: number; warned: number; flagged: number; error: number } + { + count: number; + clean: number; + warned: number; + flagged: number; + error: number; + } >(); for (let h = 0; h < hours; h++) { @@ -151,23 +157,156 @@ export async function getHourlyStats(input: { // ── 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", + "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[] { @@ -182,22 +321,36 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { const topics = analysis.topics; if (topics && Array.isArray(topics)) { for (const topic of topics) { - const key = typeof topic === "string" ? topic : topic.name || topic.topic; + 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 (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 }); } + 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 */ } + } catch { + /* not valid JSON */ + } } if (msg.content) { @@ -227,7 +380,11 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { for (const [word, count] of sortedWords) { if (!topicScores.has(word)) { - results.push({ topic: word, count, score: flaggedWordFreq.get(word) || 0 }); + results.push({ + topic: word, + count, + score: flaggedWordFreq.get(word) || 0, + }); } } @@ -308,7 +465,8 @@ export async function getUserLeaderboard(input: { existing.message_count++; if (msg.type === "edited") existing.edited_count++; if (msg.type === "deleted") existing.deleted_count++; - if (msg.ai_status === "flagged" || msg.ai_status === "warn") existing.flagged_count++; + if (msg.ai_status === "flagged" || msg.ai_status === "warn") + existing.flagged_count++; if (msg.created_at > existing.last_active) { existing.last_active = msg.created_at; } @@ -320,7 +478,8 @@ export async function getUserLeaderboard(input: { message_count: 1, edited_count: msg.type === "edited" ? 1 : 0, deleted_count: msg.type === "deleted" ? 1 : 0, - flagged_count: msg.ai_status === "flagged" || msg.ai_status === "warn" ? 1 : 0, + flagged_count: + msg.ai_status === "flagged" || msg.ai_status === "warn" ? 1 : 0, last_active: msg.created_at, }); } @@ -367,7 +526,11 @@ export async function getModerationStats(input: { const breakdown: ModerationBreakdown = { total: rows.length, - clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, + clean: 0, + warned: 0, + flagged: 0, + error: 0, + pending: 0, average_score: 0, }; @@ -398,7 +561,13 @@ export async function getModerationStats(input: { "Failed to get moderation stats", ); return { - total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0, + total: 0, + clean: 0, + warned: 0, + flagged: 0, + error: 0, + pending: 0, + average_score: 0, }; } } @@ -446,7 +615,7 @@ export interface ViolatorStat { flagged_count: number; warned_count: number; violation_score: number; // weighted: flagged*3 + warned*1 - worst_flags: string[]; // unique flag types + worst_flags: string[]; // unique flag types last_violation: number; } @@ -477,16 +646,19 @@ export async function getTopViolators(input: { .where(and(...conditions) as SQL) .orderBy(asc(messagesTable.created_at))) as MessageRecord[]; - const userMap = new Map; - last_violation: number; - }>(); + const userMap = new Map< + string, + { + user_id: string; + username: string; + avatar_url: string | null; + total_messages: number; + flagged_count: number; + warned_count: number; + flags_set: Set; + last_violation: number; + } + >(); for (const msg of rows) { let entry = userMap.get(msg.user_id); @@ -506,7 +678,8 @@ export async function getTopViolators(input: { entry.total_messages++; - const isViolation = msg.ai_status === "flagged" || msg.ai_status === "warn"; + const isViolation = + msg.ai_status === "flagged" || msg.ai_status === "warn"; if (msg.ai_status === "flagged") { entry.flagged_count++; @@ -522,7 +695,9 @@ export async function getTopViolators(input: { if (Array.isArray(flags)) { for (const f of flags) entry.flags_set.add(String(f)); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } } if (isViolation && msg.created_at > entry.last_violation) { @@ -571,13 +746,15 @@ export async function getAnalyticsOverview(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 }), - ]); + 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 }, diff --git a/src/moderation/autoDeleteManager.ts b/src/moderation/autoDeleteManager.ts index 7e5649f..227212e 100644 --- a/src/moderation/autoDeleteManager.ts +++ b/src/moderation/autoDeleteManager.ts @@ -10,7 +10,9 @@ 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") : []; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; } catch { return value .split(",") @@ -23,7 +25,8 @@ const parseStringList = (value?: string | null): string[] => { 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 === "flagged") + return score >= 0.9 ? "critical" : score >= 0.7 ? "high" : "medium"; if (msg.ai_status === "warn") return score >= 0.6 ? "medium" : "low"; return "none"; } @@ -32,19 +35,28 @@ function deriveSeverity(msg: MessageRecord): string { 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" && + (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; + 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 }, + { + messageId: message.id, + confidence, + threshold: config.AUTO_DELETE_MIN_CONFIDENCE, + }, "Auto-delete skipped: confidence below threshold", ); return false; @@ -72,31 +84,49 @@ function isAutoDeleteEligible(message: MessageRecord): boolean { return false; } - const allowedCategories = parseStringList(config.AUTO_DELETE_ALLOWED_CATEGORIES); + 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)); + 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 }, + { + 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); + 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"); + 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"); + logger.info( + { messageId: message.id, userId: message.user_id }, + "Auto-delete skipped: user excluded", + ); return false; } @@ -115,13 +145,21 @@ async function logAutoDeleteAttempt( action_type: "delete_message", reason: result.reason, executed_by: "auto-delete-manager", - status: result.deleted ? "executed" : result.reason === "dry_run" ? "executed" : "failed", + 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, + 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) }, + { + messageId: message.id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to persist auto-delete action log", ); } @@ -147,7 +185,11 @@ function isAlreadyDeletedError(error: unknown): boolean { function hasChannelMessagesApi( channel: unknown, -): channel is { messages: { fetch: (id: string) => Promise<{ delete: () => Promise }> } } { +): channel is { + messages: { + fetch: (id: string) => Promise<{ delete: () => Promise }>; + }; +} { return Boolean( channel && typeof channel === "object" && @@ -160,12 +202,17 @@ function hasChannelMessagesApi( function hasPermissionApi( channel: unknown, -): channel is { permissionsFor: (member: unknown) => { has: (permission: string) => boolean } | null } { +): 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", + typeof (channel as { permissionsFor?: unknown }).permissionsFor === + "function", ); } @@ -178,19 +225,30 @@ export async function attemptAutoDeleteFlaggedMessage( } if (message.ai_status !== "flagged" && message.ai_status !== "warn") { - const result = { deleted: false, skipped: true, reason: "not_flagged_or_warn" } as AutoDeleteResult; + 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; + 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"); + logger.warn( + { messageId: message.id }, + "Auto-delete skipped: client user missing", + ); return { deleted: false, skipped: true, reason: "client_user_missing" }; } @@ -232,11 +290,19 @@ export async function attemptAutoDeleteFlaggedMessage( { 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" }; + 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; + const result = { + deleted: false, + skipped: true, + reason: "dry_run", + } as AutoDeleteResult; await logAutoDeleteAttempt(message, result); logger.info( { messageId: message.id, channelId }, @@ -248,7 +314,11 @@ export async function attemptAutoDeleteFlaggedMessage( const discordMessage = await channel.messages.fetch(message.id); await discordMessage.delete(); - const result = { deleted: true, skipped: false, reason: "deleted" } as AutoDeleteResult; + const result = { + deleted: true, + skipped: false, + reason: "deleted", + } as AutoDeleteResult; await logAutoDeleteAttempt(message, result); logger.info( { messageId: message.id, channelId }, @@ -257,7 +327,11 @@ export async function attemptAutoDeleteFlaggedMessage( return result; } catch (error) { if (isAlreadyDeletedError(error)) { - const result = { deleted: true, skipped: false, reason: "already_deleted" } as AutoDeleteResult; + const result = { + deleted: true, + skipped: false, + reason: "already_deleted", + } as AutoDeleteResult; await logAutoDeleteAttempt(message, result); logger.info( { messageId: message.id, code: getErrorCode(error) }, @@ -266,7 +340,11 @@ export async function attemptAutoDeleteFlaggedMessage( return result; } - const result = { deleted: false, skipped: true, reason: "error" } as AutoDeleteResult; + const result = { + deleted: false, + skipped: true, + reason: "error", + } as AutoDeleteResult; await logAutoDeleteAttempt(message, result); logger.error( { diff --git a/src/moderation/conversationContext.ts b/src/moderation/conversationContext.ts index bb8bb1e..42fddc8 100644 --- a/src/moderation/conversationContext.ts +++ b/src/moderation/conversationContext.ts @@ -51,7 +51,10 @@ export async function buildConversationContext( const targetLines = await Promise.all( targets.map((msg) => formatMessageForPrompt(msg, "target")), ); - let usedTokens = targetLines.reduce((sum, line) => sum + estimateTokens(line), 0); + let usedTokens = targetLines.reduce( + (sum, line) => sum + estimateTokens(line), + 0, + ); const selectedContextLines: string[] = []; diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index 5138ce4..244e7dd 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -53,10 +53,13 @@ export function normalizeDiscordCustomEmoji(text: string): { emojiNames: string[]; } { const emojiNames: string[] = []; - const normalized = text.replace(CUSTOM_EMOJI_PATTERN, (_match, name: string) => { - emojiNames.push(name); - return `[emoji:${name}]`; - }); + const normalized = text.replace( + CUSTOM_EMOJI_PATTERN, + (_match, name: string) => { + emojiNames.push(name); + return `[emoji:${name}]`; + }, + ); return { text: normalized, emojiNames }; } @@ -82,16 +85,47 @@ export function normalizeIndonesianSlang(text: string): { // --------------------------------------------------------------------------- const LOCAL_BADWORDS = [ - "anjing", "bangsat", "brengsek", "bajingan", "kontol", "memek", - "tai", "goblok", "tolol", "bego", "sialan", "jancuk", "kampret", - "pepek", "jembut", "ngentot", "ngewe", "coli", "celaka", "laknat", - "pantek", "entod", "ndasmu", "ndas", "piyo", "asu", + "anjing", + "bangsat", + "brengsek", + "bajingan", + "kontol", + "memek", + "tai", + "goblok", + "tolol", + "bego", + "sialan", + "jancuk", + "kampret", + "pepek", + "jembut", + "ngentot", + "ngewe", + "coli", + "celaka", + "laknat", + "pantek", + "entod", + "ndasmu", + "ndas", + "piyo", + "asu", ]; const FALSE_POSITIVE_WHITELISTS: Record = { asu: [ - "asus", "masuk", "termasuk", "dimasukkan", "memasukkan", - "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan", + "asus", + "masuk", + "termasuk", + "dimasukkan", + "memasukkan", + "kasur", + "asumsi", + "asuransi", + "asupan", + "pasukan", + "pasundan", ], goblok: ["goblok"], kontol: ["kontol"], @@ -199,7 +233,9 @@ async function callNemotronContentSafety(text: string): Promise { * Detect badwords in text using NVIDIA Nemotron-3 Content Safety API. * Falls back to local lexical list if API key is missing or call fails. */ -export async function detectIndonesianBadwords(text: string): Promise { +export async function detectIndonesianBadwords( + text: string, +): Promise { // Always run local detection first (fast, no network dependency) const localHits = detectLocalBadwords(text); @@ -211,7 +247,10 @@ export async function detectIndonesianBadwords(text: string): Promise const allHits = Array.from(new Set([...localHits, ...apiCategories])); return allHits; } catch (error) { - log.warn({ error }, "NVIDIA Nemotron API call failed, falling back to local detection"); + log.warn( + { error }, + "NVIDIA Nemotron API call failed, falling back to local detection", + ); } } @@ -222,7 +261,9 @@ export async function detectIndonesianBadwords(text: string): Promise // Async evidence builders // --------------------------------------------------------------------------- -export async function buildModerationTextEvidence(text: string): Promise { +export async function buildModerationTextEvidence( + text: string, +): Promise { const emojiNormalized = normalizeDiscordCustomEmoji(text); const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text); const badwordHits = await detectIndonesianBadwords(slangNormalized.text); @@ -249,7 +290,9 @@ export async function buildModerationTextEvidence(text: string): Promise { +export async function formatModerationTextEvidenceForPrompt( + text: string, +): Promise { const evidence = await buildModerationTextEvidence(text); if (evidence.normalized === evidence.raw && evidence.notes.length === 0) { return ""; @@ -257,7 +300,9 @@ export async function formatModerationTextEvidenceForPrompt(text: string): Promi return [ `[normalized_text: ${evidence.normalized}]`, - evidence.notes.length > 0 ? `[normalization_notes: ${evidence.notes.join("; ")}]` : null, + evidence.notes.length > 0 + ? `[normalization_notes: ${evidence.notes.join("; ")}]` + : null, ] .filter(Boolean) .join(" "); diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts index 5f42ccf..947fefe 100644 --- a/src/moderation/messageCapture.ts +++ b/src/moderation/messageCapture.ts @@ -34,7 +34,11 @@ export function shouldCaptureMessageLocation( message: MessageLocationInput, target: TextCaptureTarget, ): boolean { - if (message.channelId === "1310988070996414494" || message.channelId === "1265679542144467035") return false; // Skip specific channels + if ( + message.channelId === "1310988070996414494" || + message.channelId === "1265679542144467035" + ) + 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; diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index a3c6ce5..8c0146d 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -404,7 +404,9 @@ interface AIAnalysisUpdate { error?: string | null; } -function stringifyAIList(value: string[] | string | null | undefined): string | null { +function stringifyAIList( + value: string[] | string | null | undefined, +): string | null { if (value == null) return null; return Array.isArray(value) ? JSON.stringify(value) : value; } @@ -867,7 +869,9 @@ export async function createMessageReview( } } -export async function getMessageReview(id: string): Promise { +export async function getMessageReview( + id: string, +): Promise { try { const database = db(); const rows = await database @@ -878,7 +882,10 @@ export async function getMessageReview(id: string): Promise 0 ? and(...conditions) : undefined) - .orderBy(desc(messageReviewsTable.created_at), desc(messageReviewsTable.id)) + .orderBy( + desc(messageReviewsTable.created_at), + desc(messageReviewsTable.id), + ) .limit(limit + 1); return pageRows(rows, limit); @@ -946,7 +956,10 @@ export async function updateMessageReview( return rows[0] || null; } catch (error) { logger.error( - { reviewId: id, error: error instanceof Error ? error.message : String(error) }, + { + reviewId: id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update message review", ); throw error; @@ -986,7 +999,9 @@ export async function createModerationAction( } } -export async function getModerationAction(id: string): Promise { +export async function getModerationAction( + id: string, +): Promise { try { const database = db(); const rows = await database @@ -997,7 +1012,10 @@ export async function getModerationAction(id: string): Promise 0 ? and(...conditions) : undefined) - .orderBy(desc(moderationActionsTable.created_at), desc(moderationActionsTable.id)) + .orderBy( + desc(moderationActionsTable.created_at), + desc(moderationActionsTable.id), + ) .limit(limit + 1); return pageRows(rows, limit); @@ -1061,7 +1082,10 @@ export async function updateModerationAction( return rows[0] || null; } catch (error) { logger.error( - { actionId: id, error: error instanceof Error ? error.message : String(error) }, + { + actionId: id, + error: error instanceof Error ? error.message : String(error), + }, "Failed to update moderation action", ); throw error; @@ -1071,7 +1095,9 @@ export async function updateModerationAction( // Retention Policies CRUD // ======================= -export async function getRetentionPolicy(guildId: string): Promise { +export async function getRetentionPolicy( + guildId: string, +): Promise { try { const database = db(); const rows = await database @@ -1082,7 +1108,10 @@ export async function getRetentionPolicy(guildId: string): Promise = { krn: { normalized: "karena", note: "common abbreviation" }, jgn: { normalized: "jangan", note: "common abbreviation" }, dh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, - udh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, - blm: { normalized: "belum", note: "common abbreviation", safeByDefault: true }, - sdh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, - dg: { normalized: "dengan", note: "common abbreviation", safeByDefault: true }, + udh: { + normalized: "sudah", + note: "common abbreviation", + safeByDefault: true, + }, + blm: { + normalized: "belum", + note: "common abbreviation", + safeByDefault: true, + }, + sdh: { + normalized: "sudah", + note: "common abbreviation", + safeByDefault: true, + }, + dg: { + normalized: "dengan", + note: "common abbreviation", + safeByDefault: true, + }, dr: { normalized: "dari", note: "common abbreviation", safeByDefault: true }, - dlm: { normalized: "dalam", note: "common abbreviation", safeByDefault: true }, + dlm: { + normalized: "dalam", + note: "common abbreviation", + safeByDefault: true, + }, gt: { normalized: "gitu", note: "common abbreviation", safeByDefault: true }, - doang: { normalized: "doang", note: "Indonesian 'only/just'", safeByDefault: true }, + doang: { + normalized: "doang", + note: "Indonesian 'only/just'", + safeByDefault: true, + }, si: { normalized: "si", note: "Indonesian particle", safeByDefault: true }, - kah: { normalized: "kah", note: "Indonesian question particle", safeByDefault: true }, - ku: { normalized: "aku", note: "first-person informal pronoun", safeByDefault: true }, - mu: { normalized: "kamu", note: "second-person informal pronoun suffix", safeByDefault: true }, - nya: { normalized: "nya", note: "Indonesian possessive suffix", safeByDefault: true }, + kah: { + normalized: "kah", + note: "Indonesian question particle", + safeByDefault: true, + }, + ku: { + normalized: "aku", + note: "first-person informal pronoun", + safeByDefault: true, + }, + mu: { + normalized: "kamu", + note: "second-person informal pronoun suffix", + safeByDefault: true, + }, + nya: { + normalized: "nya", + note: "Indonesian possessive suffix", + safeByDefault: true, + }, // ========================================================================= // Profanity — consonant-dropped / vowelless slang @@ -41,17 +81,29 @@ export const INDONESIAN_SLANG_LEXICON: Record = { ajg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, anjg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, njing: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, - aj: { normalized: "anjing", note: "ultra-short slang for 'anjing' (profanity)" }, + aj: { + normalized: "anjing", + note: "ultra-short slang for 'anjing' (profanity)", + }, anj: { normalized: "anjing", note: "short slang for 'anjing' (profanity)" }, anjingg: { normalized: "anjing", note: "elongated 'anjing' (profanity)" }, - ajgg: { normalized: "anjing", note: "vowelless + elongated 'anjing' (profanity)" }, + ajgg: { + normalized: "anjing", + note: "vowelless + elongated 'anjing' (profanity)", + }, // anjir variants anjir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, njir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, njr: { normalized: "anjing", note: "vowelless 'anjir' (profanity)" }, anjay: { normalized: "anjing", note: "slang 'anjay' (profanity-adjacent)" }, - bjir: { normalized: "anjing", note: "slang interjection 'bjir' (profanity-adjacent)" }, - bjirr: { normalized: "anjing", note: "elongated 'bjir' (profanity-adjacent)" }, + bjir: { + normalized: "anjing", + note: "slang interjection 'bjir' (profanity-adjacent)", + }, + bjirr: { + normalized: "anjing", + note: "elongated 'bjir' (profanity-adjacent)", + }, // bangsat variants bgsd: { normalized: "bangsat", note: "slang for 'bangsat' (profanity)" }, bgst: { normalized: "bangsat", note: "slang for 'bangsat' (profanity)" }, @@ -91,8 +143,14 @@ export const INDONESIAN_SLANG_LEXICON: Record = { sln: { normalized: "sialan", note: "slang for 'sialan' (profanity)" }, sialann: { normalized: "sialan", note: "elongated 'sialan' (profanity)" }, // jancuk (Javanese) - jncuk: { normalized: "jancuk", note: "Javanese slang for 'jancuk' (profanity)" }, - jcuk: { normalized: "jancuk", note: "Javanese slang for 'jancuk' (profanity)" }, + jncuk: { + normalized: "jancuk", + note: "Javanese slang for 'jancuk' (profanity)", + }, + jcuk: { + normalized: "jancuk", + note: "Javanese slang for 'jancuk' (profanity)", + }, jncukk: { normalized: "jancuk", note: "elongated 'jancuk' (profanity)" }, // kampret kmprt: { normalized: "kampret", note: "slang for 'kampret' (profanity)" }, @@ -118,7 +176,10 @@ export const INDONESIAN_SLANG_LEXICON: Record = { // entod ntd: { normalized: "entod", note: "slang for 'entod' (profanity)" }, // Javanese insults - ndasmu: { normalized: "ndasmu", note: "Javanese insult 'ndasmu' (profanity)" }, + ndasmu: { + normalized: "ndasmu", + note: "Javanese insult 'ndasmu' (profanity)", + }, ndas: { normalized: "ndas", note: "Javanese insult 'ndas' (profanity)" }, // piyo (Javanese profanity) piyoo: { normalized: "piyo", note: "Javanese slang 'piyo' (profanity)" }, @@ -135,31 +196,67 @@ export const INDONESIAN_SLANG_LEXICON: Record = { // furry subculture furry: { normalized: "furry", note: "furry / prohibited topic" }, furries: { normalized: "furry", note: "furry / prohibited topic" }, - transfurry: { normalized: "transfurry", note: "transfurry / prohibited topic" }, - transfur: { normalized: "transfurry", note: "slang for 'transfurry' / prohibited topic" }, - protogen: { normalized: "protogen", note: "furry subculture / prohibited topic" }, + transfurry: { + normalized: "transfurry", + note: "transfurry / prohibited topic", + }, + transfur: { + normalized: "transfurry", + note: "slang for 'transfurry' / prohibited topic", + }, + protogen: { + normalized: "protogen", + note: "furry subculture / prohibited topic", + }, therian: { normalized: "therian", note: "therianthropy / prohibited topic" }, - therianthropy: { normalized: "therianthropy", note: "therianthropy / prohibited topic" }, - otherkin: { normalized: "otherkin", note: "otherkin identity / prohibited topic" }, + therianthropy: { + normalized: "therianthropy", + note: "therianthropy / prohibited topic", + }, + otherkin: { + normalized: "otherkin", + note: "otherkin identity / prohibited topic", + }, // furry-adjacent terms yiff: { normalized: "yiff", note: "furry sexual content / prohibited topic" }, fursona: { normalized: "fursona", note: "furry persona / prohibited topic" }, - fursonas: { normalized: "fursona", note: "furry personas / prohibited topic" }, + fursonas: { + normalized: "fursona", + note: "furry personas / prohibited topic", + }, fursuit: { normalized: "fursuit", note: "furry costume / prohibited topic" }, - fursuits: { normalized: "fursuit", note: "furry costumes / prohibited topic" }, + fursuits: { + normalized: "fursuit", + note: "furry costumes / prohibited topic", + }, // sexual orientation terms gayy: { normalized: "gay", note: "elongated 'gay' / prohibited topic" }, lesbi: { normalized: "lesbian", note: "lesbian / prohibited topic" }, - lesbii: { normalized: "lesbian", note: "slang for 'lesbian' / prohibited topic" }, + lesbii: { + normalized: "lesbian", + note: "slang for 'lesbian' / prohibited topic", + }, homo: { normalized: "homo", note: "homosexual slur / prohibited topic" }, waria: { normalized: "waria", note: "waria / prohibited topic" }, trans: { normalized: "transgender", note: "transgender / prohibited topic" }, - nonbinary: { normalized: "nonbinary", note: "nonbinary identity / prohibited topic" }, - nb: { normalized: "nonbinary", note: "nonbinary abbreviation / prohibited topic" }, - genderfluid: { normalized: "genderfluid", note: "genderfluid / prohibited topic" }, + nonbinary: { + normalized: "nonbinary", + note: "nonbinary identity / prohibited topic", + }, + nb: { + normalized: "nonbinary", + note: "nonbinary abbreviation / prohibited topic", + }, + genderfluid: { + normalized: "genderfluid", + note: "genderfluid / prohibited topic", + }, pansexual: { normalized: "pansexual", note: "pansexual / prohibited topic" }, asexual: { normalized: "asexual", note: "asexual / prohibited topic" }, - ace: { normalized: "asexual", note: "asexual abbreviation / prohibited topic" }, + ace: { + normalized: "asexual", + note: "asexual abbreviation / prohibited topic", + }, enby: { normalized: "enby", note: "NB/nonbinary slang / prohibited topic" }, // ========================================================================= @@ -448,8 +545,14 @@ export const INDONESIAN_SLANG_LEXICON: Record = { s4s: { normalized: "sub4sub", note: "spam engagement abbreviation" }, like4like: { normalized: "like4like", note: "spam engagement" }, l4l: { normalized: "like4like", note: "spam engagement abbreviation" }, - dm: { normalized: "DM", note: "direct message — check for spam/scam context" }, - pm: { normalized: "PM", note: "private message — check for spam/scam context" }, + dm: { + normalized: "DM", + note: "direct message — check for spam/scam context", + }, + pm: { + normalized: "PM", + note: "private message — check for spam/scam context", + }, click: { normalized: "click", note: "potential clickbait/scam" }, link: { normalized: "link", note: "potential spam link — check context" }, free: { normalized: "free", note: "potential spam bait — check context" }, @@ -468,15 +571,24 @@ export const INDONESIAN_SLANG_LEXICON: Record = { narkoba: { normalized: "narkoba", note: "narcotics / prohibited topic" }, kokain: { normalized: "kokain", note: "cocaine / prohibited topic" }, ekstasi: { normalized: "ekstasi", note: "ecstasy / prohibited topic" }, - shabu: { normalized: "sabu", note: "variant spelling 'sabu' / prohibited topic" }, + shabu: { + normalized: "sabu", + note: "variant spelling 'sabu' / prohibited topic", + }, // ========================================================================= // Violence / threat indicators // ========================================================================= bunuh: { normalized: "bunuh", note: "kill / violence indicator" }, - bunuhdiri: { normalized: "bunuh diri", note: "suicide / self-harm indicator" }, + bunuhdiri: { + normalized: "bunuh diri", + note: "suicide / self-harm indicator", + }, mati: { normalized: "mati", note: "die / death — context-dependent" }, - matiin: { normalized: "matikan", note: "turn off / kill — context-dependent" }, + matiin: { + normalized: "matikan", + note: "turn off / kill — context-dependent", + }, ancam: { normalized: "ancam", note: "threat indicator" }, ancamn: { normalized: "ancaman", note: "threat indicator" }, bakar: { normalized: "bakar", note: "burn / violence indicator" }, @@ -586,7 +698,11 @@ export const INDONESIAN_SLANG_LEXICON: Record = { porn: { normalized: "porno", note: "pornography / NSFW content" }, porno: { normalized: "porno", note: "pornography / NSFW content" }, bokep: { normalized: "bokep", note: "pornography / NSFW content" }, - bokap: { normalized: "bokap", note: "'father' slang — NOT bokep", safeByDefault: true }, + bokap: { + normalized: "bokap", + note: "'father' slang — NOT bokep", + safeByDefault: true, + }, ngeseks: { normalized: "ngeseks", note: "having sex / NSFW content" }, masturbasi: { normalized: "masturbasi", note: "masturbation / NSFW content" }, onani: { normalized: "onani", note: "masturbation / NSFW content" }, @@ -634,8 +750,14 @@ export const INDONESIAN_SLANG_LEXICON: Record = { // ========================================================================= // Money / financial scam indicators // ========================================================================= - investment: { normalized: "investment", note: "potential investment scam — check context" }, - investasi: { normalized: "investasi", note: "potential investment scam — check context" }, + investment: { + normalized: "investment", + note: "potential investment scam — check context", + }, + investasi: { + normalized: "investasi", + note: "potential investment scam — check context", + }, crypto: { normalized: "crypto", note: "crypto scam — check context" }, bitcoin: { normalized: "bitcoin", note: "crypto — check context" }, btc: { normalized: "bitcoin", note: "crypto — check context" }, @@ -650,8 +772,17 @@ export const INDONESIAN_SLANG_LEXICON: Record = { claim: { normalized: "claim", note: "scam bait — check context" }, klaim: { normalized: "klaim", note: "scam bait — check context" }, verify: { normalized: "verify", note: "potential phishing — check context" }, - verifikasi: { normalized: "verifikasi", note: "potential phishing — check context" }, + verifikasi: { + normalized: "verifikasi", + note: "potential phishing — check context", + }, wallet: { normalized: "wallet", note: "crypto wallet — check context" }, - seed: { normalized: "seed phrase", note: "crypto seed phrase — check context" }, - recovery: { normalized: "recovery phrase", note: "crypto recovery — check context" }, + seed: { + normalized: "seed phrase", + note: "crypto seed phrase — check context", + }, + recovery: { + normalized: "recovery phrase", + note: "crypto recovery — check context", + }, }; diff --git a/src/moderation/retentionManager.ts b/src/moderation/retentionManager.ts index 98cdd1d..0da75fd 100644 --- a/src/moderation/retentionManager.ts +++ b/src/moderation/retentionManager.ts @@ -6,10 +6,7 @@ import { voiceRecordingsTable, } from "../database/schema.js"; import { createChildLogger } from "../logger.js"; -import { - getExpiredMessages, - getRetentionPolicy, -} from "./messageStore.js"; +import { getExpiredMessages, getRetentionPolicy } from "./messageStore.js"; import type { RetentionPolicy } from "./types.js"; import { and, eq, isNull, lt, sql } from "drizzle-orm"; @@ -152,7 +149,10 @@ export async function executeAllRetentionPolicies(): Promise<{ return summary; } catch (error) { const message = error instanceof Error ? error.message : String(error); - logger.error({ error: message }, "Failed to execute all retention policies"); + logger.error( + { error: message }, + "Failed to execute all retention policies", + ); throw error; } } @@ -161,7 +161,9 @@ export async function executeAllRetentionPolicies(): Promise<{ * Starts a periodic retention policy executor * Runs every 24 hours by default */ -export function startRetentionPolicyWorker(intervalMs: number = 24 * 60 * 60 * 1000): NodeJS.Timeout { +export function startRetentionPolicyWorker( + intervalMs: number = 24 * 60 * 60 * 1000, +): NodeJS.Timeout { logger.info({ intervalMs }, "Starting retention policy worker"); const interval = setInterval(async () => { diff --git a/src/moderation/urlFetcher.ts b/src/moderation/urlFetcher.ts index db1b207..70ff8e9 100644 --- a/src/moderation/urlFetcher.ts +++ b/src/moderation/urlFetcher.ts @@ -66,7 +66,8 @@ async function isSafeUrl(urlStr: string): Promise { function extractOgImage(html: string): string | null { // Look for or - const ogRegex = /]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; + const ogRegex = + /]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; const match = html.match(ogRegex); if (match && match[1]) { // Unescape basic HTML entities @@ -74,7 +75,8 @@ function extractOgImage(html: string): string | null { } // Try reversed attribute order: - const ogRegexRev = /]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; + const ogRegexRev = + /]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; const matchRev = html.match(ogRegexRev); if (matchRev && matchRev[1]) { return matchRev[1].replace(/&/g, "&").replace(/"/g, '"'); diff --git a/src/routes/analyticsRoutes.ts b/src/routes/analyticsRoutes.ts index 55a1853..91d68b2 100644 --- a/src/routes/analyticsRoutes.ts +++ b/src/routes/analyticsRoutes.ts @@ -17,11 +17,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24) router.get("/analytics/overview", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - } = req.query as { + const { guildId, channelId, hours } = req.query as { guildId?: string; channelId?: string; hours?: string; @@ -53,11 +49,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24) router.get("/analytics/hourly", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - } = req.query as { + const { guildId, channelId, hours } = req.query as { guildId?: string; channelId?: string; hours?: string; @@ -89,11 +81,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24) router.get("/analytics/topics", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - } = req.query as { + const { guildId, channelId, hours } = req.query as { guildId?: string; channelId?: string; hours?: string; @@ -125,12 +113,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24), limit (default 20) router.get("/analytics/leaderboard", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - limit, - } = req.query as { + const { guildId, channelId, hours, limit } = req.query as { guildId?: string; channelId?: string; hours?: string; @@ -165,11 +148,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24) router.get("/analytics/stats", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - } = req.query as { + const { guildId, channelId, hours } = req.query as { guildId?: string; channelId?: string; hours?: string; @@ -201,12 +180,7 @@ export function createAnalyticsRoutes(): Router { // Query params: guildId (required), channelId, hours (default 24), limit (default 20) router.get("/analytics/violators", async (req, res, next) => { try { - const { - guildId, - channelId, - hours, - limit, - } = req.query as { + const { guildId, channelId, hours, limit } = req.query as { guildId?: string; channelId?: string; hours?: string; diff --git a/tests/moderation/autoDeleteManager.test.ts b/tests/moderation/autoDeleteManager.test.ts index 5d37a8f..c270b3f 100644 --- a/tests/moderation/autoDeleteManager.test.ts +++ b/tests/moderation/autoDeleteManager.test.ts @@ -46,11 +46,13 @@ function createMessage(overrides: Partial = {}): MessageRecord { }; } -function createClient(options: { - canManageMessages?: boolean; - fetchError?: unknown; - deleteError?: unknown; -} = {}) { +function createClient( + options: { + canManageMessages?: boolean; + fetchError?: unknown; + deleteError?: unknown; + } = {}, +) { const deleteMock = vi.fn(async () => { if (options.deleteError) throw options.deleteError; }); @@ -113,7 +115,11 @@ describe("attemptAutoDeleteFlaggedMessage", () => { createMessage(), // defaults to flagged ); - expect(result).toEqual({ deleted: true, skipped: false, reason: "deleted" }); + expect(result).toEqual({ + deleted: true, + skipped: false, + reason: "deleted", + }); expect(fetchMessageMock).toHaveBeenCalledWith("m1"); expect(deleteMock).toHaveBeenCalledTimes(1); }); @@ -127,7 +133,11 @@ describe("attemptAutoDeleteFlaggedMessage", () => { createMessage({ ai_status: "warn" }), ); - expect(result).toEqual({ deleted: true, skipped: false, reason: "deleted" }); + expect(result).toEqual({ + deleted: true, + skipped: false, + reason: "deleted", + }); expect(fetchMessageMock).toHaveBeenCalledWith("m1"); expect(deleteMock).toHaveBeenCalledTimes(1); }); diff --git a/tests/moderation/indonesianTextNormalizer.test.ts b/tests/moderation/indonesianTextNormalizer.test.ts index aa61c25..fb771ab 100644 --- a/tests/moderation/indonesianTextNormalizer.test.ts +++ b/tests/moderation/indonesianTextNormalizer.test.ts @@ -9,7 +9,9 @@ import { describe("normalizeDiscordCustomEmoji", () => { it("replaces static custom emoji", () => { - const result = normalizeDiscordCustomEmoji("Bersiaplah woy <:hadeh:1217434294281048185>"); + const result = normalizeDiscordCustomEmoji( + "Bersiaplah woy <:hadeh:1217434294281048185>", + ); expect(result.text).toBe("Bersiaplah woy [emoji:hadeh]"); expect(result.emojiNames).toContain("hadeh"); }); @@ -61,7 +63,9 @@ describe("buildModerationTextEvidence", () => { expect(evidence.normalized).toContain("[emoji:hadeh]"); expect(evidence.badwords).toHaveLength(0); expect(evidence.hasBadwords).toBe(false); - expect(evidence.notes.some((n) => n.includes("no Indonesian badword"))).toBe(true); + expect( + evidence.notes.some((n) => n.includes("no Indonesian badword")), + ).toBe(true); expect(evidence.notes.some((n) => n.includes("emoji:hadeh"))).toBe(true); expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true); }); @@ -69,7 +73,9 @@ describe("buildModerationTextEvidence", () => { it("detects badword when present", async () => { const evidence = await buildModerationTextEvidence("anjing loe kontol"); expect(evidence.hasBadwords).toBe(true); - expect(evidence.notes.some((n) => n.includes("badword detected"))).toBe(true); + expect(evidence.notes.some((n) => n.includes("badword detected"))).toBe( + true, + ); }); }); diff --git a/tests/moderation/messageCaptureFilter.test.ts b/tests/moderation/messageCaptureFilter.test.ts index aba16ff..2c0bb1e 100644 --- a/tests/moderation/messageCaptureFilter.test.ts +++ b/tests/moderation/messageCaptureFilter.test.ts @@ -32,7 +32,7 @@ describe("shouldCaptureMessageLocation", () => { { guildId: "guild-1" }, ), ).toBe(false); - + expect( shouldCaptureMessageLocation( { guildId: "guild-1", channelId: "1265679542144467035" },