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;
|
channelId: string | null;
|
||||||
guildId: string | null;
|
guildId: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
|
content: string | null;
|
||||||
|
repliedUsername: string | null;
|
||||||
|
repliedUserId: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
isCrosspost: boolean;
|
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 {
|
export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||||
const member = message.member;
|
const member = message.member;
|
||||||
|
const referenceContent = getReferencedMessageContent(message);
|
||||||
|
const ref = message.reference;
|
||||||
return {
|
return {
|
||||||
stickers: getStickerMetadata(message),
|
stickers: getStickerMetadata(message),
|
||||||
embeds: getEmbedMetadata(message),
|
embeds: getEmbedMetadata(message),
|
||||||
@@ -232,13 +266,16 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
channel: getMessageLocation(message),
|
channel: getMessageLocation(message),
|
||||||
reference: message.reference
|
reference: ref
|
||||||
? {
|
? {
|
||||||
messageId: message.reference.messageId ?? null,
|
messageId: ref.messageId ?? null,
|
||||||
channelId: message.reference.channelId ?? null,
|
channelId: ref.channelId ?? null,
|
||||||
guildId: message.reference.guildId ?? null,
|
guildId: ref.guildId ?? null,
|
||||||
type:
|
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,
|
: null,
|
||||||
isCrosspost: message.flags?.has(1 << 1) ?? false,
|
isCrosspost: message.flags?.has(1 << 1) ?? false,
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Forward,
|
||||||
Hash,
|
Hash,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
MessageCircle,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Reply,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Smile,
|
Smile,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} 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 type { MessageRecord } from "../../../entities/message/types.js";
|
||||||
import { parseMetadata } from "../../../shared/lib/utils.js";
|
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||||
|
import { getMessageById } from "../../../shared/api/client.js";
|
||||||
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
||||||
|
|
||||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
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;
|
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
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 analysisSummary = useMemo(() => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (categories.length > 0) {
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Row header: time + edit/delete indicators + AI badges */}
|
{/* Row header: time + edit/delete indicators + AI badges */}
|
||||||
@@ -211,6 +296,9 @@ function MessageRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reference context: reply / forward / crosspost */}
|
||||||
|
{referenceIndicator}
|
||||||
|
|
||||||
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
||||||
{shouldShowContent ? (
|
{shouldShowContent ? (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -166,6 +166,12 @@ export function reanalyzeMessage(id: string): Promise<void> {
|
|||||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
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: {
|
export function reanalyzeErrorBatch(opts: {
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export interface MessageMetadata {
|
|||||||
threadId?: string;
|
threadId?: string;
|
||||||
threadName?: 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 {
|
export function parseMetadata(value: string | null): MessageMetadata {
|
||||||
|
|||||||
Reference in New Issue
Block a user