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
This commit is contained in:
MythEclipse
2026-06-01 17:23:46 +07:00
parent f5507e01f6
commit 5078b34ac2
5 changed files with 175 additions and 13 deletions
@@ -1,8 +1,58 @@
import { useState, useMemo } from "react"; import { useState, useMemo, Fragment } from "react";
import type { MessageRecord } from "../../../shared/api/client"; import type { MessageRecord } from "../../../shared/api/client";
import { Badge, Button, Skeleton } from "../../../shared/ui"; import { Badge, Button, Skeleton } from "../../../shared/ui";
import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle, Trash2, Pencil, Image as ImageIcon, Smile } from "lucide-react"; 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(
<img
key={`${id}-${match.index}`}
src={url}
alt={name}
className="inline-block h-[22px] w-[22px] align-middle object-contain"
loading="lazy"
draggable={false}
title={`:${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 <Fragment>{parts}</Fragment>;
}
interface MessageCardProps { interface MessageCardProps {
message: MessageRecord; message: MessageRecord;
onReanalyze: (id: string) => Promise<void>; onReanalyze: (id: string) => Promise<void>;
@@ -128,7 +178,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
{displayContent ? ( {displayContent ? (
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90"> <p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{displayContent} {renderContentWithCustomEmojis(displayContent)}
</p> </p>
) : null} ) : null}
+41 -11
View File
@@ -13,11 +13,13 @@ import {
setStickerInCache, setStickerInCache,
} from "./stickerCache.js"; } from "./stickerCache.js";
import { import {
buildCustomEmojiVisionPrompt,
buildStickerTextOnlyWarning, buildStickerTextOnlyWarning,
buildStickerVisionPrompt, buildStickerVisionPrompt,
} from "./stickerPrompt.js"; } from "./stickerPrompt.js";
import { import {
getCachedMediaAnalysis, getCachedMediaAnalysis,
makeCustomEmojiCacheKey,
makeImageCacheKey, makeImageCacheKey,
makeStickerCacheKey, makeStickerCacheKey,
upsertCachedMediaAnalysis, upsertCachedMediaAnalysis,
@@ -465,6 +467,8 @@ type MessageImagePart = {
image_url: { url: string }; image_url: { url: string };
sourceLabel: string; sourceLabel: string;
stickerName?: string; stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -495,9 +499,11 @@ const analyzeSingleMediaImage = async (
messageId: string, messageId: string,
image: MessageImagePart, image: MessageImagePart,
): Promise<string | null> => { ): Promise<string | null> => {
const cacheKey = image.stickerName const cacheKey = image.customEmojiId
? makeStickerCacheKey(image.stickerName) ? makeCustomEmojiCacheKey(image.customEmojiId)
: makeImageCacheKey(image.image_url.url); : image.stickerName
? makeStickerCacheKey(image.stickerName)
: makeImageCacheKey(image.image_url.url);
const cached = await getCachedMediaAnalysis(cacheKey); const cached = await getCachedMediaAnalysis(cacheKey);
if (cached) { if (cached) {
@@ -505,6 +511,12 @@ const analyzeSingleMediaImage = async (
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`; 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 { try {
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({
model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL,
@@ -514,9 +526,7 @@ const analyzeSingleMediaImage = async (
content: [ content: [
{ {
type: "text", type: "text",
text: image.stickerName text: promptText,
? 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 },
], ],
@@ -1021,9 +1031,16 @@ async function runSingleMediaAnalysis(
if (webTexts.length > 0) webTextMap.set(targetId, webTexts); if (webTexts.length > 0) webTextMap.set(targetId, webTexts);
} }
// ── 3. Sticker / embed images ── // ── 3. Sticker / embed / custom emoji images ──
const mediaEvidence = extractMessageMediaEvidence(target.metadata); const mediaEvidence = extractMessageMediaEvidence(target.metadata);
const mediaCandidates = [ const mediaCandidates: Array<{
messageId: string;
url: string;
label: string;
stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
}> = [
...mediaEvidence.stickers ...mediaEvidence.stickers
.filter((s) => s.url) .filter((s) => s.url)
.map((s) => ({ .map((s) => ({
@@ -1056,9 +1073,18 @@ async function runSingleMediaAnalysis(
url: string; url: string;
label: string; label: string;
stickerName?: string; stickerName?: string;
customEmojiId?: string;
customEmojiName?: string;
} => c !== null, } => 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)); const remainingSlots = Math.max(0, 8 - (imageMap.get(targetId)?.length ?? 0));
@@ -1066,9 +1092,11 @@ async function runSingleMediaAnalysis(
await Promise.all( await Promise.all(
mediaCandidates.slice(0, remainingSlots).map(async (candidate) => { mediaCandidates.slice(0, remainingSlots).map(async (candidate) => {
// Vision cache check before download // Vision cache check before download
const visionCacheKey = candidate.stickerName const visionCacheKey = candidate.customEmojiId
? makeStickerCacheKey(candidate.stickerName) ? makeCustomEmojiCacheKey(candidate.customEmojiId)
: makeImageCacheKey(candidate.url); : candidate.stickerName
? makeStickerCacheKey(candidate.stickerName)
: makeImageCacheKey(candidate.url);
const cachedVision = await getCachedMediaAnalysis(visionCacheKey); const cachedVision = await getCachedMediaAnalysis(visionCacheKey);
if (cachedVision) { if (cachedVision) {
log.debug( log.debug(
@@ -1122,6 +1150,8 @@ async function runSingleMediaAnalysis(
}, },
sourceLabel: candidate.label, sourceLabel: candidate.label,
stickerName: candidate.stickerName, stickerName: candidate.stickerName,
customEmojiId: candidate.customEmojiId,
customEmojiName: candidate.customEmojiName,
}; };
const existing = imageMap.get(targetId) ?? []; const existing = imageMap.get(targetId) ?? [];
existing.push(part); existing.push(part);
+36
View File
@@ -21,6 +21,13 @@ export interface StickerEvidence {
format: string | null; format: string | null;
} }
export interface CustomEmojiEvidence {
id: string;
name: string;
animated: boolean;
url: string;
}
export interface EmbedEvidence { export interface EmbedEvidence {
title: string | null; title: string | null;
description: string | null; description: string | null;
@@ -49,12 +56,14 @@ export interface MessageMediaEvidence {
stickers: StickerEvidence[]; stickers: StickerEvidence[];
embeds: EmbedEvidence[]; embeds: EmbedEvidence[];
attachments: AttachmentEvidence[]; attachments: AttachmentEvidence[];
customEmojis: CustomEmojiEvidence[];
} }
export interface RichMessageMetadata { export interface RichMessageMetadata {
stickers: Array<StickerEvidence>; stickers: Array<StickerEvidence>;
embeds: Array<EmbedEvidence>; embeds: Array<EmbedEvidence>;
attachments: Array<AttachmentEvidence>; attachments: Array<AttachmentEvidence>;
customEmojis: Array<CustomEmojiEvidence>;
author: { author: {
id: string; id: string;
username: 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( export function getAttachmentMetadata(
message: Message, message: Message,
): RichMessageMetadata["attachments"] { ): RichMessageMetadata["attachments"] {
@@ -178,6 +211,7 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
stickers: getStickerMetadata(message), stickers: getStickerMetadata(message),
embeds: getEmbedMetadata(message), embeds: getEmbedMetadata(message),
attachments: getAttachmentMetadata(message), attachments: getAttachmentMetadata(message),
customEmojis: getCustomEmojiMetadata(message),
author: { author: {
id: message.author.id, id: message.author.id,
username: message.author.username, username: message.author.username,
@@ -217,6 +251,7 @@ export function parseRichMessageMetadata(
stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [], stickers: Array.isArray(parsed.stickers) ? parsed.stickers : [],
embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [], embeds: Array.isArray(parsed.embeds) ? parsed.embeds : [],
attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [], attachments: Array.isArray(parsed.attachments) ? parsed.attachments : [],
customEmojis: Array.isArray(parsed.customEmojis) ? parsed.customEmojis : [],
author: parsed.author as RichMessageMetadata["author"], author: parsed.author as RichMessageMetadata["author"],
member: (parsed.member ?? null) as RichMessageMetadata["member"], member: (parsed.member ?? null) as RichMessageMetadata["member"],
channel: parsed.channel as RichMessageMetadata["channel"], channel: parsed.channel as RichMessageMetadata["channel"],
@@ -249,6 +284,7 @@ export function extractMessageMediaEvidence(
stickers: parsed?.stickers ?? [], stickers: parsed?.stickers ?? [],
embeds: parsed?.embeds ?? [], embeds: parsed?.embeds ?? [],
attachments: parsed?.attachments ?? [], attachments: parsed?.attachments ?? [],
customEmojis: parsed?.customEmojis ?? [],
}; };
} }
+39
View File
@@ -57,3 +57,42 @@ export function buildStickerTextOnlyWarning(
`Nama yang terdengar provokatif adalah hal umum untuk sticker satir/humor di Discord.]` `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.]`
);
}
+7
View File
@@ -163,6 +163,13 @@ export function makeStickerCacheKey(stickerName: string): string {
return `sticker:${stickerName}`; 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. * Generate a deterministic cache key for an image data URL.
* Hashes the first 128 chars of the data URL (enough to identify the image * Hashes the first 128 chars of the data URL (enough to identify the image