From 5078b34ac243deaa6182ee1b44bf6d2a5383c3a7 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 1 Jun 2026 17:23:46 +0700 Subject: [PATCH] fix: render custom emoji as images and feed to AI vision pipeline - Extract custom emoji metadata with Discord CDN URLs - Download and send emoji images to vision model for moderation analysis - Render custom emoji as inline images in dashboard instead of raw <:name:id> text - Add emoji vision cache with deterministic keying by emoji ID - Add custom emoji vision prompt for context-aware moderation --- .../messages/components/MessageCard.tsx | 54 ++++++++++++++++++- src/moderation/llmModerationClient.ts | 52 ++++++++++++++---- src/moderation/messageMetadata.ts | 36 +++++++++++++ src/moderation/stickerPrompt.ts | 39 ++++++++++++++ src/moderation/textCacheStore.ts | 7 +++ 5 files changed, 175 insertions(+), 13 deletions(-) diff --git a/frontend/src/features/messages/components/MessageCard.tsx b/frontend/src/features/messages/components/MessageCard.tsx index 3173f1b..e228fb0 100644 --- a/frontend/src/features/messages/components/MessageCard.tsx +++ b/frontend/src/features/messages/components/MessageCard.tsx @@ -1,8 +1,58 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, Fragment } from "react"; import type { MessageRecord } from "../../../shared/api/client"; import { Badge, Button, Skeleton } from "../../../shared/ui"; import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react"; +const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; + +/** + * Renders message content with Discord custom emojis displayed as images + * instead of raw text like `<:name:id>`. + */ +function renderContentWithCustomEmojis(content: string): React.ReactNode { + const parts: React.ReactNode[] = []; + const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g"); + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + // Text before the emoji + if (match.index > lastIndex) { + parts.push(content.slice(lastIndex, match.index)); + } + + const [, animated, name, id] = match; + const ext = animated ? "gif" : "png"; + const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`; + + parts.push( + {name}, + ); + + lastIndex = regex.lastIndex; + } + + // Remaining text after last emoji + if (lastIndex < content.length) { + parts.push(content.slice(lastIndex)); + } + + // If no emojis were found, just return the raw content + if (parts.length === 0) { + return content; + } + + return {parts}; +} + interface MessageCardProps { message: MessageRecord; onReanalyze: (id: string) => Promise; @@ -128,7 +178,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { {displayContent ? (

