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:
MythEclipse
2026-06-21 18:22:27 +07:00
parent 752d144dc0
commit 21b818a344
4 changed files with 147 additions and 6 deletions
@@ -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;
+10
View File
@@ -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 {