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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+22
-8
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,12 +45,15 @@ export default async function processAnalysisRequest({
|
||||
messages,
|
||||
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
||||
if (!config.AI_LLM_API_KEY) {
|
||||
console.error(JSON.stringify({
|
||||
level: "FATAL",
|
||||
context: "aiAnalysisWorker",
|
||||
error: "AI_LLM_API_KEY is missing from environment. Force closing worker operation.",
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, {
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
warned_count: number;
|
||||
flags_set: Set<string>;
|
||||
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<string>;
|
||||
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 },
|
||||
|
||||
@@ -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<unknown> }> } } {
|
||||
): channel is {
|
||||
messages: {
|
||||
fetch: (id: string) => Promise<{ delete: () => Promise<unknown> }>;
|
||||
};
|
||||
} {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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<string[]> {
|
||||
* 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<string[]> {
|
||||
export async function detectIndonesianBadwords(
|
||||
text: string,
|
||||
): Promise<string[]> {
|
||||
// Always run local detection first (fast, no network dependency)
|
||||
const localHits = detectLocalBadwords(text);
|
||||
|
||||
@@ -211,7 +247,10 @@ export async function detectIndonesianBadwords(text: string): Promise<string[]>
|
||||
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<string[]>
|
||||
// Async evidence builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildModerationTextEvidence(text: string): Promise<ModerationTextEvidence> {
|
||||
export async function buildModerationTextEvidence(
|
||||
text: string,
|
||||
): Promise<ModerationTextEvidence> {
|
||||
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<Moderat
|
||||
};
|
||||
}
|
||||
|
||||
export async function formatModerationTextEvidenceForPrompt(text: string): Promise<string> {
|
||||
export async function formatModerationTextEvidenceForPrompt(
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
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(" ");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MessageReview | null> {
|
||||
export async function getMessageReview(
|
||||
id: string,
|
||||
): Promise<MessageReview | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
@@ -878,7 +882,10 @@ export async function getMessageReview(id: string): Promise<MessageReview | null
|
||||
return (rows[0] as MessageReview) || 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 get message review",
|
||||
);
|
||||
throw error;
|
||||
@@ -918,7 +925,10 @@ export async function listMessageReviews(query: {
|
||||
.select()
|
||||
.from(messageReviewsTable)
|
||||
.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);
|
||||
|
||||
return pageRows<MessageReview>(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<ModerationAction | null> {
|
||||
export async function getModerationAction(
|
||||
id: string,
|
||||
): Promise<ModerationAction | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
@@ -997,7 +1012,10 @@ export async function getModerationAction(id: string): Promise<ModerationAction
|
||||
return (rows[0] as ModerationAction) || 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 get moderation action",
|
||||
);
|
||||
throw error;
|
||||
@@ -1033,7 +1051,10 @@ export async function listModerationActions(query: {
|
||||
.select()
|
||||
.from(moderationActionsTable)
|
||||
.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);
|
||||
|
||||
return pageRows<ModerationAction>(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<RetentionPolicy | null> {
|
||||
export async function getRetentionPolicy(
|
||||
guildId: string,
|
||||
): Promise<RetentionPolicy | null> {
|
||||
try {
|
||||
const database = db();
|
||||
const rows = await database
|
||||
@@ -1082,7 +1108,10 @@ export async function getRetentionPolicy(guildId: string): Promise<RetentionPoli
|
||||
return (rows[0] as RetentionPolicy) || null;
|
||||
} catch (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",
|
||||
);
|
||||
throw error;
|
||||
@@ -1155,7 +1184,10 @@ export async function getExpiredMessages(
|
||||
return rows as MessageRecord[];
|
||||
} catch (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",
|
||||
);
|
||||
throw error;
|
||||
|
||||
@@ -21,19 +21,59 @@ export const INDONESIAN_SLANG_LEXICON: Record<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
// 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<string, SlangLexiconEntry> = {
|
||||
// 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<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
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<string, SlangLexiconEntry> = {
|
||||
// =========================================================================
|
||||
// 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<string, SlangLexiconEntry> = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -66,7 +66,8 @@ async function isSafeUrl(urlStr: string): Promise<boolean> {
|
||||
|
||||
function extractOgImage(html: string): string | null {
|
||||
// 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);
|
||||
if (match && match[1]) {
|
||||
// Unescape basic HTML entities
|
||||
@@ -74,7 +75,8 @@ function extractOgImage(html: string): string | null {
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (matchRev && matchRev[1]) {
|
||||
return matchRev[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -46,11 +46,13 @@ function createMessage(overrides: Partial<MessageRecord> = {}): 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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("shouldCaptureMessageLocation", () => {
|
||||
{ guildId: "guild-1" },
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
|
||||
expect(
|
||||
shouldCaptureMessageLocation(
|
||||
{ guildId: "guild-1", channelId: "1265679542144467035" },
|
||||
|
||||
Reference in New Issue
Block a user