diff --git a/src/config.ts b/src/config.ts index 1f9d96b..aff1002 100644 --- a/src/config.ts +++ b/src/config.ts @@ -136,11 +136,13 @@ const configSchema = z .optional() .transform((v) => v === "true") .default(true), - AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.50), + AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5), AUTO_DELETE_ALLOWED_SEVERITIES: z.string().default("critical,high,medium"), AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""), AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""), AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""), + STICKER_CACHE_DIR: z.string().default("./sticker-cache"), + STICKER_CACHE_MAX_SIZE_MB: z.coerce.number().int().positive().default(100), RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0), diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 8321216..9666b42 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -6,6 +6,16 @@ import { createChildLogger } from "../logger.js"; import { retryWithBackoff } from "../retry.js"; import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js"; import { extractMessageMediaEvidence } from "./messageMetadata.js"; +import { + buildStickerTextOnlyWarning, + buildStickerVisionPrompt, +} from "./stickerPrompt.js"; +import { + getStickerFromCache, + initStickerCache, + isStickerCacheReady, + setStickerInCache, +} from "./stickerCache.js"; import type { AnalysisResult, AttachmentRecord, @@ -50,7 +60,10 @@ function hasDeferralAnalysis(analysis: string): boolean { } function clampScore(value: number | undefined, fallback = 0): number { - return Math.max(0, Math.min(1, Number.isFinite(value) ? (value as number) : fallback)); + return Math.max( + 0, + Math.min(1, Number.isFinite(value) ? (value as number) : fallback), + ); } function deriveSeverity( @@ -272,7 +285,8 @@ export function parseModerationResponse( const normalizedScore = clampScore(score); const normalizedConfidence = clampScore(confidence, normalizedScore); - const normalizedSeverity = severity ?? deriveSeverity(status, normalizedScore); + const normalizedSeverity = + severity ?? deriveSeverity(status, normalizedScore); return { messageId: finalId, @@ -284,7 +298,8 @@ export function parseModerationResponse( severity: normalizedSeverity, confidence: normalizedConfidence, recommendedAction: - recommended_action ?? deriveRecommendedAction(status, normalizedSeverity), + recommended_action ?? + deriveRecommendedAction(status, normalizedSeverity), policyVersion: policy_version ?? "default-2026-05-30", evidence: evidence ?? [], }; @@ -426,6 +441,19 @@ export async function runModerationAnalysis( throw new Error("No targets provided for analysis"); } + // Lazy init sticker cache on first run + if (!isStickerCacheReady()) { + await initStickerCache({ + cacheDir: config.STICKER_CACHE_DIR, + maxSizeBytes: config.STICKER_CACHE_MAX_SIZE_MB * 1024 * 1024, + }).catch((err) => { + log.warn( + { error: err instanceof Error ? err.message : String(err) }, + "Sticker cache init failed — continuing without cache", + ); + }); + } + const targetIds = targets.map((t) => t.id); // Build a lookup: message_id → list of resolved base64 image parts @@ -433,6 +461,7 @@ export async function runModerationAnalysis( type: "image_url"; image_url: { url: string }; sourceLabel: string; + stickerName?: string; }; type MessageImageMap = Map; @@ -600,6 +629,7 @@ export async function runModerationAnalysis( messageId: msg.id, url: sticker.url, label: `[gambar di atas adalah sticker "${sticker.name}" dari pesan id=${msg.id}]`, + stickerName: sticker.name, })), ...evidence.embeds.flatMap((embed) => [ @@ -618,8 +648,14 @@ export async function runModerationAnalysis( } : null, ].filter( - (candidate): candidate is { messageId: string; url: string; label: string } => - candidate !== null, + ( + candidate, + ): candidate is { + messageId: string; + url: string; + label: string; + stickerName?: string; + } => candidate !== null, ), ), ]; @@ -627,25 +663,82 @@ export async function runModerationAnalysis( const remainingImageSlots = Math.max( 0, - 8 - Array.from(messageImageMap.values()).reduce((sum, imgs) => sum + imgs.length, 0), + 8 - + Array.from(messageImageMap.values()).reduce( + (sum, imgs) => sum + imgs.length, + 0, + ), ); await Promise.all( - mediaImageCandidates.slice(0, remainingImageSlots).map(async (candidate) => { - const result = await fetchUrlSafely(candidate.url); - if (result.type !== "image" || !result.data || !result.mimeType) return; + mediaImageCandidates + .slice(0, remainingImageSlots) + .map(async (candidate) => { + // --- Sticker cache check --- + if (candidate.stickerName && isStickerCacheReady()) { + try { + const cached = await getStickerFromCache(candidate.stickerName); + if (cached) { + const part: MessageImagePart = { + type: "image_url", + image_url: { + url: `data:${cached.mimeType};base64,${cached.base64}`, + }, + sourceLabel: candidate.label, + stickerName: candidate.stickerName, + }; + const existing = messageImageMap.get(candidate.messageId) ?? []; + existing.push(part); + messageImageMap.set(candidate.messageId, existing); + log.debug( + { stickerName: candidate.stickerName }, + "Sticker cache HIT — skipped fetch", + ); + return; + } + } catch (err) { + log.debug( + { + stickerName: candidate.stickerName, + error: err instanceof Error ? err.message : String(err), + }, + "Sticker cache read error — falling back to fetch", + ); + } + } - const part: MessageImagePart = { - type: "image_url", - image_url: { - url: `data:${result.mimeType};base64,${result.data.toString("base64")}`, - }, - sourceLabel: candidate.label, - }; - const existing = messageImageMap.get(candidate.messageId) ?? []; - existing.push(part); - messageImageMap.set(candidate.messageId, existing); - }), + // --- Cache miss or non-sticker: fetch normally --- + const result = await fetchUrlSafely(candidate.url); + if (result.type !== "image" || !result.data || !result.mimeType) return; + + const base64 = result.data.toString("base64"); + + // Cache on success (sticker only) + if (candidate.stickerName) { + setStickerInCache( + candidate.stickerName, + base64, + result.mimeType, + ).catch((err) => { + log.warn( + { error: err instanceof Error ? err.message : String(err) }, + "Failed to cache sticker — continuing without cache", + ); + }); + } + + const part: MessageImagePart = { + type: "image_url", + image_url: { + url: `data:${result.mimeType};base64,${base64}`, + }, + sourceLabel: candidate.label, + stickerName: candidate.stickerName, + }; + const existing = messageImageMap.get(candidate.messageId) ?? []; + existing.push(part); + messageImageMap.set(candidate.messageId, existing); + }), ); const analyzeSingleMediaImage = async ( @@ -661,7 +754,9 @@ export async function runModerationAnalysis( content: [ { type: "text", - text: `Analisis media Discord berikut sebagai evidence moderasi. ${image.sourceLabel}\nJelaskan isi visual, teks yang terlihat, konteks risiko, dan apakah ada indikasi spam, scam, SARA, harassment, sexual content, violence, self-harm, doxxing, NSFW, gore, atau illegal content. Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek; berikan observasi langsung dari media.`, + text: image.stickerName + ? buildStickerVisionPrompt(image.stickerName, messageId) + : `Analisis media Discord berikut sebagai evidence moderasi. ${image.sourceLabel}\nJelaskan isi visual, teks yang terlihat, konteks risiko, dan apakah ada indikasi spam, scam, SARA, harassment, sexual content, violence, self-harm, doxxing, NSFW, gore, atau illegal content. Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek; berikan observasi langsung dari media.`, }, { type: "image_url", image_url: image.image_url }, ], @@ -703,7 +798,6 @@ export async function runModerationAnalysis( ), ); - // ------------------------------------------------------------------------- // System prompt — Indonesian-first, English as secondary language. // @@ -725,7 +819,16 @@ export async function runModerationAnalysis( ## Instruksi Analisis Media Gambar, sticker, embed image, preview link, dan attachment sudah dianalisis lewat request media terpisah sebelum batch utama. Gunakan baris "Media analysis" sebagai evidence visual utama dalam keputusan moderasi batch ini. -Sticker wajib diperlakukan sebagai image evidence, bukan sekadar nama sticker. + +## Panduan Khusus Sticker +- Sticker Discord adalah media kartun/meme/ilustrasi, BUKAN foto atau video nyata. +- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan. +- Gambar sticker bisa menampilkan adegan kartun yang terlihat "keras" (tokoh kartun menginjak sesuatu, ledakan komik, senjata kartun) — itu SENI KARTUN, bukan dokumentasi kekerasan nyata. +- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat", "Bom atom", dll) adalah konteks satir/humor. JANGAN flag "violence", "harassment", atau "sara" berdasarkan nama sticker saja tanpa melihat gambar. +- Jika sticker evidence hanya tersedia sebagai nama (gambar gagal diunduh), abaikan sebagai evidence pelanggaran — nama sticker saja TIDAK cukup untuk flag. +- Terapkan standar yang lebih longgar untuk konten kartun/meme dibanding foto/video nyata. + +Sticker yang berhasil diunduh WAJIB diperlakukan sebagai image evidence, bukan sekadar nama sticker. Jangan abaikan link: gunakan isi web, preview image, atau hasil analisis media link bila tersedia. `; @@ -830,7 +933,8 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan const content = msg.edited_content ?? msg.content; const webTexts = messageWebTextMap.get(msg.id) ?? []; const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? []; - const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; + const webContext = + webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; const textEvidence = textEvidenceMap.get(msg.id) ?? ""; const textContext = textEvidence ? `\n${textEvidence}` : ""; const mediaAnalysisContext = @@ -838,11 +942,17 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan const mediaEvidence = extractMessageMediaEvidence(msg.metadata); const mediaContext = [ mediaEvidence.stickers.length > 0 - ? `[sticker evidence: ${mediaEvidence.stickers.map((s) => `${s.name} (${s.url})`).join(" | ")}]` + ? mediaEvidence.stickers + .map((s) => buildStickerTextOnlyWarning(s.name, s.url)) + .join(" ") : null, mediaEvidence.embeds.length > 0 ? `[embed evidence: ${mediaEvidence.embeds - .map((e) => [e.title, e.description, e.url, e.image, e.thumbnail].filter(Boolean).join(" | ")) + .map((e) => + [e.title, e.description, e.url, e.image, e.thumbnail] + .filter(Boolean) + .join(" | "), + ) .join(" || ")}]` : null, ] diff --git a/src/moderation/stickerCache.ts b/src/moderation/stickerCache.ts new file mode 100644 index 0000000..f440446 --- /dev/null +++ b/src/moderation/stickerCache.ts @@ -0,0 +1,209 @@ +import { mkdir, readFile, writeFile, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { createChildLogger } from "../logger.js"; + +const logger = createChildLogger("sticker-cache"); + +export interface StickerCacheEntry { + base64: string; + mimeType: string; + fetchedAt: number; + size: number; +} + +interface CacheIndexEntry { + file: string; + mimeType: string; + size: number; + fetchedAt: number; +} + +interface CacheIndex { + entries: Record; + totalSizeBytes: number; +} + +export interface StickerCacheOptions { + cacheDir: string; + maxSizeBytes: number; + ttlMs?: number; +} + +let cacheDir = ""; +let maxSizeBytes = 0; +let ttlMs = 7 * 24 * 60 * 60 * 1000; // 7 days default +let index: CacheIndex = { entries: {}, totalSizeBytes: 0 }; +let ready = false; + +function sanitizeKey(name: string): string { + return encodeURIComponent(name).replace(/%/g, "_"); +} + +async function loadIndex(): Promise { + try { + const raw = await readFile(join(cacheDir, "index.json"), "utf-8"); + return JSON.parse(raw) as CacheIndex; + } catch { + return { entries: {}, totalSizeBytes: 0 }; + } +} + +async function saveIndex(idx: CacheIndex): Promise { + await writeFile( + join(cacheDir, "index.json"), + JSON.stringify(idx, null, 2), + "utf-8", + ); +} + +/** + * Initialise the sticker cache: create directory, load index. + * Idempotent — safe to call multiple times. + */ +export async function initStickerCache( + opts: StickerCacheOptions, +): Promise { + if (ready) return; + cacheDir = opts.cacheDir; + maxSizeBytes = opts.maxSizeBytes; + ttlMs = opts.ttlMs ?? 7 * 24 * 60 * 60 * 1000; + + await mkdir(cacheDir, { recursive: true }); + index = await loadIndex(); + + // Prune expired entries on startup + const now = Date.now(); + let changed = false; + for (const [key, meta] of Object.entries(index.entries)) { + if (now - meta.fetchedAt > ttlMs) { + await unlink(join(cacheDir, meta.file)).catch(() => {}); + index.totalSizeBytes -= meta.size; + delete index.entries[key]; + changed = true; + } + } + if (changed) await saveIndex(index); + + ready = true; + logger.info( + { + entryCount: Object.keys(index.entries).length, + totalSizeBytes: index.totalSizeBytes, + }, + "Sticker cache initialized", + ); +} + +/** + * Look up a sticker image by name. Returns null on miss or TTL expiry. + */ +export async function getStickerFromCache( + stickerName: string, +): Promise { + if (!ready) return null; + + const key = sanitizeKey(stickerName); + const meta = index.entries[key]; + if (!meta) return null; + + // TTL check + if (Date.now() - meta.fetchedAt > ttlMs) { + await unlink(join(cacheDir, meta.file)).catch(() => {}); + index.totalSizeBytes -= meta.size; + delete index.entries[key]; + await saveIndex(index); + return null; + } + + try { + const raw = await readFile(join(cacheDir, meta.file), "utf-8"); + return { + base64: raw, + mimeType: meta.mimeType, + fetchedAt: meta.fetchedAt, + size: meta.size, + }; + } catch { + // File missing — clean up index entry + delete index.entries[key]; + await saveIndex(index); + return null; + } +} + +/** + * Store a sticker image in the cache. Fires and forgets — never blocks. + */ +export async function setStickerInCache( + stickerName: string, + base64: string, + mimeType: string, +): Promise { + if (!ready) return; + + const key = sanitizeKey(stickerName); + const fileName = `${key}.dat`; + const size = Buffer.byteLength(base64, "utf-8"); + + // Evict if needed + await evictIfNeeded(size); + + try { + await writeFile(join(cacheDir, fileName), base64, "utf-8"); + index.entries[key] = { + file: fileName, + mimeType, + size, + fetchedAt: Date.now(), + }; + index.totalSizeBytes += size; + await saveIndex(index); + logger.debug({ stickerName, size }, "Sticker cached"); + } catch (err) { + logger.warn( + { stickerName, error: err instanceof Error ? err.message : String(err) }, + "Failed to write sticker to cache", + ); + } +} + +async function evictIfNeeded(newSize: number): Promise { + while (index.totalSizeBytes + newSize > maxSizeBytes) { + // Find oldest entry + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, meta] of Object.entries(index.entries)) { + if (meta.fetchedAt < oldestTime) { + oldestTime = meta.fetchedAt; + oldestKey = key; + } + } + if (!oldestKey) break; + + const meta = index.entries[oldestKey]; + await unlink(join(cacheDir, meta.file)).catch(() => {}); + index.totalSizeBytes -= meta.size; + delete index.entries[oldestKey]; + } + await saveIndex(index); +} + +/** + * Return current cache stats for observability. + */ +export function getStickerCacheStats(): { + entryCount: number; + totalSizeBytes: number; +} { + return { + entryCount: Object.keys(index.entries).length, + totalSizeBytes: index.totalSizeBytes, + }; +} + +/** + * Check if cache has been initialized. + */ +export function isStickerCacheReady(): boolean { + return ready; +} diff --git a/src/moderation/stickerPrompt.ts b/src/moderation/stickerPrompt.ts new file mode 100644 index 0000000..6189d7f --- /dev/null +++ b/src/moderation/stickerPrompt.ts @@ -0,0 +1,59 @@ +/** + * Sticker-specific prompt templates for AI moderation. + * + * Discord stickers are cartoon/meme artwork — not real photos. + * These prompts give the LLM proper context to avoid false-positive flags + * based solely on sticker names or cartoon imagery. + */ + +/** + * Prompt used when a sticker image was successfully downloaded (from cache + * or network) and is being sent to the vision LLM as a base64 image. + * + * Explains that stickers are cartoon art, not documentation of real events, + * and instructs the model to apply looser standards for cartoon content. + */ +export function buildStickerVisionPrompt( + stickerName: string, + messageId: string, +): string { + return [ + `Analisis sticker Discord berikut sebagai evidence moderasi.`, + `Sticker "${stickerName}" berasal dari pesan id=${messageId}.`, + ``, + `PENTING — Konteks Sticker:`, + `- Sticker Discord adalah gambar KARTUN/MEME/ILUSTRASI, BUKAN foto atau video nyata.`, + `- Sticker sering bersifat humor, satir, atau ekspresi emosi yang dilebih-lebihkan.`, + `- Gambar di sticker bisa menampilkan adegan yang terlihat "keras" (tokoh kartun menginjak sesuatu, ledakan komik, senjata kartun, tokoh berantem) — itu SENI KARTUN, bukan dokumentasi kekerasan atau ancaman nyata.`, + `- Teks di sticker sering berupa lelucon, sindiran, atau ekspresi khas komunitas — bukan ancaman literal.`, + ``, + `Jelaskan isi visual, teks yang terlihat, dan konteks risiko.`, + `Terapkan standar yang lebih longgar untuk konten kartun/meme:`, + `- Adegan kartun yang terlihat "keras" ≠ kekerasan nyata → jangan flag "violence" kecuali jelas menargetkan individu/kelompok nyata dengan ancaman serius.`, + `- Nama sticker yang terdengar provokatif (mis. "Singa injek pejabat") adalah konteks satir/kartun, bukan bukti pelanggaran.`, + `- Humor/satir/politik kartun ≠ SARA atau hate speech.`, + `- Sticker yang menampilkan tokoh kartun dalam pose agresif adalah ekspresi/emosi umum di Discord, bukan harassment.`, + ``, + `Jawab Bahasa Indonesia, maksimal 3 kalimat. Jangan bilang kurang konteks atau perlu admin cek.`, + ].join("\n"); +} + +/** + * Wrapper for text-only evidence when a sticker image failed to download. + * + * Returns a formatted string that explicitly tells the LLM not to flag + * based on the sticker name alone, since names can sound provocative + * while the actual cartoon image is harmless. + */ +export function buildStickerTextOnlyWarning( + stickerName: string, + stickerUrl: string, +): string { + return ( + `[sticker: "${stickerName}" (${stickerUrl}) — GAMBAR GAGAL DIUNDUH. ` + + `"${stickerName}" adalah sticker kartun/meme Discord. ` + + `JANGAN flag berdasarkan nama sticker saja tanpa gambar visual. ` + + `Sticker Discord adalah seni kartun/ekspresi humor, bukan foto nyata. ` + + `Nama yang terdengar provokatif adalah hal umum untuk sticker satir/humor di Discord.]` + ); +}