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:
@@ -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"])
|
||||
|
||||
@@ -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({
|
||||
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-xs text-text-secondary">
|
||||
{item.content || item.id}
|
||||
{renderMessageContent(item.content, item.metadata) || item.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{msg.content}</p>
|
||||
<p className="text-sm leading-relaxed">{renderMessageContent(msg.content, msg.metadata)}</p>
|
||||
{msg.ai_moderation_flags &&
|
||||
msg.ai_moderation_flags !== "[]" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p className="text-xs leading-relaxed text-text-secondary line-clamp-2">
|
||||
{msg.username}: {msg.content || "(no text content)"}
|
||||
{msg.username}: {renderMessageContent(msg.content, msg.metadata) || "(no text content)"}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-mono text-text-secondary/40">
|
||||
{new Date(msg.created_at).toLocaleString()}
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary truncate flex-1">
|
||||
{msg.content}
|
||||
{renderMessageContent(msg.content, msg.metadata)}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-secondary/40 shrink-0">
|
||||
{msg.timestamp}
|
||||
|
||||
@@ -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[] }) {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary line-clamp-1">
|
||||
{item.content}
|
||||
{renderMessageContent(item.content, item.metadata)}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-secondary/50">
|
||||
{item.reason}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<p className="text-xs leading-relaxed text-text-secondary line-clamp-2">
|
||||
{msg.content || "(no text content)"}
|
||||
{renderMessageContent(msg.content, msg.metadata) || "(no text content)"}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-mono text-text-secondary/40">
|
||||
{msg.channel_id?.slice(0, 8)} ·{" "}
|
||||
|
||||
@@ -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)}
|
||||
</p>
|
||||
{(() => {
|
||||
const u = extractFirstImage(msg.metadata);
|
||||
|
||||
@@ -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 */}
|
||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||
{message.content || "(no text content)"}
|
||||
{renderMessageContent(message.content, message.metadata) || "(no text content)"}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -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 */}
|
||||
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
|
||||
{message.content || "(no text content)"}
|
||||
{renderMessageContent(message.content, message.metadata) || "(no text content)"}
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
|
||||
@@ -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) {
|
||||
<span className="font-medium text-text-primary">{msg.username}</span>
|
||||
<span className="text-text-secondary/40">{getMessageChannelLabel(msg)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{msg.content}</p>
|
||||
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{renderMessageContent(msg.content, msg.metadata)}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user