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:
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user