feat(automod): render sticker, role & user names in moderation views

QoL lanjutan dari fix60084b3: content pesan mentah masih nampilin
snowflake (<@&roleid>, <@userid>, <:emoji:id>) di log moderasi dan
prompt LLM. Sekarang dirender ke nama yang bisa dibaca:

- Gateway capture: metadata menyimpan mentionedRoles + mentionedUsers
  (id+name) dari message.mentions, disimpan ke metadata JSON
- renderDiscordMentions(): <@&id> -> @RoleName, <@id> -> @Username,
  <:name:id> -> :name:, fallback @role/@user — dipakai di
  conversationContext (konteks LLM) dan moderationBuilders
  (getAnalysisContent) sehingga LLM lihat nama role/user beneran,
  bukan placeholder generik
- Frontend renderMessageContent() (mirror gateway) dipasang di semua
  tempat nampilin content: message-card, message-detail(-view),
  search-overlay, search-panel, users/channels section, live-stream,
  mod-queue, review list; sticker-only message tetap [Sticker: name],
  pesan teks+sticker kini ikut nampilin nama sticker
- tsc --noEmit PASS di gateway & frontend; renderDiscordMentions
  diverifikasi manual (6 kasus: role/user/emoji/unknown/plain)
This commit is contained in:
asepharyana
2026-08-01 08:56:10 +07:00
parent 6df4f306dd
commit 1f91f99de3
16 changed files with 182 additions and 18 deletions
@@ -1,6 +1,9 @@
import { createChildLogger } from "@/shared/logger/index";
import { encoding_for_model as encodingForModel } from "tiktoken";
import { formatMediaEvidenceForPrompt } from "../message-capture/messageMetadata.js";
import {
formatMediaEvidenceForPrompt,
renderDiscordMentions,
} from "../message-capture/messageMetadata.js";
import type { MessageRecord } from "../message-capture/types.js";
import { sanitizeDiscordTokens } from "./discordTokens.js";
@@ -101,7 +104,7 @@ export function formatMessageForPrompt(
label: "context" | "target",
): string {
const content = sanitizeDiscordTokens(
msg.edited_content ?? msg.content,
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
);
const timestamp = formatTimestamp(msg.created_at);
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
@@ -7,6 +7,7 @@
import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js";
import { renderDiscordMentions } from "../message-capture/messageMetadata.js";
import { sanitizeDiscordTokens } from "./discordTokens.js";
/** Simple XML-escaping for content text. */
@@ -30,7 +31,9 @@ export function getAnalysisContent(message: MessageRecord): string {
/\[(?:Attachment|Sticker):[^\]]*\]|\[Embed\]/g,
"",
);
return sanitizeDiscordTokens(stripped).trim();
return sanitizeDiscordTokens(
renderDiscordMentions(stripped, message.metadata),
).trim();
}
/**
@@ -28,6 +28,16 @@ export interface CustomEmojiEvidence {
url: string;
}
export interface MentionedRoleEvidence {
id: string;
name: string;
}
export interface MentionedUserEvidence {
id: string;
username: string;
}
export interface EmbedEvidence {
title: string | null;
description: string | null;
@@ -64,6 +74,8 @@ export interface RichMessageMetadata {
embeds: Array<EmbedEvidence>;
attachments: Array<AttachmentEvidence>;
customEmojis: Array<CustomEmojiEvidence>;
mentionedRoles: Array<MentionedRoleEvidence>;
mentionedUsers: Array<MentionedUserEvidence>;
author: {
id: string;
username: string;
@@ -268,6 +280,12 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
embeds: getEmbedMetadata(message),
attachments: getAttachmentMetadata(message),
customEmojis: getCustomEmojiMetadata(message),
mentionedRoles: Array.from(message.mentions?.roles?.values() ?? []).map(
(role) => ({ id: role.id, name: role.name }),
),
mentionedUsers: Array.from(message.mentions?.users?.values() ?? []).map(
(user) => ({ id: user.id, username: user.username }),
),
author: {
id: message.author.id,
username: message.author.username,
@@ -315,6 +333,12 @@ export function parseRichMessageMetadata(
customEmojis: Array.isArray(parsed.customEmojis)
? parsed.customEmojis
: [],
mentionedRoles: Array.isArray(parsed.mentionedRoles)
? parsed.mentionedRoles
: [],
mentionedUsers: Array.isArray(parsed.mentionedUsers)
? parsed.mentionedUsers
: [],
author: parsed.author as RichMessageMetadata["author"],
member: (parsed.member ?? null) as RichMessageMetadata["member"],
channel: parsed.channel as RichMessageMetadata["channel"],
@@ -463,3 +487,44 @@ export function getDisplayContent(message: Message): string {
return "";
}
/**
* Renders Discord mention/emoji tokens in message content to readable names
* using the captured metadata (mentionedRoles / mentionedUsers / customEmojis).
*
* - `<@&id>` → `@RoleName` (falls back to `@role`)
* - `<@id>` / `<@!id>` → `@Username` (falls back to `@user`)
* - `<:name:id>` → `:name:` (falls back to the literal name)
*
* Unresolvable tokens keep Discord's own name from the token, so no numeric
* snowflake ever reaches the reader. Content without "<" is returned untouched.
* Used by both the LLM prompt pipeline (conversationContext / moderationBuilders)
* and mirrored in the frontend (lib/format.ts renderMessageContent).
*/
export function renderDiscordMentions(
content: string,
metadata: string | null | undefined,
): string {
if (!content || !content.includes("<")) return content;
const parsed = parseRichMessageMetadata(metadata);
const roleName = new Map(
(parsed?.mentionedRoles ?? []).map((r) => [r.id, r.name] as const),
);
const userName = new Map(
(parsed?.mentionedUsers ?? []).map((u) => [u.id, u.username] as const),
);
const emojiName = new Map(
(parsed?.customEmojis ?? []).map((e) => [e.id, e.name] as const),
);
return content.replace(
/<(?:a)?:([a-zA-Z0-9_]+):(\d{17,20})>|<@!?(\d{17,20})>|<@&(\d{17,20})>/g,
(_full, emojiTokenName, emojiId, userId, roleId) => {
if (emojiId !== undefined) {
return `:${emojiName.get(emojiId) ?? emojiTokenName}:`;
}
if (roleId !== undefined) return `@${roleName.get(roleId) ?? "role"}`;
if (userId !== undefined) return `@${userName.get(userId) ?? "user"}`;
return _full;
},
);
}