feat: add forwarding & reply detection with frontend indicators
- Enhanced messageMetadata to capture referenced message content - Added reply indicator (↩️ Replying to @username + content snippet) - Added forward badge (🔁 Forwarded) and crosspost badge (📢 Crossposted) - Frontend auto-fetches referenced message content when metadata is empty - Added getMessageById API helper
This commit is contained in:
@@ -82,6 +82,9 @@ export interface RichMessageMetadata {
|
||||
channelId: string | null;
|
||||
guildId: string | null;
|
||||
type: string | null;
|
||||
content: string | null;
|
||||
repliedUsername: string | null;
|
||||
repliedUserId: string | null;
|
||||
} | null;
|
||||
isCrosspost: boolean;
|
||||
}
|
||||
@@ -207,8 +210,39 @@ export function getEmbedMetadata(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get referenced message content from the channel cache.
|
||||
* For replies, Discord sends `referenced_message` in the API, which
|
||||
* discord.js-selfbot-v13 caches in the channel's message manager.
|
||||
* Returns null if the message isn't cached (e.g. forwards without
|
||||
* referenced_message payload).
|
||||
*/
|
||||
function getReferencedMessageContent(
|
||||
message: Message,
|
||||
): { content: string; username: string; userId: string } | null {
|
||||
const ref = message.reference;
|
||||
if (!ref?.messageId) return null;
|
||||
try {
|
||||
const cached = (message.channel as any)?.messages?.cache?.get(
|
||||
ref.messageId,
|
||||
);
|
||||
if (cached?.content) {
|
||||
return {
|
||||
content: cached.content,
|
||||
username: cached.author?.username ?? "Unknown",
|
||||
userId: cached.author?.id ?? "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Cache may not be available or message not in it
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||
const member = message.member;
|
||||
const referenceContent = getReferencedMessageContent(message);
|
||||
const ref = message.reference;
|
||||
return {
|
||||
stickers: getStickerMetadata(message),
|
||||
embeds: getEmbedMetadata(message),
|
||||
@@ -232,13 +266,16 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||
}
|
||||
: null,
|
||||
channel: getMessageLocation(message),
|
||||
reference: message.reference
|
||||
reference: ref
|
||||
? {
|
||||
messageId: message.reference.messageId ?? null,
|
||||
channelId: message.reference.channelId ?? null,
|
||||
guildId: message.reference.guildId ?? null,
|
||||
messageId: ref.messageId ?? null,
|
||||
channelId: ref.channelId ?? null,
|
||||
guildId: ref.guildId ?? null,
|
||||
type:
|
||||
(message.reference.type as unknown as string | undefined) ?? null,
|
||||
(ref.type as unknown as string | undefined) ?? null,
|
||||
content: referenceContent?.content ?? null,
|
||||
repliedUsername: referenceContent?.username ?? null,
|
||||
repliedUserId: referenceContent?.userId ?? null,
|
||||
}
|
||||
: null,
|
||||
isCrosspost: message.flags?.has(1 << 1) ?? false,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Forward,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Reply,
|
||||
RotateCw,
|
||||
Smile,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||
import { getMessageById } from "../../../shared/api/client.js";
|
||||
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
@@ -127,6 +131,35 @@ function MessageRow({
|
||||
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
||||
|
||||
// ── Fetch referenced message content for replies if not in metadata ──
|
||||
const referenceMeta = metadata.reference;
|
||||
const [fetchedRefContent, setFetchedRefContent] = useState<{
|
||||
username: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
message.is_reply &&
|
||||
referenceMeta?.messageId &&
|
||||
!referenceMeta?.content &&
|
||||
!message.deleted_at
|
||||
) {
|
||||
getMessageById(referenceMeta.messageId)
|
||||
.then((refMsg) => {
|
||||
if (refMsg) {
|
||||
setFetchedRefContent({
|
||||
username: refMsg.username,
|
||||
content: refMsg.content,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Referenced message might not exist in our DB
|
||||
});
|
||||
}
|
||||
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
|
||||
|
||||
const analysisSummary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (categories.length > 0) {
|
||||
@@ -167,6 +200,58 @@ function MessageRow({
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reference context (reply / forward / crosspost) ─────────────────
|
||||
const renderReferenceIndicator = () => {
|
||||
// Use fetched content if metadata doesn't have it
|
||||
const effectiveRepliedUsername =
|
||||
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
|
||||
const effectiveRepliedContent =
|
||||
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
|
||||
|
||||
if (message.is_reply) {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
|
||||
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-foreground/60">
|
||||
Replying to{" "}
|
||||
{effectiveRepliedUsername
|
||||
? `@${effectiveRepliedUsername}`
|
||||
: "a message"}
|
||||
</span>
|
||||
{effectiveRepliedContent && (
|
||||
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
|
||||
{effectiveRepliedContent}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_forward) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-amber-400/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-amber-500" />
|
||||
<span className="font-medium text-amber-600/70">Forwarded</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_crosspost) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-sky-400/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-sky-500" />
|
||||
<span className="font-medium text-sky-600/70">Crossposted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const referenceIndicator = renderReferenceIndicator();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Row header: time + edit/delete indicators + AI badges */}
|
||||
@@ -211,6 +296,9 @@ function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference context: reply / forward / crosspost */}
|
||||
{referenceIndicator}
|
||||
|
||||
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
||||
{shouldShowContent ? (
|
||||
<p
|
||||
|
||||
@@ -166,6 +166,12 @@ export function reanalyzeMessage(id: string): Promise<void> {
|
||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getMessageById(
|
||||
id: string,
|
||||
): Promise<MessageRecord | null> {
|
||||
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
|
||||
}
|
||||
|
||||
export function reanalyzeErrorBatch(opts: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
|
||||
@@ -27,6 +27,16 @@ export interface MessageMetadata {
|
||||
threadId?: string;
|
||||
threadName?: string;
|
||||
};
|
||||
reference?: {
|
||||
messageId: string | null;
|
||||
channelId: string | null;
|
||||
guildId: string | null;
|
||||
type: string | null;
|
||||
content: string | null;
|
||||
repliedUsername: string | null;
|
||||
repliedUserId: string | null;
|
||||
} | null;
|
||||
isCrosspost?: boolean;
|
||||
}
|
||||
|
||||
export function parseMetadata(value: string | null): MessageMetadata {
|
||||
|
||||
Reference in New Issue
Block a user