feat: add sticker context to LLM prompts + sticker image cache
- New sticker-specific vision prompt that tells LLM stickers are cartoon/meme art, not real photos - New text-only warning for stickers that fail to download — prevents flagging based on name alone - Updated system prompt with dedicated sticker guidance section (looser standards for cartoon content) - Filesystem-backed sticker cache (keyed by name, 7-day TTL, 100MB max with LRU eviction) - Config: STICKER_CACHE_DIR and STICKER_CACHE_MAX_SIZE_MB with defaults - Updated .env.example with auto-delete + sticker config docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
202ea311a5
commit
4fa7875b80
+3
-1
@@ -136,11 +136,13 @@ const configSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
.default(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_SEVERITIES: z.string().default("critical,high,medium"),
|
||||||
AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""),
|
AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""),
|
||||||
AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
|
AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""),
|
||||||
AUTO_DELETE_EXCLUDED_USER_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_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
||||||
RETENTION_ATTACHMENTS_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),
|
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ import { createChildLogger } from "../logger.js";
|
|||||||
import { retryWithBackoff } from "../retry.js";
|
import { retryWithBackoff } from "../retry.js";
|
||||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||||
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
||||||
|
import {
|
||||||
|
buildStickerTextOnlyWarning,
|
||||||
|
buildStickerVisionPrompt,
|
||||||
|
} from "./stickerPrompt.js";
|
||||||
|
import {
|
||||||
|
getStickerFromCache,
|
||||||
|
initStickerCache,
|
||||||
|
isStickerCacheReady,
|
||||||
|
setStickerInCache,
|
||||||
|
} from "./stickerCache.js";
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
@@ -50,7 +60,10 @@ function hasDeferralAnalysis(analysis: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clampScore(value: number | undefined, fallback = 0): number {
|
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(
|
function deriveSeverity(
|
||||||
@@ -272,7 +285,8 @@ export function parseModerationResponse(
|
|||||||
|
|
||||||
const normalizedScore = clampScore(score);
|
const normalizedScore = clampScore(score);
|
||||||
const normalizedConfidence = clampScore(confidence, normalizedScore);
|
const normalizedConfidence = clampScore(confidence, normalizedScore);
|
||||||
const normalizedSeverity = severity ?? deriveSeverity(status, normalizedScore);
|
const normalizedSeverity =
|
||||||
|
severity ?? deriveSeverity(status, normalizedScore);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: finalId,
|
messageId: finalId,
|
||||||
@@ -284,7 +298,8 @@ export function parseModerationResponse(
|
|||||||
severity: normalizedSeverity,
|
severity: normalizedSeverity,
|
||||||
confidence: normalizedConfidence,
|
confidence: normalizedConfidence,
|
||||||
recommendedAction:
|
recommendedAction:
|
||||||
recommended_action ?? deriveRecommendedAction(status, normalizedSeverity),
|
recommended_action ??
|
||||||
|
deriveRecommendedAction(status, normalizedSeverity),
|
||||||
policyVersion: policy_version ?? "default-2026-05-30",
|
policyVersion: policy_version ?? "default-2026-05-30",
|
||||||
evidence: evidence ?? [],
|
evidence: evidence ?? [],
|
||||||
};
|
};
|
||||||
@@ -426,6 +441,19 @@ export async function runModerationAnalysis(
|
|||||||
throw new Error("No targets provided for analysis");
|
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);
|
const targetIds = targets.map((t) => t.id);
|
||||||
|
|
||||||
// Build a lookup: message_id → list of resolved base64 image parts
|
// Build a lookup: message_id → list of resolved base64 image parts
|
||||||
@@ -433,6 +461,7 @@ export async function runModerationAnalysis(
|
|||||||
type: "image_url";
|
type: "image_url";
|
||||||
image_url: { url: string };
|
image_url: { url: string };
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
|
stickerName?: string;
|
||||||
};
|
};
|
||||||
type MessageImageMap = Map<string, MessageImagePart[]>;
|
type MessageImageMap = Map<string, MessageImagePart[]>;
|
||||||
|
|
||||||
@@ -600,6 +629,7 @@ export async function runModerationAnalysis(
|
|||||||
messageId: msg.id,
|
messageId: msg.id,
|
||||||
url: sticker.url,
|
url: sticker.url,
|
||||||
label: `[gambar di atas adalah sticker "${sticker.name}" dari pesan id=${msg.id}]`,
|
label: `[gambar di atas adalah sticker "${sticker.name}" dari pesan id=${msg.id}]`,
|
||||||
|
stickerName: sticker.name,
|
||||||
})),
|
})),
|
||||||
...evidence.embeds.flatMap((embed) =>
|
...evidence.embeds.flatMap((embed) =>
|
||||||
[
|
[
|
||||||
@@ -618,8 +648,14 @@ export async function runModerationAnalysis(
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter(
|
].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,20 +663,77 @@ export async function runModerationAnalysis(
|
|||||||
|
|
||||||
const remainingImageSlots = Math.max(
|
const remainingImageSlots = Math.max(
|
||||||
0,
|
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(
|
await Promise.all(
|
||||||
mediaImageCandidates.slice(0, remainingImageSlots).map(async (candidate) => {
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cache miss or non-sticker: fetch normally ---
|
||||||
const result = await fetchUrlSafely(candidate.url);
|
const result = await fetchUrlSafely(candidate.url);
|
||||||
if (result.type !== "image" || !result.data || !result.mimeType) return;
|
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 = {
|
const part: MessageImagePart = {
|
||||||
type: "image_url",
|
type: "image_url",
|
||||||
image_url: {
|
image_url: {
|
||||||
url: `data:${result.mimeType};base64,${result.data.toString("base64")}`,
|
url: `data:${result.mimeType};base64,${base64}`,
|
||||||
},
|
},
|
||||||
sourceLabel: candidate.label,
|
sourceLabel: candidate.label,
|
||||||
|
stickerName: candidate.stickerName,
|
||||||
};
|
};
|
||||||
const existing = messageImageMap.get(candidate.messageId) ?? [];
|
const existing = messageImageMap.get(candidate.messageId) ?? [];
|
||||||
existing.push(part);
|
existing.push(part);
|
||||||
@@ -661,7 +754,9 @@ export async function runModerationAnalysis(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
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 },
|
{ type: "image_url", image_url: image.image_url },
|
||||||
],
|
],
|
||||||
@@ -703,7 +798,6 @@ export async function runModerationAnalysis(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// System prompt — Indonesian-first, English as secondary language.
|
// System prompt — Indonesian-first, English as secondary language.
|
||||||
//
|
//
|
||||||
@@ -725,7 +819,16 @@ export async function runModerationAnalysis(
|
|||||||
## Instruksi Analisis Media
|
## Instruksi Analisis Media
|
||||||
Gambar, sticker, embed image, preview link, dan attachment sudah dianalisis lewat request media terpisah sebelum batch utama.
|
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.
|
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.
|
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 content = msg.edited_content ?? msg.content;
|
||||||
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
const webTexts = messageWebTextMap.get(msg.id) ?? [];
|
||||||
const mediaAnalyses = messageMediaAnalysisMap.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 textEvidence = textEvidenceMap.get(msg.id) ?? "";
|
||||||
const textContext = textEvidence ? `\n${textEvidence}` : "";
|
const textContext = textEvidence ? `\n${textEvidence}` : "";
|
||||||
const mediaAnalysisContext =
|
const mediaAnalysisContext =
|
||||||
@@ -838,11 +942,17 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
|
|||||||
const mediaEvidence = extractMessageMediaEvidence(msg.metadata);
|
const mediaEvidence = extractMessageMediaEvidence(msg.metadata);
|
||||||
const mediaContext = [
|
const mediaContext = [
|
||||||
mediaEvidence.stickers.length > 0
|
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,
|
: null,
|
||||||
mediaEvidence.embeds.length > 0
|
mediaEvidence.embeds.length > 0
|
||||||
? `[embed evidence: ${mediaEvidence.embeds
|
? `[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(" || ")}]`
|
.join(" || ")}]`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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<string, CacheIndexEntry>;
|
||||||
|
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<CacheIndex> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<StickerCacheEntry | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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.]`
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user