chore(format): auto-format files with biome

This commit is contained in:
MythEclipse
2026-06-04 18:52:56 +07:00
parent 367d8696c8
commit d1d4510ce8
33 changed files with 342 additions and 157 deletions
+3 -1
View File
@@ -5,6 +5,8 @@ export default defineConfig({
out: "./drizzle/migrations", out: "./drizzle/migrations",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/bete", url:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/bete",
}, },
}); });
+1 -1
View File
@@ -1,9 +1,9 @@
import type { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import type { CommandHandler } from "../modules/command-handler/commandHandler.js"; import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js"; import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
import type { VoiceController } from "../modules/voice-recording/voiceController.js"; import type { VoiceController } from "../modules/voice-recording/voiceController.js";
import type { closeDatabase } from "../shared/database/drizzle.js"; import type { closeDatabase } from "../shared/database/drizzle.js";
import type { createChildLogger } from "@bete/shared/logger";
type Logger = ReturnType<typeof createChildLogger>; type Logger = ReturnType<typeof createChildLogger>;
type CloseDatabase = typeof closeDatabase; type CloseDatabase = typeof closeDatabase;
+1 -1
View File
@@ -2,8 +2,8 @@ import "./mock-crc.js";
import "libsodium-wrappers"; import "libsodium-wrappers";
import "@snazzah/davey"; import "@snazzah/davey";
import "dotenv/config"; import "dotenv/config";
import { initializeDiscordGateway } from "./app/bootstrap.js";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { initializeDiscordGateway } from "./app/bootstrap.js";
const logger = createChildLogger("discord-gateway"); const logger = createChildLogger("discord-gateway");
@@ -1,13 +1,19 @@
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { initializeDatabase } from "../../shared/database/drizzle.js"; import { initializeDatabase } from "../../shared/database/drizzle.js";
import { buildConversationContext } from "./conversationContext.js";
import { runModerationAnalysis, runSimpleTextFallback } from "./llmModerationClient.js";
import { import {
getAttachmentsForMessages, getAttachmentsForMessages,
getConversationContextBefore, getConversationContextBefore,
updateMessagesAIAnalysisBulk, updateMessagesAIAnalysisBulk,
} from "../message-capture/messageStore.js"; } from "../message-capture/messageStore.js";
import type { MessageRecord, AnalysisResult } from "../message-capture/types.js"; import type {
AnalysisResult,
MessageRecord,
} from "../message-capture/types.js";
import { buildConversationContext } from "./conversationContext.js";
import {
runModerationAnalysis,
runSimpleTextFallback,
} from "./llmModerationClient.js";
let dbInitialized = false; let dbInitialized = false;
let dbInitPromise: Promise<any> | null = null; let dbInitPromise: Promise<any> | null = null;
@@ -30,24 +36,44 @@ type WorkerJob =
| { type: "batch"; conversationKey: string; messages: MessageRecord[] } | { type: "batch"; conversationKey: string; messages: MessageRecord[] }
| { type: "individual"; message: MessageRecord; skipNormalAnalysis: boolean }; | { type: "individual"; message: MessageRecord; skipNormalAnalysis: boolean };
type BatchOkResponse = { ok: true; conversationKey: string; rows: MessageRecord[] }; type BatchOkResponse = {
type BatchErrorResponse = { ok: false; conversationKey: string; rows: MessageRecord[]; error: string }; ok: true;
conversationKey: string;
rows: MessageRecord[];
};
type BatchErrorResponse = {
ok: false;
conversationKey: string;
rows: MessageRecord[];
error: string;
};
type IndividualOkResponse = { ok: true; results: AnalysisResult[] }; type IndividualOkResponse = { ok: true; results: AnalysisResult[] };
type IndividualErrorResponse = { ok: false; results: AnalysisResult[]; error: string }; type IndividualErrorResponse = {
ok: false;
results: AnalysisResult[];
error: string;
};
type WorkerResponse = BatchOkResponse | BatchErrorResponse | IndividualOkResponse | IndividualErrorResponse; type WorkerResponse =
| BatchOkResponse
| BatchErrorResponse
| IndividualOkResponse
| IndividualErrorResponse;
/** /**
* Default export — Piscina worker entry point. * Default export — Piscina worker entry point.
* Routes to the correct handler based on `type` field. * Routes to the correct handler based on `type` field.
*/ */
export default async function workerRouter(job: WorkerJob): Promise<WorkerResponse> { export default async function workerRouter(
job: WorkerJob,
): Promise<WorkerResponse> {
if (!config.AI_LLM_API_KEY) { if (!config.AI_LLM_API_KEY) {
console.error( console.error(
JSON.stringify({ JSON.stringify({
level: "FATAL", level: "FATAL",
context: "aiAnalysisWorker", context: "aiAnalysisWorker",
error: "AI_LLM_API_KEY is missing from environment. Force closing worker operation.", error:
"AI_LLM_API_KEY is missing from environment. Force closing worker operation.",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}), }),
); );
@@ -59,7 +85,12 @@ export default async function workerRouter(job: WorkerJob): Promise<WorkerRespon
} catch (dbError) { } catch (dbError) {
const msg = dbError instanceof Error ? dbError.message : String(dbError); const msg = dbError instanceof Error ? dbError.message : String(dbError);
if (job.type === "batch") { if (job.type === "batch") {
return { ok: false, conversationKey: job.conversationKey, rows: [], error: `Database init failed: ${msg}` }; return {
ok: false,
conversationKey: job.conversationKey,
rows: [],
error: `Database init failed: ${msg}`,
};
} }
return { ok: false, results: [], error: `Database init failed: ${msg}` }; return { ok: false, results: [], error: `Database init failed: ${msg}` };
} }
@@ -72,16 +103,23 @@ export default async function workerRouter(job: WorkerJob): Promise<WorkerRespon
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined; const errorStack = error instanceof Error ? error.stack : undefined;
console.error(JSON.stringify({ console.error(
JSON.stringify({
level: "ERROR", level: "ERROR",
context: "aiAnalysisWorker", context: "aiAnalysisWorker",
type: job.type, type: job.type,
error: errorMessage, error: errorMessage,
stack: errorStack, stack: errorStack,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
})); }),
);
if (job.type === "batch") { if (job.type === "batch") {
return { ok: false, conversationKey: job.conversationKey, rows: [], error: errorMessage }; return {
ok: false,
conversationKey: job.conversationKey,
rows: [],
error: errorMessage,
};
} }
return { ok: false, results: [], error: errorMessage }; return { ok: false, results: [], error: errorMessage };
} }
@@ -91,7 +129,11 @@ export default async function workerRouter(job: WorkerJob): Promise<WorkerRespon
// Batch handler // Batch handler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processBatch(job: { type: "batch"; conversationKey: string; messages: MessageRecord[] }): Promise<BatchOkResponse | BatchErrorResponse> { async function processBatch(job: {
type: "batch";
conversationKey: string;
messages: MessageRecord[];
}): Promise<BatchOkResponse | BatchErrorResponse> {
const { conversationKey, messages } = job; const { conversationKey, messages } = job;
const firstMessage = messages[0]; const firstMessage = messages[0];
if (!firstMessage) return { ok: true, conversationKey, rows: [] }; if (!firstMessage) return { ok: true, conversationKey, rows: [] };
@@ -150,7 +192,11 @@ async function processBatch(job: { type: "batch"; conversationKey: string; messa
// Individual fallback handler (offloaded from main thread) // Individual fallback handler (offloaded from main thread)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processIndividual(job: { type: "individual"; message: MessageRecord; skipNormalAnalysis: boolean }): Promise<IndividualOkResponse | IndividualErrorResponse> { async function processIndividual(job: {
type: "individual";
message: MessageRecord;
skipNormalAnalysis: boolean;
}): Promise<IndividualOkResponse | IndividualErrorResponse> {
const { message, skipNormalAnalysis } = job; const { message, skipNormalAnalysis } = job;
const contextBefore = await getConversationContextBefore({ const contextBefore = await getConversationContextBefore({
@@ -167,7 +213,10 @@ async function processIndividual(job: { type: "individual"; message: MessageReco
}); });
const contextIds = contextBefore.map((m) => m.id); const contextIds = contextBefore.map((m) => m.id);
const attachments = await getAttachmentsForMessages([message.id, ...contextIds]); const attachments = await getAttachmentsForMessages([
message.id,
...contextIds,
]);
let results: AnalysisResult[]; let results: AnalysisResult[];
@@ -1,6 +1,6 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Client, PermissionString } from "discord.js-selfbot-v13"; import type { Client, PermissionString } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger";
import { createModerationAction } from "../message-capture/messageStore.js"; import { createModerationAction } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
@@ -323,7 +323,8 @@ export async function attemptAutoDeleteFlaggedMessage(
try { try {
const targetUser = await client.users.fetch(message.user_id); const targetUser = await client.users.fetch(message.user_id);
if (targetUser) { if (targetUser) {
const reason = message.ai_categories ?? message.ai_moderation_flags ?? "(unknown)"; const reason =
message.ai_categories ?? message.ai_moderation_flags ?? "(unknown)";
await targetUser.send( await targetUser.send(
`Pesan Anda di **${guild.name}** telah dihapus oleh sistem moderasi otomatis.\n` + `Pesan Anda di **${guild.name}** telah dihapus oleh sistem moderasi otomatis.\n` +
`Alasan: ${reason}\n` + `Alasan: ${reason}\n` +
@@ -333,7 +334,11 @@ export async function attemptAutoDeleteFlaggedMessage(
} catch (dmErr) { } catch (dmErr) {
// DM might fail if user has DMs disabled — not critical // DM might fail if user has DMs disabled — not critical
logger.debug( logger.debug(
{ messageId: message.id, userId: message.user_id, error: String(dmErr) }, {
messageId: message.id,
userId: message.user_id,
error: String(dmErr),
},
"Failed to send DM notification for auto-deleted message", "Failed to send DM notification for auto-deleted message",
); );
} }
@@ -342,11 +347,21 @@ export async function attemptAutoDeleteFlaggedMessage(
// ── Log to moderation channel ── // ── Log to moderation channel ──
if (config.AUTO_DELETE_LOG_CHANNEL_ID) { if (config.AUTO_DELETE_LOG_CHANNEL_ID) {
try { try {
const logChannel = guild.channels.cache.get(config.AUTO_DELETE_LOG_CHANNEL_ID); const logChannel = guild.channels.cache.get(
if (logChannel && "send" in logChannel && typeof (logChannel as any).send === "function") { config.AUTO_DELETE_LOG_CHANNEL_ID,
);
if (
logChannel &&
"send" in logChannel &&
typeof (logChannel as any).send === "function"
) {
const severity = message.ai_severity ?? "none"; const severity = message.ai_severity ?? "none";
const categories = message.ai_categories ?? message.ai_moderation_flags ?? "—"; const categories =
const snippet = (message.edited_content ?? message.content).substring(0, 200); message.ai_categories ?? message.ai_moderation_flags ?? "—";
const snippet = (message.edited_content ?? message.content).substring(
0,
200,
);
await (logChannel as any).send( await (logChannel as any).send(
`**🧹 Auto-Delete** — Pesan dari <@${message.user_id}> di <#${channelId}>\n` + `**🧹 Auto-Delete** — Pesan dari <@${message.user_id}> di <#${channelId}>\n` +
`**Status:** ${message.ai_status}\n` + `**Status:** ${message.ai_status}\n` +
@@ -1,7 +1,7 @@
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js"; import { encoding_for_model as encodingForModel } from "tiktoken";
import { formatMediaEvidenceForPrompt } from "../message-capture/messageMetadata.js"; import { formatMediaEvidenceForPrompt } from "../message-capture/messageMetadata.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
import { encoding_for_model as encodingForModel } from "tiktoken"; import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
export interface ConversationContextInput { export interface ConversationContextInput {
contextBefore: MessageRecord[]; contextBefore: MessageRecord[];
@@ -59,13 +59,17 @@ export function buildConversationContext(
const { contextBefore, targets, maxTokens } = input; const { contextBefore, targets, maxTokens } = input;
// Calculate tokens used by targets (parallel) // Calculate tokens used by targets (parallel)
const targetLines = targets.map((msg) => formatMessageForPrompt(msg, "target")); const targetLines = targets.map((msg) =>
formatMessageForPrompt(msg, "target"),
);
let usedTokens = targetLines.reduce( let usedTokens = targetLines.reduce(
(sum, line) => sum + estimateTokens(line), (sum, line) => sum + estimateTokens(line),
0, 0,
); );
const contextLines = contextBefore.map((msg) => formatMessageForPrompt(msg, "context")); const contextLines = contextBefore.map((msg) =>
formatMessageForPrompt(msg, "context"),
);
const selectedContextLines: string[] = []; const selectedContextLines: string[] = [];
// Go backwards through context, taking most recent first // Go backwards through context, taking most recent first
@@ -1,8 +1,8 @@
export { startPendingAIAnalysisWorker } from "./aiAnalyzer.js"; export { startPendingAIAnalysisWorker } from "./aiAnalyzer.js";
export { export {
normalizeDiscordCustomEmoji,
detectIndonesianBadwords,
buildModerationTextEvidence, buildModerationTextEvidence,
detectIndonesianBadwords,
normalizeDiscordCustomEmoji,
} from "./indonesianTextNormalizer.js"; } from "./indonesianTextNormalizer.js";
export { runModerationAnalysis } from "./llmModerationClient.js"; export { runModerationAnalysis } from "./llmModerationClient.js";
export { buildSystemPrompt } from "./moderationPrompt.js"; export { buildSystemPrompt } from "./moderationPrompt.js";
@@ -19,17 +19,29 @@ const badwordCache = new Map<string, BadwordCacheEntry>();
// Conservative: only returns true for patterns that CANNOT be violations. // Conservative: only returns true for patterns that CANNOT be violations.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SAFE_PATTERNS: Array<{ test: (text: string) => boolean; reason: string }> = [ const SAFE_PATTERNS: Array<{
test: (text: string) => boolean;
reason: string;
}> = [
{ {
test: (t) => /^(wkwk+|w+kw+k+|wkwkw+|haha+|hehe+|hihi+|huhu+|xixi+|wakak+|awkwa+)$/i.test(t), test: (t) =>
/^(wkwk+|w+kw+k+|wkwkw+|haha+|hehe+|hihi+|huhu+|xixi+|wakak+|awkwa+)$/i.test(
t,
),
reason: "laughter pattern", reason: "laughter pattern",
}, },
{ {
test: (t) => /^(ok|oke|okay|sip|siap|aman|mantap|gas|gass|gaskeun|santuy|gaskan|lah|wih|wah|eh|nah|loh|hmm|hm|heh)$/i.test(t), test: (t) =>
/^(ok|oke|okay|sip|siap|aman|mantap|gas|gass|gaskeun|santuy|gaskan|lah|wih|wah|eh|nah|loh|hmm|hm|heh)$/i.test(
t,
),
reason: "single-word affirmative", reason: "single-word affirmative",
}, },
{ {
test: (t) => /^(hai|halo|hello|hi|oi|woy|woi|pagi|siang|sore|malam|mlm|p|w|L|F|gws|thx|thks|makasih|ty|thanks|yw|sama-sama|ok sip|ok bang|siap bang)$/i.test(t), test: (t) =>
/^(hai|halo|hello|hi|oi|woy|woi|pagi|siang|sore|malam|mlm|p|w|L|F|gws|thx|thks|makasih|ty|thanks|yw|sama-sama|ok sip|ok bang|siap bang)$/i.test(
t,
),
reason: "greeting/common expression", reason: "greeting/common expression",
}, },
{ {
@@ -63,49 +75,148 @@ const BADWORD_CATEGORIES: BadwordEntry[] = [
description: "vulgar genitalia / sexual terms", description: "vulgar genitalia / sexual terms",
flag: "vulgar_language", flag: "vulgar_language",
words: [ words: [
"kontol", "memek", "pepek", "tempik", "peler", "pelir", "pukimak", "pukima", "kontol",
"jancok", "jancuk", "cok", "cuk", "pantek", "palek", "ngentot", "ngewe", "memek",
"entot", "ewe", "coli", "sange", "sangean", "ngocok", "bangkot", "pepek",
"nenen", "tete", "tetek", "dodot", "kentu", "perek", "bispak", "bangsat", "tempik",
"babi", "asu", "anjing", "anjir", "anjirt", "njing", "njir", "anjay", "peler",
"kampret", "kampang", "brengsek", "brengus", "bejad", "bajingan", "pelir",
"goblok", "tolol", "bego", "dungu", "idiot", "beban", "keparat", "pukimak",
"setan", "iblis", "sialan", "sial", "kacang", "edan", "gila", "pukima",
"jancok",
"jancuk",
"cok",
"cuk",
"pantek",
"palek",
"ngentot",
"ngewe",
"entot",
"ewe",
"coli",
"sange",
"sangean",
"ngocok",
"bangkot",
"nenen",
"tete",
"tetek",
"dodot",
"kentu",
"perek",
"bispak",
"bangsat",
"babi",
"asu",
"anjing",
"anjir",
"anjirt",
"njing",
"njir",
"anjay",
"kampret",
"kampang",
"brengsek",
"brengus",
"bejad",
"bajingan",
"goblok",
"tolol",
"bego",
"dungu",
"idiot",
"beban",
"keparat",
"setan",
"iblis",
"sialan",
"sial",
"kacang",
"edan",
"gila",
], ],
}, },
{ {
description: "harassment / targeted insults", description: "harassment / targeted insults",
flag: "harassment", flag: "harassment",
words: [ words: [
"mampus", "mati", "bunuh", "bacot", "cupu", "geblek", "kere", "mampus",
"ngawur", "sembarangan", "nyampah", "nyampah", "sarap", "mati",
"ke laut aja", "gila lu", "sinting", "editan", "mending mati", "bunuh",
"monyet", "kuda", "unta", "bangke", "bangsat", "bacot",
"cupu",
"geblek",
"kere",
"ngawur",
"sembarangan",
"nyampah",
"nyampah",
"sarap",
"ke laut aja",
"gila lu",
"sinting",
"editan",
"mending mati",
"monyet",
"kuda",
"unta",
"bangke",
"bangsat",
], ],
}, },
{ {
description: "SARA / racial slurs (non-exhaustive)", description: "SARA / racial slurs (non-exhaustive)",
flag: "sara", flag: "sara",
words: [ words: [
"cina", "tionghoa", "pribumi", "non-pribumi", "kaffir", "kafir", "cina",
"murtad", "sesat", "liberal", "komunis", "komunisme", "pki", "tionghoa",
"pribumi",
"non-pribumi",
"kaffir",
"kafir",
"murtad",
"sesat",
"liberal",
"komunis",
"komunisme",
"pki",
], ],
}, },
{ {
description: "gambling / judi", description: "gambling / judi",
flag: "gambling", flag: "gambling",
words: [ words: [
"judi", "slot", "togel", "toto gelap", "casino", "roulette", "judi",
"poker", "domino", "gaple", "sabung ayam", "bola jalan", "slot",
"maxwin", "gacor", "scatter", "bonanza", "olympus", "togel",
"toto gelap",
"casino",
"roulette",
"poker",
"domino",
"gaple",
"sabung ayam",
"bola jalan",
"maxwin",
"gacor",
"scatter",
"bonanza",
"olympus",
], ],
}, },
{ {
description: "hate speech / extreme discrimination", description: "hate speech / extreme discrimination",
flag: "hate_speech", flag: "hate_speech",
words: [ words: [
"bencina", "bencin", "bangsat", "dajjal", "laknat", "keparat", "bencina",
"dasar cina", "dasar tionghoa", "dasar pribumi", "bencin",
"bangsat",
"dajjal",
"laknat",
"keparat",
"dasar cina",
"dasar tionghoa",
"dasar pribumi",
], ],
}, },
]; ];
@@ -294,9 +405,7 @@ export function buildModerationTextEvidence(
}; };
} }
export function formatModerationTextEvidenceForPrompt( export function formatModerationTextEvidenceForPrompt(text: string): string {
text: string,
): string {
const evidence = buildModerationTextEvidence(text); const evidence = buildModerationTextEvidence(text);
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) { if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
return ""; return "";
@@ -6,11 +6,11 @@
* defaults are maintained in one place. * defaults are maintained in one place.
*/ */
import { createChildLogger } from "@bete/shared/logger";
import { retryWithBackoff } from "@bete/shared/utils";
import OpenAI from "openai"; import OpenAI from "openai";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { retryWithBackoff } from "@bete/shared/utils";
import { withLlmConcurrency } from "./concurrencyLimiter.js"; import { withLlmConcurrency } from "./concurrencyLimiter.js";
import { createChildLogger } from "@bete/shared/logger";
const log = createChildLogger("llm-client"); const log = createChildLogger("llm-client");
@@ -32,19 +32,17 @@ import {
computeImagePhash, computeImagePhash,
getCachedMediaAnalysis, getCachedMediaAnalysis,
getCachedMediaByPhash, getCachedMediaByPhash,
getCachedUserModeration,
getRecentCorrectedModerations, getRecentCorrectedModerations,
makeCustomEmojiCacheKey, makeCustomEmojiCacheKey,
makeImageCacheKey, makeImageCacheKey,
makeStickerCacheKey, makeStickerCacheKey,
makeUserModerationCacheKey,
setCachedUserModeration,
upsertCachedMediaAnalysis, upsertCachedMediaAnalysis,
upsertCachedMediaByPhash, upsertCachedMediaByPhash,
} from "./textCacheStore.js"; } from "./textCacheStore.js";
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import {
getCachedUserModeration,
makeUserModerationCacheKey,
setCachedUserModeration,
} from "./textCacheStore.js";
const SeveritySchema = z.enum(["none", "low", "medium", "high", "critical"]); const SeveritySchema = z.enum(["none", "low", "medium", "high", "critical"]);
const RecommendedActionSchema = z.enum([ const RecommendedActionSchema = z.enum([
@@ -1690,7 +1688,11 @@ export async function runModerationAnalysis(
// This guards against both legacy corrupt entries and any future write-path bugs. // This guards against both legacy corrupt entries and any future write-path bugs.
if ( if (
cached.flags.some((f) => cached.flags.some((f) =>
["analysis_api_failed", "analysis_parse_failed", "analysis_incomplete"].includes(f), [
"analysis_api_failed",
"analysis_parse_failed",
"analysis_incomplete",
].includes(f),
) )
) { ) {
log.warn( log.warn(
@@ -1707,7 +1709,8 @@ export async function runModerationAnalysis(
categories: cached.categories, categories: cached.categories,
severity: cached.severity as AnalysisResult["severity"], severity: cached.severity as AnalysisResult["severity"],
confidence: cached.confidence, confidence: cached.confidence,
recommendedAction: cached.recommendedAction as AnalysisResult["recommendedAction"], recommendedAction:
cached.recommendedAction as AnalysisResult["recommendedAction"],
policyVersion: "cached-user-moderation-2026-06", policyVersion: "cached-user-moderation-2026-06",
evidence: [], evidence: [],
}); });
@@ -1727,7 +1730,11 @@ export async function runModerationAnalysis(
if (cacheHits.length > 0) { if (cacheHits.length > 0) {
log.info( log.info(
{ cacheHits: cacheHits.length, uncached: uncachedTargets.length, total: targets.length }, {
cacheHits: cacheHits.length,
uncached: uncachedTargets.length,
total: targets.length,
},
"User moderation cache applied — skipping LLM call for cached targets", "User moderation cache applied — skipping LLM call for cached targets",
); );
} }
@@ -1953,15 +1960,11 @@ Kategori: spam`;
} }
// ── Parse category from "Kategori: xxx" line ── // ── Parse category from "Kategori: xxx" line ──
const categoryMatch = analysis.match( const categoryMatch = analysis.match(/[Kk]ategori:\s*(\w+)/i);
/[Kk]ategori:\s*(\w+)/i,
);
if (categoryMatch) { if (categoryMatch) {
const parsedCat = categoryMatch[1].toLowerCase(); const parsedCat = categoryMatch[1].toLowerCase();
// Only accept known categories // Only accept known categories
if ( if (["harassment", "spam", "gambling", "sara"].includes(parsedCat)) {
["harassment", "spam", "gambling", "sara"].includes(parsedCat)
) {
category = parsedCat; category = parsedCat;
} }
// Strip the "Kategori:" line from the analysis text so it's cleaner // Strip the "Kategori:" line from the analysis text so it's cleaner
@@ -1969,7 +1972,12 @@ Kategori: spam`;
} }
log.info( log.info(
{ messageId: message.id, status, category, analysis: analysis.slice(0, 100) }, {
messageId: message.id,
status,
category,
analysis: analysis.slice(0, 100),
},
"Simple fallback step 2 — reason + category", "Simple fallback step 2 — reason + category",
); );
} catch (error) { } catch (error) {
@@ -1985,10 +1993,8 @@ Kategori: spam`;
} }
// Build the result fields using parsed category // Build the result fields using parsed category
const flags: string[] = const flags: string[] = status === "clean" ? [] : [category];
status === "clean" ? [] : [category]; const categories: string[] = status === "clean" ? [] : [category];
const categories: string[] =
status === "clean" ? [] : [category];
const score = status === "flagged" ? 0.7 : status === "warn" ? 0.4 : 0; const score = status === "flagged" ? 0.7 : status === "warn" ? 0.4 : 0;
const severity: "none" | "low" | "medium" | "high" | "critical" = const severity: "none" | "low" | "medium" | "high" | "critical" =
status === "flagged" ? "medium" : status === "warn" ? "low" : "none"; status === "flagged" ? "medium" : status === "warn" ? "low" : "none";
@@ -83,7 +83,11 @@ export function logModerationAnalysis(
model: string, model: string,
results: AnalysisResult[], results: AnalysisResult[],
duration_ms: number, duration_ms: number,
tokenUsage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }, tokenUsage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
},
parseErrors: string[] = [], parseErrors: string[] = [],
): void { ): void {
const response: ModerationAnalysisResponse = { const response: ModerationAnalysisResponse = {
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
const logger = createChildLogger("text-cache-store"); const logger = createChildLogger("text-cache-store");
@@ -255,10 +255,7 @@ export function makeUserModerationCacheKey(
userId: string, userId: string,
content: string, content: string,
): string { ): string {
const hash = createHash("sha256") const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
.update(content)
.digest("hex")
.slice(0, 16);
return `user_mod:${userId}:${hash}`; return `user_mod:${userId}:${hash}`;
} }
@@ -266,9 +263,7 @@ export function makeUserModerationCacheKey(
* Lookup a cached moderation result for a (user, content) pair. * Lookup a cached moderation result for a (user, content) pair.
* Returns the stored result fields or null. * Returns the stored result fields or null.
*/ */
export async function getCachedUserModeration( export async function getCachedUserModeration(cacheKey: string): Promise<{
cacheKey: string,
): Promise<{
status: "clean" | "flagged"; status: "clean" | "flagged";
flags: string[]; flags: string[];
score: number; score: number;
@@ -409,8 +404,9 @@ export async function computeImagePhash(
): Promise<string | null> { ): Promise<string | null> {
try { try {
// Dynamic import — imghash is ESM with a default export containing { hash, hashRaw, ... } // Dynamic import — imghash is ESM with a default export containing { hash, hashRaw, ... }
const imghashModule: { default?: { hash?: (buf: Buffer) => Promise<string> } } = const imghashModule: {
await import("imghash"); default?: { hash?: (buf: Buffer) => Promise<string> };
} = await import("imghash");
const hashFn = imghashModule.default?.hash; const hashFn = imghashModule.default?.hash;
if (typeof hashFn !== "function") return null; if (typeof hashFn !== "function") return null;
const hash = await hashFn(buffer); const hash = await hashFn(buffer);
@@ -451,13 +447,15 @@ export async function getRecentCorrectedModerations(
if (!rows || rows.length === 0) return []; if (!rows || rows.length === 0) return [];
return (rows as Array<{ return (
rows as Array<{
id: string; id: string;
original_flags: string; original_flags: string;
corrected_flags: string; corrected_flags: string;
correction_notes: string | null; correction_notes: string | null;
content_snippet: string; content_snippet: string;
}>).map((row) => ({ }>
).map((row) => ({
id: row.id, id: row.id,
originalFlags: JSON.parse(row.original_flags) as string[], originalFlags: JSON.parse(row.original_flags) as string[],
correctedFlags: JSON.parse(row.corrected_flags) as string[], correctedFlags: JSON.parse(row.corrected_flags) as string[],
@@ -1,11 +1,11 @@
import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { uploadToTele } from "./teleUpload.js"; import { config } from "../../shared/config/config.js";
import { import {
updateAttachmentAsFailedUpload, updateAttachmentAsFailedUpload,
updateAttachmentAsUploaded, updateAttachmentAsUploaded,
updateAttachmentDiscordUrl, updateAttachmentDiscordUrl,
} from "../message-capture/messageStore.js"; } from "../message-capture/messageStore.js";
import { uploadToTele } from "./teleUpload.js";
const logger = createChildLogger("attachment-uploader"); const logger = createChildLogger("attachment-uploader");
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import sharp from "sharp";
const log = createChildLogger("imageResizer"); const log = createChildLogger("imageResizer");
@@ -37,14 +37,8 @@ export async function uploadToTele(input: {
timeoutMs?: number; timeoutMs?: number;
retries: number; retries: number;
}): Promise<TeleUploadResult> { }): Promise<TeleUploadResult> {
const { const { buffer, filename, contentType, uploadUrl, timeoutMs, retries } =
buffer, input;
filename,
contentType,
uploadUrl,
timeoutMs,
retries,
} = input;
const response = await retryWithBackoff( const response = await retryWithBackoff(
async () => { async () => {
@@ -1,7 +1,7 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis"; import Redis from "ioredis";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger";
import { discordPlayer } from "../voice-recording/player.js"; import { discordPlayer } from "../voice-recording/player.js";
import type { VoiceController } from "../voice-recording/voiceController.js"; import type { VoiceController } from "../voice-recording/voiceController.js";
@@ -1,5 +1,5 @@
import Redis from "ioredis";
import type { CustomLogger } from "@bete/shared/logger"; import type { CustomLogger } from "@bete/shared/logger";
import Redis from "ioredis";
export interface DiscordGatewayEvent { export interface DiscordGatewayEvent {
type: string; type: string;
@@ -1,5 +1,5 @@
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
import type { MessageRecord } from "./types.js"; import type { MessageRecord } from "./types.js";
const logger = createChildLogger("analytics-store"); const logger = createChildLogger("analytics-store");
@@ -1,12 +1,12 @@
import type { WebSocket } from "ws";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { MediaState } from "../voice-recording/mediaTypes.js"; import type { WebSocket } from "ws";
import type { import type {
AnalysisQueueStatus, AnalysisQueueStatus,
AttachmentRecord, AttachmentRecord,
MessageRecord, MessageRecord,
ModerationWsEvent, ModerationWsEvent,
} from "../message-capture/types.js"; } from "../message-capture/types.js";
import type { MediaState } from "../voice-recording/mediaTypes.js";
export type BroadcasterClient = Pick<WebSocket, "readyState" | "send">; export type BroadcasterClient = Pick<WebSocket, "readyState" | "send">;
@@ -1,4 +1,3 @@
export { registerMessageCapture } from "./messageCapture.js";
export { export {
getDisplayContent, getDisplayContent,
getMessageLocation, getMessageLocation,
@@ -19,3 +18,4 @@ export type {
MessageRecord, MessageRecord,
VoiceSegmentRecord, VoiceSegmentRecord,
} from "../message-capture/types.js"; } from "../message-capture/types.js";
export { registerMessageCapture } from "./messageCapture.js";
@@ -1,6 +1,6 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Client, Message } from "discord.js-selfbot-v13"; import type { Client, Message } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger";
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js"; import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js"; import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js"; import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
@@ -16,7 +16,10 @@ import {
updateMessageAsEdited, updateMessageAsEdited,
upsertMessageForCapture, upsertMessageForCapture,
} from "../message-capture/messageStore.js"; } from "../message-capture/messageStore.js";
import type { AttachmentRecord, MessageRecord } from "../message-capture/types.js"; import type {
AttachmentRecord,
MessageRecord,
} from "../message-capture/types.js";
const logger = createChildLogger("message-capture"); const logger = createChildLogger("message-capture");
@@ -1,3 +1,4 @@
import { createChildLogger } from "@bete/shared/logger";
import { import {
and, and,
asc, asc,
@@ -17,7 +18,6 @@ import {
moderationActionsTable, moderationActionsTable,
retentionPoliciesTable, retentionPoliciesTable,
} from "../../shared/database/schema.js"; } from "../../shared/database/schema.js";
import { createChildLogger } from "@bete/shared/logger";
import { decodeCursor, encodeCursor } from "../message-capture/pagination.js"; import { decodeCursor, encodeCursor } from "../message-capture/pagination.js";
import type { import type {
AttachmentRecord, AttachmentRecord,
@@ -8,10 +8,7 @@ import {
StreamType, StreamType,
VoiceConnection, VoiceConnection,
} from "@discordjs/voice"; } from "@discordjs/voice";
import type { import type { DiscordPlayerOwner, DiscordPlayOptions } from "./mediaTypes.js";
DiscordPlayerOwner,
DiscordPlayOptions,
} from "./mediaTypes.js";
export class DiscordPlayer { export class DiscordPlayer {
private player: AudioPlayer; private player: AudioPlayer;
@@ -1,5 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { createChildLogger } from "@bete/shared/logger";
import { retryWithBackoff } from "@bete/shared/utils";
import { import {
type DiscordGatewayAdapterCreator, type DiscordGatewayAdapterCreator,
EndBehaviorType, EndBehaviorType,
@@ -11,7 +13,7 @@ import {
} from "@discordjs/voice"; } from "@discordjs/voice";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger"; import type { PcmBroadcaster } from "../message-capture/types.js";
import { PacketFilter } from "./packetFilter.js"; import { PacketFilter } from "./packetFilter.js";
import { subscribeToAudioStream } from "./recorder/audioStream.js"; import { subscribeToAudioStream } from "./recorder/audioStream.js";
import { OpusDecoder } from "./recorder/decoder.js"; import { OpusDecoder } from "./recorder/decoder.js";
@@ -26,8 +28,6 @@ import {
type RecordingSession, type RecordingSession,
} from "./recorder/sessionRecording.js"; } from "./recorder/sessionRecording.js";
import { uploadRecordingSegment } from "./recorder/uploader.js"; import { uploadRecordingSegment } from "./recorder/uploader.js";
import { retryWithBackoff } from "@bete/shared/utils";
import type { PcmBroadcaster } from "../message-capture/types.js";
const logger = createChildLogger("recorder"); const logger = createChildLogger("recorder");
@@ -1,7 +1,11 @@
import path from "node:path"; import path from "node:path";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
import { config } from "../../../shared/config/config.js"; import { config } from "../../../shared/config/config.js";
import type { SegmentMetadata, SegmentState, UserMetadata } from "../../message-capture/types.js"; import type {
SegmentMetadata,
SegmentState,
UserMetadata,
} from "../../message-capture/types.js";
export async function collectUserMetadata( export async function collectUserMetadata(
client: Client, client: Client,
@@ -1,10 +1,10 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { UserMetadata } from "../../message-capture/types.js";
import { import {
buildMuxFfmpegArgs, buildMuxFfmpegArgs,
runFfmpeg as defaultRunFfmpeg, runFfmpeg as defaultRunFfmpeg,
} from "../ffmpegProcess.js"; } from "../ffmpegProcess.js";
import type { UserMetadata } from "../../message-capture/types.js";
export type SessionRecordingStatus = export type SessionRecordingStatus =
| "pending" | "pending"
@@ -1,12 +1,12 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { createChildLogger } from "@bete/shared/logger";
import { config } from "../../../shared/config/config.js"; import { config } from "../../../shared/config/config.js";
import { import {
insertVoiceRecording, insertVoiceRecording,
updateVoiceRecordingAsFailed, updateVoiceRecordingAsFailed,
updateVoiceRecordingAsUploaded, updateVoiceRecordingAsUploaded,
} from "../../../shared/database/voiceRecordingRepo.js"; } from "../../../shared/database/voiceRecordingRepo.js";
import { createChildLogger } from "@bete/shared/logger";
import { uploadToTele } from "../teleUpload.js"; import { uploadToTele } from "../teleUpload.js";
const logger = createChildLogger("recording-uploader"); const logger = createChildLogger("recording-uploader");
@@ -37,14 +37,8 @@ export async function uploadToTele(input: {
timeoutMs?: number; timeoutMs?: number;
retries: number; retries: number;
}): Promise<TeleUploadResult> { }): Promise<TeleUploadResult> {
const { const { buffer, filename, contentType, uploadUrl, timeoutMs, retries } =
buffer, input;
filename,
contentType,
uploadUrl,
timeoutMs,
retries,
} = input;
const response = await retryWithBackoff( const response = await retryWithBackoff(
async () => { async () => {
@@ -1,7 +1,7 @@
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { AppError } from "@bete/shared/errors"; import { AppError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice";
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import { discordPlayer } from "./player.js"; import { discordPlayer } from "./player.js";
import { startRecording, stopRecording } from "./recorder.js"; import { startRecording, stopRecording } from "./recorder.js";
@@ -1,8 +1,8 @@
import { createChildLogger } from "@bete/shared/logger";
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
import type { PoolClient } from "pg"; import type { PoolClient } from "pg";
import { Pool } from "pg"; import { Pool } from "pg";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { createChildLogger } from "@bete/shared/logger";
import * as schema from "./schema.js"; import * as schema from "./schema.js";
const logger = createChildLogger("drizzle"); const logger = createChildLogger("drizzle");
@@ -1,10 +1,10 @@
import "dotenv/config"; import "dotenv/config";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import type { PoolClient } from "pg"; import { createChildLogger } from "@bete/shared/logger";
import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres";
import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator"; import { migrate as migratePostgres } from "drizzle-orm/node-postgres/migrator";
import { createChildLogger } from "@bete/shared/logger"; import type { PoolClient } from "pg";
import { import {
closeDatabase, closeDatabase,
initializeDatabase, initializeDatabase,
@@ -44,7 +44,9 @@ async function getFirstMigrationTag(): Promise<string> {
const journal: MigrationJournal = JSON.parse(raw); const journal: MigrationJournal = JSON.parse(raw);
if (!journal.entries || journal.entries.length === 0) { if (!journal.entries || journal.entries.length === 0) {
throw new Error("Migration journal is empty — cannot determine first migration tag"); throw new Error(
"Migration journal is empty — cannot determine first migration tag",
);
} }
// Entries are ordered by idx — the first entry is the initial migration. // Entries are ordered by idx — the first entry is the initial migration.
@@ -110,7 +112,10 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
[firstMigrationTag, Date.now()], [firstMigrationTag, Date.now()],
); );
} }
logger.info({ firstMigrationTag }, "Drizzle history seeded — first migration marked applied"); logger.info(
{ firstMigrationTag },
"Drizzle history seeded — first migration marked applied",
);
} }
export async function runMigrations(): Promise<void> { export async function runMigrations(): Promise<void> {
@@ -497,4 +497,5 @@ export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert; export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
export type CorrectedModeration = typeof correctedModerationsTable.$inferSelect; export type CorrectedModeration = typeof correctedModerationsTable.$inferSelect;
export type CorrectedModerationInsert = typeof correctedModerationsTable.$inferInsert; export type CorrectedModerationInsert =
typeof correctedModerationsTable.$inferInsert;
@@ -1,5 +1,5 @@
import { desc, eq } from "drizzle-orm";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { desc, eq } from "drizzle-orm";
import { getDatabase } from "./drizzle.js"; import { getDatabase } from "./drizzle.js";
import { import {
type VoiceRecording, type VoiceRecording,