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 };
}
File diff suppressed because one or more lines are too long
@@ -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;