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 <reference> 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 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-13 00:55:42 +07:00
co-authored by Claude
parent 0ac056dc8a
commit e3249edb6c
9 changed files with 133 additions and 8 deletions
+7
View File
@@ -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"],
+6
View File
@@ -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;
@@ -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;
@@ -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
}
]
}
@@ -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}`;
}
/**
@@ -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 <reference> 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<string> {
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
? `<parent_content>${escapeXml(parentContent)}</parent_content>`
: "";
return `<reference ${attr} message_id="${msg.reference_message_id}" channel_id="${msg.reference_channel_id ?? ""}" guild_id="${msg.reference_guild_id ?? ""}">${parentXml}</reference>`;
}
/** Simple XML-escaping for content text. */
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// 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 `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${profileLine}\n <content>${content}</content>${webContext}\n</message>`;
})
.join("\n");
const refXml = await buildReferenceXml(msg);
const refLine = refXml ? `\n ${refXml}` : "";
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${profileLine}${refLine}\n <content>${content}</content>${webContext}\n</message>`;
}),
).then((blocks) => blocks.join("\n"));
// XML delimiter wraps the entire messages block (R1)
return `${systemText}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
@@ -1040,8 +1089,10 @@ async function prepareMediaMessage(
const userProfileCtx = profile
? `\n <user_profile>${profile.profile_summary}</user_profile>`
: "";
const refXml = await buildReferenceXml(target);
const refLine = refXml ? `\n ${refXml}` : "";
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}${userProfileCtx}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}${userProfileCtx}${refLine}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
return { targetId, messageBlock };
}
@@ -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.
@@ -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),
};
}
@@ -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;