Refactor code for improved readability and consistency

- Adjusted formatting in conversationContext.ts for better token estimation readability.
- Enhanced readability in indonesianTextNormalizer.ts by formatting multiline replacements.
- Reformatted badword lists and whitelists in indonesianTextNormalizer.ts for consistency.
- Improved function signatures in messageStore.ts for clarity.
- Reformatted messageCapture.ts to enhance readability of channel ID checks.
- Cleaned up error logging in messageStore.ts and retentionManager.ts for better clarity.
- Reformatted indonesianSlangLexicon.ts for consistent object formatting.
- Enhanced URL fetching regex patterns in urlFetcher.ts for better readability.
- Simplified query parameter destructuring in analyticsRoutes.ts for cleaner code.
- Improved test readability in autoDeleteManager.test.ts and indonesianTextNormalizer.test.ts by formatting expectations.
- Cleaned up whitespace in messageCaptureFilter.test.ts for consistency.
This commit is contained in:
MythEclipse
2026-05-30 15:55:01 +07:00
parent 4fa7875b80
commit 551d11ba82
18 changed files with 697 additions and 224 deletions
+3 -1
View File
@@ -15,7 +15,9 @@ export async function initializeApp() {
const logger = createChildLogger("bot"); const logger = createChildLogger("bot");
if (!config.AI_LLM_API_KEY) { 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); process.exit(1);
} }
+22 -8
View File
@@ -493,9 +493,13 @@ export const pgMessageReviewsTable = pgTable(
reviewed_at: pgBigint("reviewed_at", { mode: "number" }), reviewed_at: pgBigint("reviewed_at", { mode: "number" }),
}, },
(table) => ({ (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), 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( guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on(
table.guild_id, table.guild_id,
table.status, table.status,
@@ -553,9 +557,14 @@ export const pgModerationActionsTable = pgTable(
user_id: pgText("user_id"), user_id: pgText("user_id"),
guild_id: pgText("guild_id").notNull(), guild_id: pgText("guild_id").notNull(),
action_type: pgText("action_type", { action_type: pgText("action_type", {
enum: ["delete_message", "mute_user", "warn_user", "kick_user", "ban_user"], enum: [
}) "delete_message",
.notNull(), "mute_user",
"warn_user",
"kick_user",
"ban_user",
],
}).notNull(),
reason: pgText("reason"), reason: pgText("reason"),
executed_by: pgText("executed_by"), executed_by: pgText("executed_by"),
status: pgText("status", { status: pgText("status", {
@@ -593,9 +602,14 @@ export const sqliteModerationActionsTable = sqliteTable(
user_id: sqliteText("user_id"), user_id: sqliteText("user_id"),
guild_id: sqliteText("guild_id").notNull(), guild_id: sqliteText("guild_id").notNull(),
action_type: sqliteText("action_type", { action_type: sqliteText("action_type", {
enum: ["delete_message", "mute_user", "warn_user", "kick_user", "ban_user"], enum: [
}) "delete_message",
.notNull(), "mute_user",
"warn_user",
"kick_user",
"ban_user",
],
}).notNull(),
reason: sqliteText("reason"), reason: sqliteText("reason"),
executed_by: sqliteText("executed_by"), executed_by: sqliteText("executed_by"),
status: sqliteText("status", { status: sqliteText("status", {
+3 -11
View File
@@ -1,9 +1,6 @@
import type { Client, Guild, User } from "discord.js-selfbot-v13"; import type { Client, Guild, User } from "discord.js-selfbot-v13";
import { createChildLogger } from "../logger.js"; import { createChildLogger } from "../logger.js";
import { import { getModerationAction, updateModerationAction } from "./messageStore.js";
getModerationAction,
updateModerationAction,
} from "./messageStore.js";
import type { ModerationAction, ModerationActionType } from "./types.js"; import type { ModerationAction, ModerationActionType } from "./types.js";
const logger = createChildLogger("action-executor"); const logger = createChildLogger("action-executor");
@@ -151,9 +148,7 @@ async function executeWarnUser(
} }
const reason = action.reason || "Warned by moderation system"; const reason = action.reason || "Warned by moderation system";
await user.send( await user.send(`You have been warned in ${guild.name}. Reason: ${reason}`);
`You have been warned in ${guild.name}. Reason: ${reason}`,
);
logger.info( logger.info(
{ userId: action.user_id, guildId: guild.id }, { userId: action.user_id, guildId: guild.id },
@@ -257,10 +252,7 @@ export async function processPendingActions(
} }
} }
logger.info( logger.info({ guildId, ...result }, "Processed pending moderation actions");
{ guildId, ...result },
"Processed pending moderation actions",
);
return result; return result;
} catch (error) { } catch (error) {
+9 -6
View File
@@ -45,12 +45,15 @@ export default async function processAnalysisRequest({
messages, messages,
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> { }: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
if (!config.AI_LLM_API_KEY) { if (!config.AI_LLM_API_KEY) {
console.error(JSON.stringify({ console.error(
level: "FATAL", JSON.stringify({
context: "aiAnalysisWorker", level: "FATAL",
error: "AI_LLM_API_KEY is missing from environment. Force closing worker operation.", context: "aiAnalysisWorker",
timestamp: new Date().toISOString(), error:
})); "AI_LLM_API_KEY is missing from environment. Force closing worker operation.",
timestamp: new Date().toISOString(),
}),
);
process.exit(1); process.exit(1);
} }
+1 -3
View File
@@ -7,9 +7,7 @@ import { config } from "../config.js";
import { createChildLogger } from "../logger.js"; import { createChildLogger } from "../logger.js";
import { retryWithBackoff } from "../retry.js"; import { retryWithBackoff } from "../retry.js";
import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js";
import { import { buildConversationContext } from "./conversationContext.js";
buildConversationContext,
} from "./conversationContext.js";
import { runModerationAnalysis } from "./llmModerationClient.js"; import { runModerationAnalysis } from "./llmModerationClient.js";
import { import {
getAttachmentsForMessages, getAttachmentsForMessages,
+226 -49
View File
@@ -110,7 +110,13 @@ export async function getHourlyStats(input: {
// Initialize all hour buckets // Initialize all hour buckets
const buckets = new Map< const buckets = new Map<
string, 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++) { for (let h = 0; h < hours; h++) {
@@ -151,23 +157,156 @@ export async function getHourlyStats(input: {
// ── Topic Trends ─────────────────────────────────────────────────────── // ── Topic Trends ───────────────────────────────────────────────────────
const STOP_WORDS = new Set([ const STOP_WORDS = new Set([
"yang", "dan", "itu", "ini", "dengan", "akan", "pada", "dari", "di", "ke", "yang",
"untuk", "tidak", "ada", "juga", "sudah", "saya", "kamu", "dia", "mereka", "dan",
"kami", "aku", "lo", "lu", "gua", "gue", "org", "orang", "aja", "sama", "itu",
"kalo", "kalau", "bisa", "karena", "gak", "nggak", "ga", "tak", "belum", "ini",
"udah", "dah", "lah", "kah", "pun", "nih", "tuh", "deh", "dong", "si", "dengan",
"nya", "kan", "ya", "yah", "yuk", "kok", "loh", "nah", "wow", "eh", "akan",
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "pada",
"have", "has", "had", "having", "do", "does", "did", "doing", "dari",
"will", "would", "could", "should", "may", "might", "must", "shall", "di",
"i", "you", "he", "she", "it", "we", "they", "me", "him", "her", "ke",
"us", "them", "my", "your", "his", "its", "our", "their", "untuk",
"and", "but", "or", "nor", "not", "so", "yet", "for", "if", "tidak",
"to", "of", "in", "on", "at", "by", "as", "with", "about", "ada",
"just", "then", "now", "here", "there", "when", "where", "why", "juga",
"how", "all", "both", "each", "few", "more", "most", "other", "sudah",
"some", "such", "only", "own", "same", "too", "very", "can", "saya",
"go", "ok", "okay", "yeah", "yes", "no", "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[] { function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
@@ -182,22 +321,36 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
const topics = analysis.topics; const topics = analysis.topics;
if (topics && Array.isArray(topics)) { if (topics && Array.isArray(topics)) {
for (const topic of 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; if (!key) continue;
const k = key.toLowerCase(); const k = key.toLowerCase();
const score = msg.ai_moderation_score || 0; const score = msg.ai_moderation_score || 0;
const existing = topicScores.get(k); const existing = topicScores.get(k);
if (existing) { existing.count++; existing.score += score; } if (existing) {
else { topicScores.set(k, { count: 1, score }); } existing.count++;
existing.score += score;
} else {
topicScores.set(k, { count: 1, score });
}
} }
} }
if (analysis.category) { if (analysis.category) {
const cat = String(analysis.category).toLowerCase(); const cat = String(analysis.category).toLowerCase();
const existing = topicScores.get(cat); const existing = topicScores.get(cat);
if (existing) { existing.count++; existing.score += msg.ai_moderation_score || 0; } if (existing) {
else { topicScores.set(cat, { count: 1, score: msg.ai_moderation_score || 0 }); } 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) { if (msg.content) {
@@ -227,7 +380,11 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] {
for (const [word, count] of sortedWords) { for (const [word, count] of sortedWords) {
if (!topicScores.has(word)) { 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++; existing.message_count++;
if (msg.type === "edited") existing.edited_count++; if (msg.type === "edited") existing.edited_count++;
if (msg.type === "deleted") existing.deleted_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) { if (msg.created_at > existing.last_active) {
existing.last_active = msg.created_at; existing.last_active = msg.created_at;
} }
@@ -320,7 +478,8 @@ export async function getUserLeaderboard(input: {
message_count: 1, message_count: 1,
edited_count: msg.type === "edited" ? 1 : 0, edited_count: msg.type === "edited" ? 1 : 0,
deleted_count: msg.type === "deleted" ? 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, last_active: msg.created_at,
}); });
} }
@@ -367,7 +526,11 @@ export async function getModerationStats(input: {
const breakdown: ModerationBreakdown = { const breakdown: ModerationBreakdown = {
total: rows.length, 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, average_score: 0,
}; };
@@ -398,7 +561,13 @@ export async function getModerationStats(input: {
"Failed to get moderation stats", "Failed to get moderation stats",
); );
return { 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; flagged_count: number;
warned_count: number; warned_count: number;
violation_score: number; // weighted: flagged*3 + warned*1 violation_score: number; // weighted: flagged*3 + warned*1
worst_flags: string[]; // unique flag types worst_flags: string[]; // unique flag types
last_violation: number; last_violation: number;
} }
@@ -477,16 +646,19 @@ export async function getTopViolators(input: {
.where(and(...conditions) as SQL) .where(and(...conditions) as SQL)
.orderBy(asc(messagesTable.created_at))) as MessageRecord[]; .orderBy(asc(messagesTable.created_at))) as MessageRecord[];
const userMap = new Map<string, { const userMap = new Map<
user_id: string; string,
username: string; {
avatar_url: string | null; user_id: string;
total_messages: number; username: string;
flagged_count: number; avatar_url: string | null;
warned_count: number; total_messages: number;
flags_set: Set<string>; flagged_count: number;
last_violation: number; warned_count: number;
}>(); flags_set: Set<string>;
last_violation: number;
}
>();
for (const msg of rows) { for (const msg of rows) {
let entry = userMap.get(msg.user_id); let entry = userMap.get(msg.user_id);
@@ -506,7 +678,8 @@ export async function getTopViolators(input: {
entry.total_messages++; 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") { if (msg.ai_status === "flagged") {
entry.flagged_count++; entry.flagged_count++;
@@ -522,7 +695,9 @@ export async function getTopViolators(input: {
if (Array.isArray(flags)) { if (Array.isArray(flags)) {
for (const f of flags) entry.flags_set.add(String(f)); for (const f of flags) entry.flags_set.add(String(f));
} }
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
if (isViolation && msg.created_at > entry.last_violation) { if (isViolation && msg.created_at > entry.last_violation) {
@@ -571,13 +746,15 @@ export async function getAnalyticsOverview(input: {
const now = Date.now(); const now = Date.now();
const since = now - hours * 3600_000; const since = now - hours * 3600_000;
const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all([ const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all(
getModerationStats(input), [
getHourlyStats(input), getModerationStats(input),
getTopicTrends(input), getHourlyStats(input),
getUserLeaderboard(input), getTopicTrends(input),
getActiveChannelCount({ guildId, hours }), getUserLeaderboard(input),
]); getActiveChannelCount({ guildId, hours }),
],
);
return { return {
period: { start: since, end: now }, period: { start: since, end: now },
+104 -26
View File
@@ -10,7 +10,9 @@ const parseStringList = (value?: string | null): string[] => {
if (!value) return []; if (!value) return [];
try { try {
const parsed = JSON.parse(value) as unknown; 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 { } catch {
return value return value
.split(",") .split(",")
@@ -23,7 +25,8 @@ const parseStringList = (value?: string | null): string[] => {
function deriveSeverity(msg: MessageRecord): string { function deriveSeverity(msg: MessageRecord): string {
if (msg.ai_severity) return msg.ai_severity; if (msg.ai_severity) return msg.ai_severity;
const score = msg.ai_confidence ?? msg.ai_moderation_score ?? 0; 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"; if (msg.ai_status === "warn") return score >= 0.6 ? "medium" : "low";
return "none"; return "none";
} }
@@ -32,19 +35,28 @@ function deriveSeverity(msg: MessageRecord): string {
function deriveRecommendedAction(msg: MessageRecord): string { function deriveRecommendedAction(msg: MessageRecord): string {
if (msg.ai_recommended_action) return msg.ai_recommended_action; if (msg.ai_recommended_action) return msg.ai_recommended_action;
const severity = deriveSeverity(msg); 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 === "flagged") return "review";
if (msg.ai_status === "warn") return "warn"; if (msg.ai_status === "warn") return "warn";
return "none"; return "none";
} }
function isAutoDeleteEligible(message: MessageRecord): boolean { 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; const confidence = message.ai_confidence ?? message.ai_moderation_score ?? 0;
if (confidence < config.AUTO_DELETE_MIN_CONFIDENCE) { if (confidence < config.AUTO_DELETE_MIN_CONFIDENCE) {
logger.info( 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", "Auto-delete skipped: confidence below threshold",
); );
return false; return false;
@@ -72,31 +84,49 @@ function isAutoDeleteEligible(message: MessageRecord): boolean {
return false; return false;
} }
const allowedCategories = parseStringList(config.AUTO_DELETE_ALLOWED_CATEGORIES); const allowedCategories = parseStringList(
config.AUTO_DELETE_ALLOWED_CATEGORIES,
);
if (allowedCategories.length > 0) { if (allowedCategories.length > 0) {
const messageCategories = parseStringList(message.ai_categories ?? message.ai_moderation_flags); const messageCategories = parseStringList(
const hasAllowedCategory = messageCategories.some((cat) => allowedCategories.includes(cat)); message.ai_categories ?? message.ai_moderation_flags,
);
const hasAllowedCategory = messageCategories.some((cat) =>
allowedCategories.includes(cat),
);
if (!hasAllowedCategory) { if (!hasAllowedCategory) {
logger.info( logger.info(
{ messageId: message.id, categories: messageCategories, allowed: allowedCategories }, {
messageId: message.id,
categories: messageCategories,
allowed: allowedCategories,
},
"Auto-delete skipped: no allowed categories match", "Auto-delete skipped: no allowed categories match",
); );
return false; return false;
} }
} }
const excludedChannels = parseStringList(config.AUTO_DELETE_EXCLUDED_CHANNEL_IDS); const excludedChannels = parseStringList(
config.AUTO_DELETE_EXCLUDED_CHANNEL_IDS,
);
if (excludedChannels.length > 0) { if (excludedChannels.length > 0) {
const channelId = message.thread_id ?? message.channel_id; const channelId = message.thread_id ?? message.channel_id;
if (excludedChannels.includes(channelId)) { 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; return false;
} }
} }
const excludedUsers = parseStringList(config.AUTO_DELETE_EXCLUDED_USER_IDS); const excludedUsers = parseStringList(config.AUTO_DELETE_EXCLUDED_USER_IDS);
if (excludedUsers.length > 0 && excludedUsers.includes(message.user_id)) { 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; return false;
} }
@@ -115,13 +145,21 @@ async function logAutoDeleteAttempt(
action_type: "delete_message", action_type: "delete_message",
reason: result.reason, reason: result.reason,
executed_by: "auto-delete-manager", 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, 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) { } catch (error) {
logger.warn( 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", "Failed to persist auto-delete action log",
); );
} }
@@ -147,7 +185,11 @@ function isAlreadyDeletedError(error: unknown): boolean {
function hasChannelMessagesApi( function hasChannelMessagesApi(
channel: unknown, channel: unknown,
): channel is { messages: { fetch: (id: string) => Promise<{ delete: () => Promise<unknown> }> } } { ): channel is {
messages: {
fetch: (id: string) => Promise<{ delete: () => Promise<unknown> }>;
};
} {
return Boolean( return Boolean(
channel && channel &&
typeof channel === "object" && typeof channel === "object" &&
@@ -160,12 +202,17 @@ function hasChannelMessagesApi(
function hasPermissionApi( function hasPermissionApi(
channel: unknown, channel: unknown,
): channel is { permissionsFor: (member: unknown) => { has: (permission: string) => boolean } | null } { ): channel is {
permissionsFor: (
member: unknown,
) => { has: (permission: string) => boolean } | null;
} {
return Boolean( return Boolean(
channel && channel &&
typeof channel === "object" && typeof channel === "object" &&
"permissionsFor" in channel && "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") { 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); await logAutoDeleteAttempt(message, result);
return result; return result;
} }
if (!isAutoDeleteEligible(message)) { 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); await logAutoDeleteAttempt(message, result);
return result; return result;
} }
if (!client?.user?.id) { 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" }; 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 }, { messageId: message.id, channelId, userId: client.user.id },
"Auto-delete skipped: current user lacks Manage Messages", "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) { 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); await logAutoDeleteAttempt(message, result);
logger.info( logger.info(
{ messageId: message.id, channelId }, { messageId: message.id, channelId },
@@ -248,7 +314,11 @@ export async function attemptAutoDeleteFlaggedMessage(
const discordMessage = await channel.messages.fetch(message.id); const discordMessage = await channel.messages.fetch(message.id);
await discordMessage.delete(); 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); await logAutoDeleteAttempt(message, result);
logger.info( logger.info(
{ messageId: message.id, channelId }, { messageId: message.id, channelId },
@@ -257,7 +327,11 @@ export async function attemptAutoDeleteFlaggedMessage(
return result; return result;
} catch (error) { } catch (error) {
if (isAlreadyDeletedError(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); await logAutoDeleteAttempt(message, result);
logger.info( logger.info(
{ messageId: message.id, code: getErrorCode(error) }, { messageId: message.id, code: getErrorCode(error) },
@@ -266,7 +340,11 @@ export async function attemptAutoDeleteFlaggedMessage(
return result; 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); await logAutoDeleteAttempt(message, result);
logger.error( logger.error(
{ {
+4 -1
View File
@@ -51,7 +51,10 @@ export async function buildConversationContext(
const targetLines = await Promise.all( const targetLines = await Promise.all(
targets.map((msg) => formatMessageForPrompt(msg, "target")), 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[] = []; const selectedContextLines: string[] = [];
+60 -15
View File
@@ -53,10 +53,13 @@ export function normalizeDiscordCustomEmoji(text: string): {
emojiNames: string[]; emojiNames: string[];
} { } {
const emojiNames: string[] = []; const emojiNames: string[] = [];
const normalized = text.replace(CUSTOM_EMOJI_PATTERN, (_match, name: string) => { const normalized = text.replace(
emojiNames.push(name); CUSTOM_EMOJI_PATTERN,
return `[emoji:${name}]`; (_match, name: string) => {
}); emojiNames.push(name);
return `[emoji:${name}]`;
},
);
return { text: normalized, emojiNames }; return { text: normalized, emojiNames };
} }
@@ -82,16 +85,47 @@ export function normalizeIndonesianSlang(text: string): {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LOCAL_BADWORDS = [ const LOCAL_BADWORDS = [
"anjing", "bangsat", "brengsek", "bajingan", "kontol", "memek", "anjing",
"tai", "goblok", "tolol", "bego", "sialan", "jancuk", "kampret", "bangsat",
"pepek", "jembut", "ngentot", "ngewe", "coli", "celaka", "laknat", "brengsek",
"pantek", "entod", "ndasmu", "ndas", "piyo", "asu", "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<string, string[]> = { const FALSE_POSITIVE_WHITELISTS: Record<string, string[]> = {
asu: [ asu: [
"asus", "masuk", "termasuk", "dimasukkan", "memasukkan", "asus",
"kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan", "masuk",
"termasuk",
"dimasukkan",
"memasukkan",
"kasur",
"asumsi",
"asuransi",
"asupan",
"pasukan",
"pasundan",
], ],
goblok: ["goblok"], goblok: ["goblok"],
kontol: ["kontol"], kontol: ["kontol"],
@@ -199,7 +233,9 @@ async function callNemotronContentSafety(text: string): Promise<string[]> {
* Detect badwords in text using NVIDIA Nemotron-3 Content Safety API. * 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. * Falls back to local lexical list if API key is missing or call fails.
*/ */
export async function detectIndonesianBadwords(text: string): Promise<string[]> { export async function detectIndonesianBadwords(
text: string,
): Promise<string[]> {
// Always run local detection first (fast, no network dependency) // Always run local detection first (fast, no network dependency)
const localHits = detectLocalBadwords(text); const localHits = detectLocalBadwords(text);
@@ -211,7 +247,10 @@ export async function detectIndonesianBadwords(text: string): Promise<string[]>
const allHits = Array.from(new Set([...localHits, ...apiCategories])); const allHits = Array.from(new Set([...localHits, ...apiCategories]));
return allHits; return allHits;
} catch (error) { } 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<string[]>
// Async evidence builders // Async evidence builders
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function buildModerationTextEvidence(text: string): Promise<ModerationTextEvidence> { export async function buildModerationTextEvidence(
text: string,
): Promise<ModerationTextEvidence> {
const emojiNormalized = normalizeDiscordCustomEmoji(text); const emojiNormalized = normalizeDiscordCustomEmoji(text);
const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text); const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text);
const badwordHits = await detectIndonesianBadwords(slangNormalized.text); const badwordHits = await detectIndonesianBadwords(slangNormalized.text);
@@ -249,7 +290,9 @@ export async function buildModerationTextEvidence(text: string): Promise<Moderat
}; };
} }
export async function formatModerationTextEvidenceForPrompt(text: string): Promise<string> { export async function formatModerationTextEvidenceForPrompt(
text: string,
): Promise<string> {
const evidence = await buildModerationTextEvidence(text); const evidence = await buildModerationTextEvidence(text);
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) { if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
return ""; return "";
@@ -257,7 +300,9 @@ export async function formatModerationTextEvidenceForPrompt(text: string): Promi
return [ return [
`[normalized_text: ${evidence.normalized}]`, `[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) .filter(Boolean)
.join(" "); .join(" ");
+5 -1
View File
@@ -34,7 +34,11 @@ export function shouldCaptureMessageLocation(
message: MessageLocationInput, message: MessageLocationInput,
target: TextCaptureTarget, target: TextCaptureTarget,
): boolean { ): 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 (!message.guildId || message.guildId !== target.guildId) return false;
if (target.channelId && message.channelId !== target.channelId) return false; if (target.channelId && message.channelId !== target.channelId) return false;
return true; return true;
+44 -12
View File
@@ -404,7 +404,9 @@ interface AIAnalysisUpdate {
error?: string | null; 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; if (value == null) return null;
return Array.isArray(value) ? JSON.stringify(value) : value; return Array.isArray(value) ? JSON.stringify(value) : value;
} }
@@ -867,7 +869,9 @@ export async function createMessageReview(
} }
} }
export async function getMessageReview(id: string): Promise<MessageReview | null> { export async function getMessageReview(
id: string,
): Promise<MessageReview | null> {
try { try {
const database = db(); const database = db();
const rows = await database const rows = await database
@@ -878,7 +882,10 @@ export async function getMessageReview(id: string): Promise<MessageReview | null
return (rows[0] as MessageReview) || null; return (rows[0] as MessageReview) || null;
} catch (error) { } catch (error) {
logger.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 get message review", "Failed to get message review",
); );
throw error; throw error;
@@ -918,7 +925,10 @@ export async function listMessageReviews(query: {
.select() .select()
.from(messageReviewsTable) .from(messageReviewsTable)
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(messageReviewsTable.created_at), desc(messageReviewsTable.id)) .orderBy(
desc(messageReviewsTable.created_at),
desc(messageReviewsTable.id),
)
.limit(limit + 1); .limit(limit + 1);
return pageRows<MessageReview>(rows, limit); return pageRows<MessageReview>(rows, limit);
@@ -946,7 +956,10 @@ export async function updateMessageReview(
return rows[0] || null; return rows[0] || null;
} catch (error) { } catch (error) {
logger.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", "Failed to update message review",
); );
throw error; throw error;
@@ -986,7 +999,9 @@ export async function createModerationAction(
} }
} }
export async function getModerationAction(id: string): Promise<ModerationAction | null> { export async function getModerationAction(
id: string,
): Promise<ModerationAction | null> {
try { try {
const database = db(); const database = db();
const rows = await database const rows = await database
@@ -997,7 +1012,10 @@ export async function getModerationAction(id: string): Promise<ModerationAction
return (rows[0] as ModerationAction) || null; return (rows[0] as ModerationAction) || null;
} catch (error) { } catch (error) {
logger.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 get moderation action", "Failed to get moderation action",
); );
throw error; throw error;
@@ -1033,7 +1051,10 @@ export async function listModerationActions(query: {
.select() .select()
.from(moderationActionsTable) .from(moderationActionsTable)
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(moderationActionsTable.created_at), desc(moderationActionsTable.id)) .orderBy(
desc(moderationActionsTable.created_at),
desc(moderationActionsTable.id),
)
.limit(limit + 1); .limit(limit + 1);
return pageRows<ModerationAction>(rows, limit); return pageRows<ModerationAction>(rows, limit);
@@ -1061,7 +1082,10 @@ export async function updateModerationAction(
return rows[0] || null; return rows[0] || null;
} catch (error) { } catch (error) {
logger.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", "Failed to update moderation action",
); );
throw error; throw error;
@@ -1071,7 +1095,9 @@ export async function updateModerationAction(
// Retention Policies CRUD // Retention Policies CRUD
// ======================= // =======================
export async function getRetentionPolicy(guildId: string): Promise<RetentionPolicy | null> { export async function getRetentionPolicy(
guildId: string,
): Promise<RetentionPolicy | null> {
try { try {
const database = db(); const database = db();
const rows = await database const rows = await database
@@ -1082,7 +1108,10 @@ export async function getRetentionPolicy(guildId: string): Promise<RetentionPoli
return (rows[0] as RetentionPolicy) || null; return (rows[0] as RetentionPolicy) || null;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ guildId, error: error instanceof Error ? error.message : String(error) }, {
guildId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get retention policy", "Failed to get retention policy",
); );
throw error; throw error;
@@ -1155,7 +1184,10 @@ export async function getExpiredMessages(
return rows as MessageRecord[]; return rows as MessageRecord[];
} catch (error) { } catch (error) {
logger.error( logger.error(
{ retentionDays, error: error instanceof Error ? error.message : String(error) }, {
retentionDays,
error: error instanceof Error ? error.message : String(error),
},
"Failed to get expired messages", "Failed to get expired messages",
); );
throw error; throw error;
@@ -21,19 +21,59 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
krn: { normalized: "karena", note: "common abbreviation" }, krn: { normalized: "karena", note: "common abbreviation" },
jgn: { normalized: "jangan", note: "common abbreviation" }, jgn: { normalized: "jangan", note: "common abbreviation" },
dh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, dh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true },
udh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, udh: {
blm: { normalized: "belum", note: "common abbreviation", safeByDefault: true }, normalized: "sudah",
sdh: { normalized: "sudah", note: "common abbreviation", safeByDefault: true }, note: "common abbreviation",
dg: { normalized: "dengan", note: "common abbreviation", safeByDefault: true }, 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 }, 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 }, 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 }, si: { normalized: "si", note: "Indonesian particle", safeByDefault: true },
kah: { normalized: "kah", note: "Indonesian question particle", safeByDefault: true }, kah: {
ku: { normalized: "aku", note: "first-person informal pronoun", safeByDefault: true }, normalized: "kah",
mu: { normalized: "kamu", note: "second-person informal pronoun suffix", safeByDefault: true }, note: "Indonesian question particle",
nya: { normalized: "nya", note: "Indonesian possessive suffix", safeByDefault: true }, 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 // Profanity — consonant-dropped / vowelless slang
@@ -41,17 +81,29 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
ajg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, ajg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" },
anjg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, anjg: { normalized: "anjing", note: "slang for 'anjing' (profanity)" },
njing: { 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)" }, anj: { normalized: "anjing", note: "short slang for 'anjing' (profanity)" },
anjingg: { normalized: "anjing", note: "elongated '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 variants
anjir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, anjir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" },
njir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" }, njir: { normalized: "anjing", note: "slang for 'anjing' (profanity)" },
njr: { normalized: "anjing", note: "vowelless 'anjir' (profanity)" }, njr: { normalized: "anjing", note: "vowelless 'anjir' (profanity)" },
anjay: { normalized: "anjing", note: "slang 'anjay' (profanity-adjacent)" }, anjay: { normalized: "anjing", note: "slang 'anjay' (profanity-adjacent)" },
bjir: { normalized: "anjing", note: "slang interjection 'bjir' (profanity-adjacent)" }, bjir: {
bjirr: { normalized: "anjing", note: "elongated 'bjir' (profanity-adjacent)" }, normalized: "anjing",
note: "slang interjection 'bjir' (profanity-adjacent)",
},
bjirr: {
normalized: "anjing",
note: "elongated 'bjir' (profanity-adjacent)",
},
// bangsat variants // bangsat variants
bgsd: { normalized: "bangsat", note: "slang for 'bangsat' (profanity)" }, bgsd: { normalized: "bangsat", note: "slang for 'bangsat' (profanity)" },
bgst: { 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<string, SlangLexiconEntry> = {
sln: { normalized: "sialan", note: "slang for 'sialan' (profanity)" }, sln: { normalized: "sialan", note: "slang for 'sialan' (profanity)" },
sialann: { normalized: "sialan", note: "elongated 'sialan' (profanity)" }, sialann: { normalized: "sialan", note: "elongated 'sialan' (profanity)" },
// jancuk (Javanese) // jancuk (Javanese)
jncuk: { normalized: "jancuk", note: "Javanese slang for 'jancuk' (profanity)" }, jncuk: {
jcuk: { normalized: "jancuk", note: "Javanese slang for 'jancuk' (profanity)" }, normalized: "jancuk",
note: "Javanese slang for 'jancuk' (profanity)",
},
jcuk: {
normalized: "jancuk",
note: "Javanese slang for 'jancuk' (profanity)",
},
jncukk: { normalized: "jancuk", note: "elongated 'jancuk' (profanity)" }, jncukk: { normalized: "jancuk", note: "elongated 'jancuk' (profanity)" },
// kampret // kampret
kmprt: { normalized: "kampret", note: "slang for 'kampret' (profanity)" }, kmprt: { normalized: "kampret", note: "slang for 'kampret' (profanity)" },
@@ -118,7 +176,10 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
// entod // entod
ntd: { normalized: "entod", note: "slang for 'entod' (profanity)" }, ntd: { normalized: "entod", note: "slang for 'entod' (profanity)" },
// Javanese insults // 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)" }, ndas: { normalized: "ndas", note: "Javanese insult 'ndas' (profanity)" },
// piyo (Javanese profanity) // piyo (Javanese profanity)
piyoo: { normalized: "piyo", note: "Javanese slang 'piyo' (profanity)" }, piyoo: { normalized: "piyo", note: "Javanese slang 'piyo' (profanity)" },
@@ -135,31 +196,67 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
// furry subculture // furry subculture
furry: { normalized: "furry", note: "furry / prohibited topic" }, furry: { normalized: "furry", note: "furry / prohibited topic" },
furries: { normalized: "furry", note: "furry / prohibited topic" }, furries: { normalized: "furry", note: "furry / prohibited topic" },
transfurry: { normalized: "transfurry", note: "transfurry / prohibited topic" }, transfurry: {
transfur: { normalized: "transfurry", note: "slang for 'transfurry' / prohibited topic" }, normalized: "transfurry",
protogen: { normalized: "protogen", note: "furry subculture / prohibited topic" }, 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" }, therian: { normalized: "therian", note: "therianthropy / prohibited topic" },
therianthropy: { normalized: "therianthropy", note: "therianthropy / prohibited topic" }, therianthropy: {
otherkin: { normalized: "otherkin", note: "otherkin identity / prohibited topic" }, normalized: "therianthropy",
note: "therianthropy / prohibited topic",
},
otherkin: {
normalized: "otherkin",
note: "otherkin identity / prohibited topic",
},
// furry-adjacent terms // furry-adjacent terms
yiff: { normalized: "yiff", note: "furry sexual content / prohibited topic" }, yiff: { normalized: "yiff", note: "furry sexual content / prohibited topic" },
fursona: { normalized: "fursona", note: "furry persona / 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" }, 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 // sexual orientation terms
gayy: { normalized: "gay", note: "elongated 'gay' / prohibited topic" }, gayy: { normalized: "gay", note: "elongated 'gay' / prohibited topic" },
lesbi: { normalized: "lesbian", note: "lesbian / 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" }, homo: { normalized: "homo", note: "homosexual slur / prohibited topic" },
waria: { normalized: "waria", note: "waria / prohibited topic" }, waria: { normalized: "waria", note: "waria / prohibited topic" },
trans: { normalized: "transgender", note: "transgender / prohibited topic" }, trans: { normalized: "transgender", note: "transgender / prohibited topic" },
nonbinary: { normalized: "nonbinary", note: "nonbinary identity / prohibited topic" }, nonbinary: {
nb: { normalized: "nonbinary", note: "nonbinary abbreviation / prohibited topic" }, normalized: "nonbinary",
genderfluid: { normalized: "genderfluid", note: "genderfluid / prohibited topic" }, 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" }, pansexual: { normalized: "pansexual", note: "pansexual / prohibited topic" },
asexual: { normalized: "asexual", note: "asexual / 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" }, enby: { normalized: "enby", note: "NB/nonbinary slang / prohibited topic" },
// ========================================================================= // =========================================================================
@@ -448,8 +545,14 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
s4s: { normalized: "sub4sub", note: "spam engagement abbreviation" }, s4s: { normalized: "sub4sub", note: "spam engagement abbreviation" },
like4like: { normalized: "like4like", note: "spam engagement" }, like4like: { normalized: "like4like", note: "spam engagement" },
l4l: { normalized: "like4like", note: "spam engagement abbreviation" }, l4l: { normalized: "like4like", note: "spam engagement abbreviation" },
dm: { normalized: "DM", note: "direct message — check for spam/scam context" }, dm: {
pm: { normalized: "PM", note: "private message — check for spam/scam context" }, 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" }, click: { normalized: "click", note: "potential clickbait/scam" },
link: { normalized: "link", note: "potential spam link — check context" }, link: { normalized: "link", note: "potential spam link — check context" },
free: { normalized: "free", note: "potential spam bait — check context" }, free: { normalized: "free", note: "potential spam bait — check context" },
@@ -468,15 +571,24 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
narkoba: { normalized: "narkoba", note: "narcotics / prohibited topic" }, narkoba: { normalized: "narkoba", note: "narcotics / prohibited topic" },
kokain: { normalized: "kokain", note: "cocaine / prohibited topic" }, kokain: { normalized: "kokain", note: "cocaine / prohibited topic" },
ekstasi: { normalized: "ekstasi", note: "ecstasy / 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 // Violence / threat indicators
// ========================================================================= // =========================================================================
bunuh: { normalized: "bunuh", note: "kill / violence indicator" }, 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" }, 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" }, ancam: { normalized: "ancam", note: "threat indicator" },
ancamn: { normalized: "ancaman", note: "threat indicator" }, ancamn: { normalized: "ancaman", note: "threat indicator" },
bakar: { normalized: "bakar", note: "burn / violence indicator" }, bakar: { normalized: "bakar", note: "burn / violence indicator" },
@@ -586,7 +698,11 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
porn: { normalized: "porno", note: "pornography / NSFW content" }, porn: { normalized: "porno", note: "pornography / NSFW content" },
porno: { normalized: "porno", note: "pornography / NSFW content" }, porno: { normalized: "porno", note: "pornography / NSFW content" },
bokep: { normalized: "bokep", 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" }, ngeseks: { normalized: "ngeseks", note: "having sex / NSFW content" },
masturbasi: { normalized: "masturbasi", note: "masturbation / NSFW content" }, masturbasi: { normalized: "masturbasi", note: "masturbation / NSFW content" },
onani: { normalized: "onani", note: "masturbation / NSFW content" }, onani: { normalized: "onani", note: "masturbation / NSFW content" },
@@ -634,8 +750,14 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
// ========================================================================= // =========================================================================
// Money / financial scam indicators // Money / financial scam indicators
// ========================================================================= // =========================================================================
investment: { normalized: "investment", note: "potential investment scam — check context" }, investment: {
investasi: { normalized: "investasi", note: "potential investment scam — check context" }, 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" }, crypto: { normalized: "crypto", note: "crypto scam — check context" },
bitcoin: { normalized: "bitcoin", note: "crypto — check context" }, bitcoin: { normalized: "bitcoin", note: "crypto — check context" },
btc: { normalized: "bitcoin", note: "crypto — check context" }, btc: { normalized: "bitcoin", note: "crypto — check context" },
@@ -650,8 +772,17 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
claim: { normalized: "claim", note: "scam bait — check context" }, claim: { normalized: "claim", note: "scam bait — check context" },
klaim: { normalized: "klaim", note: "scam bait — check context" }, klaim: { normalized: "klaim", note: "scam bait — check context" },
verify: { normalized: "verify", note: "potential phishing — 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" }, wallet: { normalized: "wallet", note: "crypto wallet — check context" },
seed: { normalized: "seed phrase", note: "crypto seed phrase — check context" }, seed: {
recovery: { normalized: "recovery phrase", note: "crypto recovery — check context" }, normalized: "seed phrase",
note: "crypto seed phrase — check context",
},
recovery: {
normalized: "recovery phrase",
note: "crypto recovery — check context",
},
}; };
+8 -6
View File
@@ -6,10 +6,7 @@ import {
voiceRecordingsTable, voiceRecordingsTable,
} from "../database/schema.js"; } from "../database/schema.js";
import { createChildLogger } from "../logger.js"; import { createChildLogger } from "../logger.js";
import { import { getExpiredMessages, getRetentionPolicy } from "./messageStore.js";
getExpiredMessages,
getRetentionPolicy,
} from "./messageStore.js";
import type { RetentionPolicy } from "./types.js"; import type { RetentionPolicy } from "./types.js";
import { and, eq, isNull, lt, sql } from "drizzle-orm"; import { and, eq, isNull, lt, sql } from "drizzle-orm";
@@ -152,7 +149,10 @@ export async function executeAllRetentionPolicies(): Promise<{
return summary; return summary;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(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; throw error;
} }
} }
@@ -161,7 +161,9 @@ export async function executeAllRetentionPolicies(): Promise<{
* Starts a periodic retention policy executor * Starts a periodic retention policy executor
* Runs every 24 hours by default * 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"); logger.info({ intervalMs }, "Starting retention policy worker");
const interval = setInterval(async () => { const interval = setInterval(async () => {
+4 -2
View File
@@ -66,7 +66,8 @@ async function isSafeUrl(urlStr: string): Promise<boolean> {
function extractOgImage(html: string): string | null { function extractOgImage(html: string): string | null {
// Look for <meta ... property="og:image" ... content="..."> or <meta ... name="twitter:image" ... content="..."> // Look for <meta ... property="og:image" ... content="..."> or <meta ... name="twitter:image" ... content="...">
const ogRegex = /<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i; const ogRegex =
/<meta[^>]*(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*content=["']([^"']+)["']/i;
const match = html.match(ogRegex); const match = html.match(ogRegex);
if (match && match[1]) { if (match && match[1]) {
// Unescape basic HTML entities // Unescape basic HTML entities
@@ -74,7 +75,8 @@ function extractOgImage(html: string): string | null {
} }
// Try reversed attribute order: <meta ... content="..." ... property="og:image"> // Try reversed attribute order: <meta ... content="..." ... property="og:image">
const ogRegexRev = /<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i; const ogRegexRev =
/<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["'](?:og:image|twitter:image)["']/i;
const matchRev = html.match(ogRegexRev); const matchRev = html.match(ogRegexRev);
if (matchRev && matchRev[1]) { if (matchRev && matchRev[1]) {
return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"'); return matchRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
+6 -32
View File
@@ -17,11 +17,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24) // Query params: guildId (required), channelId, hours (default 24)
router.get("/analytics/overview", async (req, res, next) => { router.get("/analytics/overview", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours } = req.query as {
guildId,
channelId,
hours,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
@@ -53,11 +49,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24) // Query params: guildId (required), channelId, hours (default 24)
router.get("/analytics/hourly", async (req, res, next) => { router.get("/analytics/hourly", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours } = req.query as {
guildId,
channelId,
hours,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
@@ -89,11 +81,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24) // Query params: guildId (required), channelId, hours (default 24)
router.get("/analytics/topics", async (req, res, next) => { router.get("/analytics/topics", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours } = req.query as {
guildId,
channelId,
hours,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
@@ -125,12 +113,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24), limit (default 20) // Query params: guildId (required), channelId, hours (default 24), limit (default 20)
router.get("/analytics/leaderboard", async (req, res, next) => { router.get("/analytics/leaderboard", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours, limit } = req.query as {
guildId,
channelId,
hours,
limit,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
@@ -165,11 +148,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24) // Query params: guildId (required), channelId, hours (default 24)
router.get("/analytics/stats", async (req, res, next) => { router.get("/analytics/stats", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours } = req.query as {
guildId,
channelId,
hours,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
@@ -201,12 +180,7 @@ export function createAnalyticsRoutes(): Router {
// Query params: guildId (required), channelId, hours (default 24), limit (default 20) // Query params: guildId (required), channelId, hours (default 24), limit (default 20)
router.get("/analytics/violators", async (req, res, next) => { router.get("/analytics/violators", async (req, res, next) => {
try { try {
const { const { guildId, channelId, hours, limit } = req.query as {
guildId,
channelId,
hours,
limit,
} = req.query as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
hours?: string; hours?: string;
+17 -7
View File
@@ -46,11 +46,13 @@ function createMessage(overrides: Partial<MessageRecord> = {}): MessageRecord {
}; };
} }
function createClient(options: { function createClient(
canManageMessages?: boolean; options: {
fetchError?: unknown; canManageMessages?: boolean;
deleteError?: unknown; fetchError?: unknown;
} = {}) { deleteError?: unknown;
} = {},
) {
const deleteMock = vi.fn(async () => { const deleteMock = vi.fn(async () => {
if (options.deleteError) throw options.deleteError; if (options.deleteError) throw options.deleteError;
}); });
@@ -113,7 +115,11 @@ describe("attemptAutoDeleteFlaggedMessage", () => {
createMessage(), // defaults to flagged 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(fetchMessageMock).toHaveBeenCalledWith("m1");
expect(deleteMock).toHaveBeenCalledTimes(1); expect(deleteMock).toHaveBeenCalledTimes(1);
}); });
@@ -127,7 +133,11 @@ describe("attemptAutoDeleteFlaggedMessage", () => {
createMessage({ ai_status: "warn" }), 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(fetchMessageMock).toHaveBeenCalledWith("m1");
expect(deleteMock).toHaveBeenCalledTimes(1); expect(deleteMock).toHaveBeenCalledTimes(1);
}); });
@@ -9,7 +9,9 @@ import {
describe("normalizeDiscordCustomEmoji", () => { describe("normalizeDiscordCustomEmoji", () => {
it("replaces static custom emoji", () => { 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.text).toBe("Bersiaplah woy [emoji:hadeh]");
expect(result.emojiNames).toContain("hadeh"); expect(result.emojiNames).toContain("hadeh");
}); });
@@ -61,7 +63,9 @@ describe("buildModerationTextEvidence", () => {
expect(evidence.normalized).toContain("[emoji:hadeh]"); expect(evidence.normalized).toContain("[emoji:hadeh]");
expect(evidence.badwords).toHaveLength(0); expect(evidence.badwords).toHaveLength(0);
expect(evidence.hasBadwords).toBe(false); 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("emoji:hadeh"))).toBe(true);
expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true); expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true);
}); });
@@ -69,7 +73,9 @@ describe("buildModerationTextEvidence", () => {
it("detects badword when present", async () => { it("detects badword when present", async () => {
const evidence = await buildModerationTextEvidence("anjing loe kontol"); const evidence = await buildModerationTextEvidence("anjing loe kontol");
expect(evidence.hasBadwords).toBe(true); 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,
);
}); });
}); });
@@ -32,7 +32,7 @@ describe("shouldCaptureMessageLocation", () => {
{ guildId: "guild-1" }, { guildId: "guild-1" },
), ),
).toBe(false); ).toBe(false);
expect( expect(
shouldCaptureMessageLocation( shouldCaptureMessageLocation(
{ guildId: "guild-1", channelId: "1265679542144467035" }, { guildId: "guild-1", channelId: "1265679542144467035" },