- {displayContent} + {renderContentWithCustomEmojis(displayContent)}

) : null} diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 61ff64d..17baa3a 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -13,11 +13,13 @@ import { setStickerInCache, } from "./stickerCache.js"; import { + buildCustomEmojiVisionPrompt, buildStickerTextOnlyWarning, buildStickerVisionPrompt, } from "./stickerPrompt.js"; import { getCachedMediaAnalysis, + makeCustomEmojiCacheKey, makeImageCacheKey, makeStickerCacheKey, upsertCachedMediaAnalysis, @@ -465,6 +467,8 @@ type MessageImagePart = { image_url: { url: string }; sourceLabel: string; stickerName?: string; + customEmojiId?: string; + customEmojiName?: string; }; // --------------------------------------------------------------------------- @@ -495,9 +499,11 @@ const analyzeSingleMediaImage = async ( messageId: string, image: MessageImagePart, ): Promise => { - const cacheKey = image.stickerName - ? makeStickerCacheKey(image.stickerName) - : makeImageCacheKey(image.image_url.url); + const cacheKey = image.customEmojiId + ? makeCustomEmojiCacheKey(image.customEmojiId) + : image.stickerName + ? makeStickerCacheKey(image.stickerName) + : makeImageCacheKey(image.image_url.url); const cached = await getCachedMediaAnalysis(cacheKey); if (cached) { @@ -505,6 +511,12 @@ const analyzeSingleMediaImage = async ( return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`; } + const promptText = image.stickerName + ? buildStickerVisionPrompt(image.stickerName, messageId) + : image.customEmojiName + ? buildCustomEmojiVisionPrompt(image.customEmojiName, 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.`; + try { const completion = await openai.chat.completions.create({ model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, @@ -514,9 +526,7 @@ const analyzeSingleMediaImage = async ( content: [ { type: "text", - 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.`, + text: promptText, }, { type: "image_url", image_url: image.image_url }, ], @@ -1021,9 +1031,16 @@ async function runSingleMediaAnalysis( if (webTexts.length > 0) webTextMap.set(targetId, webTexts); } - // ── 3. Sticker / embed images ── + // ── 3. Sticker / embed / custom emoji images ── const mediaEvidence = extractMessageMediaEvidence(target.metadata); - const mediaCandidates = [ + const mediaCandidates: Array<{ + messageId: string; + url: string; + label: string; + stickerName?: string; + customEmojiId?: string; + customEmojiName?: string; + }> = [ ...mediaEvidence.stickers .filter((s) => s.url) .map((s) => ({ @@ -1056,9 +1073,18 @@ async function runSingleMediaAnalysis( url: string; label: string; stickerName?: string; + customEmojiId?: string; + customEmojiName?: string; } => c !== null, ), ), + ...mediaEvidence.customEmojis.map((emoji) => ({ + messageId: targetId, + url: emoji.url, + label: `[gambar di atas adalah custom emoji "${emoji.name}" dari pesan id=${targetId}]`, + customEmojiId: emoji.id, + customEmojiName: emoji.name, + })), ]; const remainingSlots = Math.max(0, 8 - (imageMap.get(targetId)?.length ?? 0)); @@ -1066,9 +1092,11 @@ async function runSingleMediaAnalysis( await Promise.all( mediaCandidates.slice(0, remainingSlots).map(async (candidate) => { // Vision cache check before download - const visionCacheKey = candidate.stickerName - ? makeStickerCacheKey(candidate.stickerName) - : makeImageCacheKey(candidate.url); + const visionCacheKey = candidate.customEmojiId + ? makeCustomEmojiCacheKey(candidate.customEmojiId) + : candidate.stickerName + ? makeStickerCacheKey(candidate.stickerName) + : makeImageCacheKey(candidate.url); const cachedVision = await getCachedMediaAnalysis(visionCacheKey); if (cachedVision) { log.debug( @@ -1122,6 +1150,8 @@ async function runSingleMediaAnalysis( }, sourceLabel: candidate.label, stickerName: candidate.stickerName, + customEmojiId: candidate.customEmojiId, + customEmojiName: candidate.customEmojiName, }; const existing = imageMap.get(targetId) ?? []; existing.push(part); diff --git a/src/moderation/messageMetadata.ts b/src/moderation/messageMetadata.ts index d37a9d5..a0dc86e 100644 --- a/src/moderation/messageMetadata.ts +++ b/src/moderation/messageMetadata.ts @@ -21,6 +21,13 @@ export interface StickerEvidence { format: string | null; } +export interface CustomEmojiEvidence { + id: string; + name: string; + animated: boolean; + url: string; +} + export interface EmbedEvidence { title: string | null; description: string | null; @@ -49,12 +56,14 @@ export interface MessageMediaEvidence { stickers: StickerEvidence[]; embeds: EmbedEvidence[]; attachments: AttachmentEvidence[]; + customEmojis: CustomEmojiEvidence[]; } export interface RichMessageMetadata { stickers: Array; embeds: Array; attachments: Array; + customEmojis: Array; author: { id: string; username: string; @@ -129,6 +138,30 @@ export function getStickerMetadata( })); } +/** + * Extract custom emoji references from message content. + * Builds Discord CDN URLs for each emoji so they can be downloaded + * and sent to the vision model for analysis. + */ +export function getCustomEmojiMetadata( + message: Message, +): RichMessageMetadata["customEmojis"] { + const CUSTOM_EMOJI_PATTERN = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; + const emojis: CustomEmojiEvidence[] = []; + let match; + while ((match = CUSTOM_EMOJI_PATTERN.exec(message.content)) !== null) { + const [, animated, name, id] = match; + const ext = animated ? "gif" : "png"; + emojis.push({ + id, + name, + animated: animated === "a", + url: `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`, + }); + } + return emojis; +} + export function getAttachmentMetadata( message: Message, ): RichMessageMetadata["attachments"] { @@ -178,6 +211,7 @@ export function getMessageMetadata(message: Message): RichMessageMetadata { stickers: getStickerMetadata(message), embeds: getEmbedMetadata(message), attachments: getAttachmentMetadata(message), + customEmojis: getCustomEmojiMetadata(message), author: { id: message.author.id, username: message.author.username, @@ -217,6 +251,7 @@ export function parseRichMessageMetadata( stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [], embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [], attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [], + customEmojis: Array.isArray(parsed.customEmojis) ? parsed.customEmojis : [], author: parsed.author as RichMessageMetadata["author"], member: (parsed.member ?? null) as RichMessageMetadata["member"], channel: parsed.channel as RichMessageMetadata["channel"], @@ -249,6 +284,7 @@ export function extractMessageMediaEvidence( stickers: parsed?.stickers ?? [], embeds: parsed?.embeds ?? [], attachments: parsed?.attachments ?? [], + customEmojis: parsed?.customEmojis ?? [], }; } diff --git a/src/moderation/stickerPrompt.ts b/src/moderation/stickerPrompt.ts index 6189d7f..482329d 100644 --- a/src/moderation/stickerPrompt.ts +++ b/src/moderation/stickerPrompt.ts @@ -57,3 +57,42 @@ export function buildStickerTextOnlyWarning( `Nama yang terdengar provokatif adalah hal umum untuk sticker satir/humor di Discord.]` ); } + +/** + * Prompt used when a custom emoji image was successfully downloaded + * and is being sent to the vision LLM as a base64 image. + * + * Custom emojis are small icons — context is similar to stickers. + */ +export function buildCustomEmojiVisionPrompt( + emojiName: string, + messageId: string, +): string { + return [ + `Analisis custom emoji Discord berikut sebagai evidence moderasi.`, + `Emoji "${emojiName}" berasal dari pesan id=${messageId}.`, + ``, + `PENTING — Konteks Custom Emoji:`, + `- Custom emoji Discord adalah ikon kecil/ekspresi, BUKAN foto atau dokumen nyata.`, + `- Emoji sering digunakan untuk ekspresi emosi, reaksi, atau lelucon.`, + `- Jangan flag berdasarkan nama emoji saja — analisis isi visual gambar.`, + `- Emoji yang terlihat lucu/aneh adalah hal umum di Discord, bukan pelanggaran.`, + ``, + `Jelaskan isi visual dan konteks risiko.`, + `Jawab Bahasa Indonesia, maksimal 2 kalimat. Jangan bilang kurang konteks.`, + ].join("\n"); +} + +/** + * Fallback text for when a custom emoji image failed to download. + */ +export function buildCustomEmojiTextOnlyFallback( + emojiName: string, +): string { + return ( + `[custom_emoji: "${emojiName}" — GAMBAR GAGAL DIUNDUH. ` + + `"${emojiName}" adalah custom emoji Discord (ikon kecil). ` + + `JANGAN flag berdasarkan nama emoji saja tanpa gambar visual. ` + + `Custom emoji di Discord adalah ekspresi/emosi umum, bukan konten ofensif.]` + ); +} diff --git a/src/moderation/textCacheStore.ts b/src/moderation/textCacheStore.ts index dbce397..e79e153 100644 --- a/src/moderation/textCacheStore.ts +++ b/src/moderation/textCacheStore.ts @@ -163,6 +163,13 @@ export function makeStickerCacheKey(stickerName: string): string { return `sticker:${stickerName}`; } +/** + * Generate a deterministic cache key for a custom emoji by its Discord ID. + */ +export function makeCustomEmojiCacheKey(emojiId: string): string { + return `emoji:${emojiId}`; +} + /** * Generate a deterministic cache key for an image data URL. * Hashes the first 128 chars of the data URL (enough to identify the image