;
@@ -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