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