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");
|
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
@@ -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", {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(/&/g, "&").replace(/"/g, '"');
|
return matchRev[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user