From e3249edb6c417f382887b99c3087a1414970bf53 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 13 Jun 2026 00:55:42 +0700 Subject: [PATCH] feat: detect reply/forward/crosspost in message capture + inject into AI moderation - Add is_reply, is_forward, is_crosspost, reference_message_id, reference_channel_id, reference_guild_id columns to messages table - Update MessageRecord type with reference fields - Extract reply/forward/crosspost from Discord message type/flags - Track reference.type (DEFAULT=reply, FORWARD) and CROSSPOSTED flag - Inject XML with parent content into LLM moderation prompt - Add reply/forward/crosspost rules to moderation prompt rules - Format context messages with [reply_to], [forward_from], [crosspost] - Add migration 0009 Co-Authored-By: Claude --- packages/shared/src/database/schema.ts | 7 ++ packages/shared/src/moderation-types.ts | 6 ++ .../0009_add_reply_forward_crosspost.sql | 7 ++ .../drizzle/migrations/meta/_journal.json | 7 ++ .../ai-moderation/conversationContext.ts | 23 ++++++- .../ai-moderation/llmModerationClient.ts | 65 +++++++++++++++++-- .../modules/ai-moderation/moderationPrompt.ts | 1 + .../modules/message-capture/messageCapture.ts | 20 ++++++ .../message-capture/messageMetadata.ts | 5 ++ 9 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0009_add_reply_forward_crosspost.sql diff --git a/packages/shared/src/database/schema.ts b/packages/shared/src/database/schema.ts index 831de74..86890e6 100644 --- a/packages/shared/src/database/schema.ts +++ b/packages/shared/src/database/schema.ts @@ -1,5 +1,6 @@ import { bigint as pgBigint, + boolean as pgBoolean, foreignKey as pgForeignKey, index as pgIndex, integer as pgInteger, @@ -30,6 +31,12 @@ export const pgMessagesTable = pgTable( type: pgText("type", { enum: ["text", "edited", "deleted"] }) .notNull() .default("text"), + is_reply: pgBoolean("is_reply"), + is_forward: pgBoolean("is_forward"), + is_crosspost: pgBoolean("is_crosspost"), + reference_message_id: pgText("reference_message_id"), + reference_channel_id: pgText("reference_channel_id"), + reference_guild_id: pgText("reference_guild_id"), metadata: pgText("metadata"), ai_status: pgText("ai_status", { enum: ["pending", "processing", "clean", "warn", "flagged", "error"], diff --git a/packages/shared/src/moderation-types.ts b/packages/shared/src/moderation-types.ts index 75cd324..a080dfd 100644 --- a/packages/shared/src/moderation-types.ts +++ b/packages/shared/src/moderation-types.ts @@ -64,6 +64,12 @@ export interface MessageRecord { edited_at: number | null; deleted_at: number | null; type: "text" | "edited" | "deleted"; + is_reply: boolean | null; + is_forward: boolean | null; + is_crosspost: boolean | null; + reference_message_id: string | null; + reference_channel_id: string | null; + reference_guild_id: string | null; metadata: string | null; ai_status?: AIStatus | null; ai_moderation_flags?: string | null; diff --git a/services/discord-gateway/drizzle/migrations/0009_add_reply_forward_crosspost.sql b/services/discord-gateway/drizzle/migrations/0009_add_reply_forward_crosspost.sql new file mode 100644 index 0000000..d2727ca --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0009_add_reply_forward_crosspost.sql @@ -0,0 +1,7 @@ +ALTER TABLE messages + ADD COLUMN IF NOT EXISTS is_reply boolean, + ADD COLUMN IF NOT EXISTS is_forward boolean, + ADD COLUMN IF NOT EXISTS is_crosspost boolean, + ADD COLUMN IF NOT EXISTS reference_message_id text, + ADD COLUMN IF NOT EXISTS reference_channel_id text, + ADD COLUMN IF NOT EXISTS reference_guild_id text; diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index 202f499..6508081 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1781270000000, "tag": "0008_add_user_profiles_table", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1781316000000, + "tag": "0009_add_reply_forward_crosspost", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts index 5fba64e..41a2636 100644 --- a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts +++ b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts @@ -40,6 +40,26 @@ export function estimateTokens(text: string): number { return tokens; } +/** + * Formats reference info for a message (reply/forward/crosspost) + */ +function formatReferenceInfo(msg: MessageRecord): string { + const parts: string[] = []; + if (msg.is_reply && msg.reference_message_id) { + parts.push(`[reply_to: ${msg.reference_message_id}]`); + if (msg.reference_channel_id) { + parts.push(`(reply_channel: ${msg.reference_channel_id})`); + } + } + if (msg.is_forward && msg.reference_message_id) { + parts.push(`[forward_from: ${msg.reference_message_id}]`); + } + if (msg.is_crosspost) { + parts.push(`[crosspost]`); + } + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; +} + /** * Formats a single message for context or target display */ @@ -51,7 +71,8 @@ export function formatMessageForPrompt( const timestamp = formatTimestamp(msg.created_at); const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata); const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : ""; - return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}`; + const refInfo = formatReferenceInfo(msg); + return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}${refInfo}`; } /** diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index e7d5b22..764e5bd 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -45,6 +45,7 @@ import { import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; import { initializeUserReputation } from "./userReputationStore.js"; import { getUserProfile } from "./userProfileStore.js"; +import { getMessageById } from "../message-capture/messageStore.js"; export { sniffImageMimeType } from "./imageMimeSniffer.js"; export { extractJson } from "./jsonExtractor.js"; @@ -142,6 +143,53 @@ function getAnalysisContent(message: MessageRecord): string { return stripped.trim(); } +/** + * Builds a XML element for reply/forward/crosspost context. + * Fetches the parent message content if available so the LLM can evaluate + * the reply in context. + */ +async function buildReferenceXml(msg: MessageRecord): Promise { + const parts: string[] = []; + if (msg.is_reply && msg.reference_message_id) { + parts.push(`type="reply"`); + } else if (msg.is_forward && msg.reference_message_id) { + parts.push(`type="forward"`); + } + if (msg.is_crosspost) { + parts.push(`type="crosspost"`); + } + if (!msg.reference_message_id) return ""; + + // Try to fetch parent message content + let parentContent = ""; + if (msg.reference_message_id) { + try { + const parent = await getMessageById(msg.reference_message_id); + if (parent) { + const parentText = parent.edited_content ?? parent.content; + parentContent = parentText.slice(0, 500); + } + } catch { + // Parent fetch failed — still inject reference with available info + } + } + + const attr = parts.join(" "); + const parentXml = parentContent + ? `${escapeXml(parentContent)}` + : ""; + return `${parentXml}`; +} + +/** Simple XML-escaping for content text. */ +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + // --------------------------------------------------------------------------- // Media detection helper // --------------------------------------------------------------------------- @@ -748,7 +796,6 @@ async function runTextOnlyBatch( let lastRaw: unknown = null; const channelId = targets.length > 0 ? targets[0].channel_id : ""; - const guildId = targets.length > 0 ? targets[0].guild_id : ""; const channelCultureObj = channelId ? await getChannelCulture(channelId) : null; @@ -802,8 +849,8 @@ async function runTextOnlyBatch( channelCulture, }); - const messagesBlock = batch - .map((msg) => { + const messagesBlock = await Promise.all( + batch.map(async (msg) => { const content = getAnalysisContent(msg); // Inject fetched web content for URLs found in this message @@ -822,9 +869,11 @@ async function runTextOnlyBatch( // XML delimiters wrap each message for prompt safety (R1) const profileLine = userProfileCtx ? `\n ${userProfileCtx}` : ""; - return `\n ${userCtx}${profileLine}\n ${content}${webContext}\n`; - }) - .join("\n"); + const refXml = await buildReferenceXml(msg); + const refLine = refXml ? `\n ${refXml}` : ""; + return `\n ${userCtx}${profileLine}${refLine}\n ${content}${webContext}\n`; + }), + ).then((blocks) => blocks.join("\n")); // XML delimiter wraps the entire messages block (R1) return `${systemText}\n\n\n${messagesBlock}\n`; @@ -1040,8 +1089,10 @@ async function prepareMediaMessage( const userProfileCtx = profile ? `\n ${profile.profile_summary}` : ""; + const refXml = await buildReferenceXml(target); + const refLine = refXml ? `\n ${refXml}` : ""; - const messageBlock = `\n ${userCtx}${userProfileCtx}\n ${content}${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n`; + const messageBlock = `\n ${userCtx}${userProfileCtx}${refLine}\n ${content}${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n`; return { targetId, messageBlock }; } diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts index f5eeeca..0fd00ad 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts @@ -36,6 +36,7 @@ Bahasa utama komunitas ini adalah BAHASA INDONESIA. Bahasa Inggris adalah bahasa - **VULGARITAS ANATOMI/SEKSUAL SELALU DILARANG:** Kata-kata yang merujuk pada alat kelamin atau anatomi seksual (seperti "kontol", "memek", "titten", "tit", "dick") atau istilah seksual eksplisit WAJIB DI-FLAG sebagai "vulgar_language" atau "sexual_content" WALAUPUN dalam konteks bercanda, slang, atau tanpa target (tidak terarah). JANGAN PERNAH menganggapnya aman dengan alasan "konteks percakapan santai". - Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu". - **NAMA PROYEK, TOOLS, DAN ISTILAH TEKNIS:** Nama proyek (seperti "Bete", "IMPHNEN"), nama tools (seperti "Cursor", "VSCode", "Claude"), nama library (seperti "discord.js", "React"), istilah programming (seperti "bug", "crash", "error", "stack trace", "console.log", "kode error", "syntax error"), dan istilah database (seperti "select * from", "migration", "schema") adalah istilah TEKNIS NORMAL. Meskipun mirip kata kasar atau singkatan ambigu, JANGAN flag sebagai vulgar_language, harassment, atau pelanggaran apapun. Konten teknis dalam konteks programming adalah AMAN. +- **REPLY / FORWARD / CROSSPOST:** Jika pesan memiliki tag reference di dalamnya, itu berarti pesan tersebut adalah REPLY ke pesan lain, FORWARD dari channel lain, atau CROSSPOST. Konten di parent_content adalah isi pesan asli yang direply/diteruskan. JANGAN menganggap konten parent_content sebagai milik pengirim pesan saat ini. Pengirim hanya bertanggung jawab atas komentar/tambahannya sendiri. Contoh: Jika seseorang reply "setuju" ke pesan bermasalah, HANYA "setuju" yang dinilai — konten asli adalah konteks, bukan milik pengirim. - **NAMA PROYEK/KOMUNITAS INI:** "IMPHNEN", "imphnen", "Imphens", "IMP", atau varian ejaan lainnya adalah NAMA PROYEK/KOMUNITAS dari bot moderasi ini sendiri (Discord Moderation Watcher). Termasuk semua subdomain dan TLD: "*.imphnen.*", "imphnen.*", "*.imphnen.*.*". BUKAN agama, BUKAN kitab suci, BUKAN parodi SARA, dan BUKAN penistaan. Menyebut/mempromosikan nama proyek ini adalah AMAN. JANGAN flag sebagai "sara" hanya karena mengandung kata "imphnen". - **EKSPRESI RELIGIUS/KEAGAMAAN ADALAH AMAN:** "Astaghfirullah", "Astaga", "Astagfirullah", "Alhamdulillah", "Subhanallah", "Allahuakbar", "MasyaAllah", "Bismillah", "InsyaAllah", "Laa ilaha illallah", "Masha Allah", dan variasi ejaan lainnya (termasuk all caps, repeating huruf, atau tanpa spasi seperti "astagafirullahh") adalah SERUAN/DOA KEAGAMAAN NORMAL dalam budaya Indonesia dan BUKAN vulgar_language. JANGAN flag sebagai vulgar atau harassment. Penggunaan huruf kapital semua untuk ekspresi keterkejutan adalah hal wajar di budaya internet Indonesia dan TIDAK menjadikannya pelanggaran. - "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas. diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts index 775892e..006595e 100644 --- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts +++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts @@ -94,6 +94,20 @@ function buildMessageRecord( const location = getMessageLocation(message); const metadata = getMessageMetadata(message); const guildId = requireMessageGuildId(message); + const ref = message.reference; + + // is_reply: type === 'REPLY' OR reference type === 'DEFAULT' + // is_forward: reference type === 'FORWARD' + // is_crosspost: message flags has CROSSPOSTED + const msgType = message.type as string; + const refType = (ref?.type as string | undefined) ?? null; + const isReply = + msgType === "REPLY" || (refType === "DEFAULT" && msgType !== "FORWARD") + ? true + : null; + const isForward = refType === "FORWARD" ? true : null; + const isCrosspost = + message.flags?.has(1 << 1) || msgType === "CROSSPOSTED" ? true : null; return { id: message.id, @@ -109,6 +123,12 @@ function buildMessageRecord( edited_at: null, deleted_at: null, type, + is_reply: isReply, + is_forward: isForward, + is_crosspost: isCrosspost, + reference_message_id: ref?.messageId ?? null, + reference_channel_id: ref?.channelId ?? null, + reference_guild_id: ref?.guildId ?? null, metadata: JSON.stringify(metadata), }; } diff --git a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts index ac8f41c..a03293e 100644 --- a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts +++ b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts @@ -81,7 +81,9 @@ export interface RichMessageMetadata { messageId: string | null; channelId: string | null; guildId: string | null; + type: string | null; } | null; + isCrosspost: boolean; } export function getMessageLocation(message: Message): MessageLocation { @@ -235,8 +237,10 @@ export function getMessageMetadata(message: Message): RichMessageMetadata { messageId: message.reference.messageId ?? null, channelId: message.reference.channelId ?? null, guildId: message.reference.guildId ?? null, + type: (message.reference.type as unknown as string | undefined) ?? null, } : null, + isCrosspost: message.flags?.has(1 << 1) ?? false, }; } @@ -258,6 +262,7 @@ export function parseRichMessageMetadata( member: (parsed.member ?? null) as RichMessageMetadata["member"], channel: parsed.channel as RichMessageMetadata["channel"], reference: (parsed.reference ?? null) as RichMessageMetadata["reference"], + isCrosspost: Boolean(parsed.isCrosspost), }; } catch { return null;