2026-06-22 20:38:34 +07:00
|
|
|
/**
|
|
|
|
|
* moderationBuilders.ts
|
|
|
|
|
*
|
|
|
|
|
* Shared builder utilities extracted from llmModerationClient.ts.
|
|
|
|
|
* Used by both mediaAnalysisClient.ts and moderationOrchestrator.ts.
|
|
|
|
|
*/
|
2026-07-04 03:03:36 +07:00
|
|
|
|
2026-07-27 21:54:31 +07:00
|
|
|
import { messageStore } from "../message-capture/messageStore.js";
|
2026-07-04 03:03:36 +07:00
|
|
|
import type { MessageRecord } from "../message-capture/types.js";
|
2026-07-31 17:55:02 +07:00
|
|
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
2026-06-22 20:38:34 +07:00
|
|
|
|
|
|
|
|
/** Simple XML-escaping for content text. */
|
|
|
|
|
export function escapeXml(s: string): string {
|
|
|
|
|
return s
|
|
|
|
|
.replace(/&/g, "&")
|
|
|
|
|
.replace(/</g, "<")
|
|
|
|
|
.replace(/>/g, ">")
|
|
|
|
|
.replace(/"/g, """);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the real text content for AI analysis, stripping fallback text
|
|
|
|
|
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
|
|
|
|
* "[Embed]"). These filenames alone are meaningless to the LLM and can
|
|
|
|
|
* falsely inflate a "clean" verdict when the actual image failed to download.
|
|
|
|
|
*/
|
|
|
|
|
export function getAnalysisContent(message: MessageRecord): string {
|
|
|
|
|
const raw = message.edited_content ?? message.content;
|
|
|
|
|
const stripped = raw.replace(
|
|
|
|
|
/\[(?:Attachment|Sticker):[^\]]*\]|\[Embed\]/g,
|
|
|
|
|
"",
|
|
|
|
|
);
|
2026-07-31 17:55:02 +07:00
|
|
|
return sanitizeDiscordTokens(stripped).trim();
|
2026-06-22 20:38:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Builds a <reference> XML element for reply/forward/crosspost context.
|
|
|
|
|
*/
|
|
|
|
|
export 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 "";
|
|
|
|
|
|
|
|
|
|
let parentContent = "";
|
|
|
|
|
if (msg.reference_message_id) {
|
2026-06-23 23:27:23 +07:00
|
|
|
// 1. Try DB first — works for messages captured in the same server
|
2026-06-22 20:38:34 +07:00
|
|
|
try {
|
2026-07-27 21:54:31 +07:00
|
|
|
const parent = await messageStore.getMessageById(
|
|
|
|
|
msg.reference_message_id,
|
|
|
|
|
);
|
2026-06-22 20:38:34 +07:00
|
|
|
if (parent) {
|
|
|
|
|
const parentText = parent.edited_content ?? parent.content;
|
|
|
|
|
parentContent = parentText.slice(0, 500);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
2026-06-23 23:27:23 +07:00
|
|
|
// Parent fetch failed — fall through to metadata
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Fall back to metadata — works for forwards from other servers/channels
|
|
|
|
|
// where the original message was never captured in this DB.
|
|
|
|
|
// The capture phase stores the snapshot content in metadata.reference.content.
|
|
|
|
|
if (!parentContent && msg.metadata) {
|
|
|
|
|
try {
|
|
|
|
|
const meta = JSON.parse(msg.metadata);
|
|
|
|
|
const refContent = meta?.reference?.content;
|
|
|
|
|
if (refContent && typeof refContent === "string") {
|
|
|
|
|
parentContent = refContent.slice(0, 500);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Metadata parse failed — no fallback available
|
|
|
|
|
}
|
2026-06-22 20:38:34 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>`;
|
|
|
|
|
}
|