diff --git a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts index fdc5439..3a13724 100644 --- a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts +++ b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts @@ -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); diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts index 841bf08..1982481 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts @@ -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(); } /** diff --git a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts index 92e7b26..e054150 100644 --- a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts +++ b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts @@ -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; attachments: Array; customEmojis: Array; + mentionedRoles: Array; + mentionedUsers: Array; 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; + }, + ); +} diff --git a/services/frontend/src/app/(dashboard)/dashboard/page.tsx b/services/frontend/src/app/(dashboard)/dashboard/page.tsx index aa1dc32..2229b19 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -31,6 +31,7 @@ export default function DashboardPage() { id: msg.id, content: msg.content || msg.id, username: msg.username, + metadata: msg.metadata ?? null, severity: msg.ai_severity && msg.ai_severity !== "none" ? (msg.ai_severity as ModQueueItem["severity"]) diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 0f38500..2a6317e 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -7,6 +7,7 @@ import { MessageList } from "@/components/messages/message-list"; import { MessageDetailView } from "@/components/messages/message-detail-view"; import { SearchOverlay } from "@/components/messages/search-overlay"; import { extractFirstImage } from "@/components/messages/message-card"; +import { renderMessageContent } from "@/lib/format"; import { SubNav } from "@/components/layout/sub-nav"; import { ErrorState, LoadingSkeleton } from "@/components/shared"; import { GlassCard } from "@/components/glass/card"; @@ -317,7 +318,7 @@ function ReviewList({

- {item.content || item.id} + {renderMessageContent(item.content, item.metadata) || item.id}

diff --git a/services/frontend/src/components/analysis/search-panel.tsx b/services/frontend/src/components/analysis/search-panel.tsx index 02ba6f3..37160ed 100644 --- a/services/frontend/src/components/analysis/search-panel.tsx +++ b/services/frontend/src/components/analysis/search-panel.tsx @@ -13,7 +13,7 @@ import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { useReanalyze } from "@/hooks"; import { messagesApi } from "@/lib/api"; -import { safeParseJsonArray } from "@/lib/format"; +import { renderMessageContent, safeParseJsonArray } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -100,7 +100,7 @@ export function SearchPanel() { )} -

{msg.content}

+

{renderMessageContent(msg.content, msg.metadata)}

{msg.ai_moderation_flags && msg.ai_moderation_flags !== "[]" && (
diff --git a/services/frontend/src/components/dashboard/channels-section.tsx b/services/frontend/src/components/dashboard/channels-section.tsx index b8c4a73..e9c3489 100644 --- a/services/frontend/src/components/dashboard/channels-section.tsx +++ b/services/frontend/src/components/dashboard/channels-section.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { useChannelDetail, useChannels } from "@/hooks"; +import { renderMessageContent } from "@/lib/format"; import type { DashboardChannel } from "@/lib/types"; export function ChannelsSection({ guildId }: { guildId?: string }) { @@ -123,7 +124,7 @@ export function ChannelsSection({ guildId }: { guildId?: string }) { className="rounded-lg border border-border/40 bg-card/40 px-3 py-2" >

- {msg.username}: {msg.content || "(no text content)"} + {msg.username}: {renderMessageContent(msg.content, msg.metadata) || "(no text content)"}

{new Date(msg.created_at).toLocaleString()} diff --git a/services/frontend/src/components/dashboard/live-stream.tsx b/services/frontend/src/components/dashboard/live-stream.tsx index 788605f..33df372 100644 --- a/services/frontend/src/components/dashboard/live-stream.tsx +++ b/services/frontend/src/components/dashboard/live-stream.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { GlassCard } from "@/components/glass/card"; +import { renderMessageContent } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useWebSocket } from "@/lib/ws/context"; @@ -13,6 +14,7 @@ interface LiveMessage { channelName?: string; timestamp: string; flagged?: boolean; + metadata?: string | null; } export function LiveStream() { @@ -40,6 +42,7 @@ export function LiveStream() { channelName, timestamp: new Date().toLocaleTimeString(), flagged: data.ai_status === "flagged" || data.ai_status === "warn", + metadata: data.metadata ?? null, }; setMessages((prev) => [msg, ...prev].slice(0, 50)); }); @@ -80,7 +83,7 @@ export function LiveStream() { {msg.username} - {msg.content} + {renderMessageContent(msg.content, msg.metadata)} {msg.timestamp} diff --git a/services/frontend/src/components/dashboard/mod-queue.tsx b/services/frontend/src/components/dashboard/mod-queue.tsx index 674afe9..d4425c1 100644 --- a/services/frontend/src/components/dashboard/mod-queue.tsx +++ b/services/frontend/src/components/dashboard/mod-queue.tsx @@ -2,6 +2,7 @@ import { AlertCircle, Check, Trash2 } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; +import { renderMessageContent } from "@/lib/format"; import { cn } from "@/lib/utils"; export interface ModQueueItem { @@ -10,6 +11,7 @@ export interface ModQueueItem { username: string; severity: "low" | "medium" | "high" | "critical"; reason: string; + metadata?: string | null; } export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) { @@ -56,7 +58,7 @@ export function ModQueue({ items = [] }: { items?: ModQueueItem[] }) {

- {item.content} + {renderMessageContent(item.content, item.metadata)}

{item.reason} diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx index 4bba380..7532f1d 100644 --- a/services/frontend/src/components/dashboard/users-section.tsx +++ b/services/frontend/src/components/dashboard/users-section.tsx @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { useUserDetail, useUsers } from "@/hooks"; +import { renderMessageContent } from "@/lib/format"; import type { DashboardUser } from "@/lib/types"; export function UsersSection() { @@ -134,7 +135,7 @@ export function UsersSection() { className="rounded-lg border border-border/40 bg-card/40 px-3 py-2" >

- {msg.content || "(no text content)"} + {renderMessageContent(msg.content, msg.metadata) || "(no text content)"}

{msg.channel_id?.slice(0, 8)} ·{" "} diff --git a/services/frontend/src/components/messages/message-card.tsx b/services/frontend/src/components/messages/message-card.tsx index 38c0bb5..cb81130 100644 --- a/services/frontend/src/components/messages/message-card.tsx +++ b/services/frontend/src/components/messages/message-card.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { safeParseJsonArray, getMessageChannelLabel } from "@/lib/format"; +import { safeParseJsonArray, getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; import { cn } from "@/lib/utils"; import { AiStatusBadge } from "./ai-status-badge"; @@ -96,7 +96,7 @@ export function MessageCard({ "italic text-muted-foreground line-through", )} > - {msg.content} + {renderMessageContent(msg.content, msg.metadata)}

{(() => { const u = extractFirstImage(msg.metadata); diff --git a/services/frontend/src/components/messages/message-detail-view.tsx b/services/frontend/src/components/messages/message-detail-view.tsx index 708d883..3e17c57 100644 --- a/services/frontend/src/components/messages/message-detail-view.tsx +++ b/services/frontend/src/components/messages/message-detail-view.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; import { AttachmentsGrid } from "./attachments-grid"; import { AiAnalysisPanel } from "./ai-analysis-panel"; -import { getMessageChannelLabel } from "@/lib/format"; +import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { AttachmentRecord, MessageRecord } from "@/lib/types"; interface MessageDetailViewProps { @@ -34,7 +34,7 @@ export function MessageDetailView({ message, attachments, onBack }: MessageDetai {/* Content */}
- {message.content || "(no text content)"} + {renderMessageContent(message.content, message.metadata) || "(no text content)"}
{/* Attachments */} diff --git a/services/frontend/src/components/messages/message-detail.tsx b/services/frontend/src/components/messages/message-detail.tsx index 8696e4d..2f1a93a 100644 --- a/services/frontend/src/components/messages/message-detail.tsx +++ b/services/frontend/src/components/messages/message-detail.tsx @@ -4,7 +4,7 @@ import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; import { AttachmentsGrid } from "./attachments-grid"; import { AiAnalysisPanel } from "./ai-analysis-panel"; -import { getMessageChannelLabel } from "@/lib/format"; +import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { AttachmentRecord, MessageRecord } from "@/lib/types"; interface MessageDetailProps { @@ -34,7 +34,7 @@ export function MessageDetail({ message, attachments, onBack }: MessageDetailPro {/* Content */}
- {message.content || "(no text content)"} + {renderMessageContent(message.content, message.metadata) || "(no text content)"}
{/* Attachments */} diff --git a/services/frontend/src/components/messages/search-overlay.tsx b/services/frontend/src/components/messages/search-overlay.tsx index f6bbe65..2203b1c 100644 --- a/services/frontend/src/components/messages/search-overlay.tsx +++ b/services/frontend/src/components/messages/search-overlay.tsx @@ -4,7 +4,7 @@ import { Search, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { messagesApi } from "@/lib/api"; -import { getMessageChannelLabel } from "@/lib/format"; +import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; interface SearchOverlayProps { @@ -86,7 +86,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) { {msg.username} {getMessageChannelLabel(msg)} -

{msg.content}

+

{renderMessageContent(msg.content, msg.metadata)}

)) )} diff --git a/services/frontend/src/lib/format.ts b/services/frontend/src/lib/format.ts index 33f860e..b26cb30 100644 --- a/services/frontend/src/lib/format.ts +++ b/services/frontend/src/lib/format.ts @@ -1,3 +1,5 @@ +import type { MessageMetadata } from "@/lib/types/message"; + /** * Format a number with locale separators. */ @@ -72,3 +74,65 @@ export function getMessageChannelLabel(msg: { if (channelName) return channelName; return msg.channel_id?.slice(0, 8) ?? ""; } + +/** + * Render Discord mention/emoji/sticker tokens in message content to readable + * names using the captured metadata (mentionedRoles / mentionedUsers / + * customEmojis / stickers). Mirror of the gateway's renderDiscordMentions. + * + * - `<@&id>` → `@RoleName` (falls back to `@role`) + * - `<@id>` / `<@!id>` → `@Username` (falls back to `@user`) + * - `<:name:id>` → `:name:` (falls back to the literal name) + * - sticker-only content already stored as `[Sticker: name]`; when a message + * has both text and stickers, append `[Sticker: name]` so stickers always + * surface in the feed. + */ +export function renderMessageContent( + content: string, + metadata?: string | null, +): string { + if (!content) return content; + let m: MessageMetadata | null = null; + try { + m = JSON.parse(metadata ?? "") as MessageMetadata; + } catch { + // metadata malformed — render tokens from their literal names only + } + + let rendered = content; + if (rendered.includes("<")) { + const roles = new Map( + (m?.mentionedRoles ?? []).map((r) => [r.id, r.name] as const), + ); + const users = new Map( + (m?.mentionedUsers ?? []).map((u) => [u.id, u.username] as const), + ); + const emojis = new Map( + (m?.customEmojis ?? []).map((e) => [e.id, e.name] as const), + ); + rendered = rendered.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 `:${emojis.get(emojiId) ?? emojiTokenName}:`; + } + if (roleId !== undefined) return `@${roles.get(roleId) ?? "role"}`; + if (userId !== undefined) return `@${users.get(userId) ?? "user"}`; + return _full; + }, + ); + } + + const stickers = m?.stickers ?? []; + if ( + stickers.length > 0 && + !rendered.includes("[Sticker:") && + !rendered.includes("[Attachment:") + ) { + rendered += ` ${stickers + .map((s) => `[Sticker: ${s.name ?? "unknown"}]`) + .join(" ")}`; + } + + return rendered; +} diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index fdb1fe2..9c5dd61 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -43,6 +43,23 @@ export interface StickerInfo { url?: string | null; } +export interface CustomEmojiInfo { + id: string; + name: string; + animated?: boolean; + url?: string | null; +} + +export interface MentionedRoleInfo { + id: string; + name: string; +} + +export interface MentionedUserInfo { + id: string; + username: string; +} + export interface AttachmentRef { name: string; url: string; @@ -70,6 +87,9 @@ export interface MessageMetadata { stickers?: StickerInfo[] | null; attachments?: AttachmentRef[] | null; embeds?: EmbedInfo[] | null; + customEmojis?: CustomEmojiInfo[] | null; + mentionedRoles?: MentionedRoleInfo[] | null; + mentionedUsers?: MentionedUserInfo[] | null; channel?: ChannelRef | null; reference?: ReferenceInfo | null; }