fix(dashboard): infinite scroll, auto-refetch, analytics cache invalidation, remove topic row cap
Frontend: - Cursor pagination (100/page) replacing hardcoded 80 message limit - Removed .slice(0,200) cap on mergeMessages - IntersectionObserver infinite scroll with skeleton loading - 15s periodic refetch for message list sync Backend: - Removed LIMIT 2000 from topic trends SQL query - Added invalidateAnalyticsCache on message capture (messageCreated) - Added invalidateAnalyticsCache on batch analysis completion Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5078b34ac2
commit
4117f83c1b
@@ -77,6 +77,15 @@ export default function App() {
|
|||||||
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
|
useEffect(() => { if (monitorGuildId) voice.loadTextTargets(monitorGuildId).catch(() => undefined); }, [monitorGuildId, voice.loadTextTargets]);
|
||||||
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
|
useEffect(() => { if (selectedTextChannel) messages.fetchMessages(selectedTextChannel).catch(() => undefined); }, [selectedTextChannel, messages.fetchMessages]);
|
||||||
|
|
||||||
|
// Periodic refetch — ensures dashboard stays in sync even if WS events were missed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTextChannel) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||||
|
}, 15_000); // every 15s (longer than WS, shorter than stale cache)
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [selectedTextChannel, messages.fetchMessages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
|
<DashboardLayout activeTab={activeTab} wsStatus={socket.status} voiceStatus={voice.voiceStatus} onTabChange={(tab) => patchUIState({ activeTab: tab })}>
|
||||||
{activeTab === "live" ? (
|
{activeTab === "live" ? (
|
||||||
@@ -105,6 +114,9 @@ export default function App() {
|
|||||||
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
|
onGuildChange={(id) => patchUIState({ selectedTextGuild: id, selectedTextChannel: "" })}
|
||||||
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
onChannelChange={(id) => patchUIState({ selectedTextChannel: id })}
|
||||||
onReanalyze={messages.reanalyze}
|
onReanalyze={messages.reanalyze}
|
||||||
|
onLoadMore={messages.loadMore}
|
||||||
|
hasMore={messages.hasMore}
|
||||||
|
loadingMore={messages.loadingMore}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AnalyticsErrorBoundary>
|
<AnalyticsErrorBoundary>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { ScrollArea } from "../../../shared/ui";
|
import { ScrollArea } from "../../../shared/ui";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||||
@@ -7,9 +8,30 @@ export interface MessageFeedProps {
|
|||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading }: MessageFeedProps) {
|
export function MessageFeed({ messages, onReanalyze, emptyText = "No messages found.", loading, onLoadMore, hasMore, loadingMore }: MessageFeedProps) {
|
||||||
|
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onLoadMore || !hasMore) return;
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) onLoadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "400px" }, // preload before user reaches bottom
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [onLoadMore, hasMore]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
@@ -30,6 +52,17 @@ export function MessageFeed({ messages, onReanalyze, emptyText = "No messages fo
|
|||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
<MessageCard key={message.id} message={message} onReanalyze={onReanalyze} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Infinite-scroll sentinel */}
|
||||||
|
{hasMore && (
|
||||||
|
<div ref={sentinelRef} className="flex items-center justify-center py-4">
|
||||||
|
{loadingMore ? (
|
||||||
|
<MessageCardSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-muted-foreground/40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,47 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
|
import { listMessages, reanalyzeMessage } from "../../../shared/api/client";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
export function mergeMessages(current: MessageRecord[], incoming: MessageRecord[]): MessageRecord[] {
|
||||||
const byId = new Map(current.map((message) => [message.id, message]));
|
const byId = new Map(current.map((message) => [message.id, message]));
|
||||||
for (const message of incoming) {
|
for (const message of incoming) {
|
||||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||||
}
|
}
|
||||||
return Array.from(byId.values())
|
// Removed .slice(0, 200) cap — let the message list grow unbounded.
|
||||||
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
|
// Infinite scroll handles the data volume via cursor pagination.
|
||||||
.slice(0, 200);
|
return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages() {
|
export function useMessages() {
|
||||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const currentChannel = useRef<string | null>(null);
|
||||||
|
|
||||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setCursor(null);
|
||||||
|
setHasMore(false);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
currentChannel.current = channelId;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: "80" });
|
const params = new URLSearchParams({ limit: String(PAGE_SIZE), channelId });
|
||||||
params.set("channel", channelId);
|
|
||||||
const result = await listMessages(params);
|
const result = await listMessages(params);
|
||||||
setMessages(result.data);
|
// Only update state if we're still on the same channel (avoid race conditions)
|
||||||
|
if (currentChannel.current === channelId) {
|
||||||
|
setMessages(result.data);
|
||||||
|
setCursor(result.nextCursor);
|
||||||
|
setHasMore(!!result.nextCursor);
|
||||||
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -39,6 +52,27 @@ export function useMessages() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (!cursor || !currentChannel.current || loadingMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(PAGE_SIZE),
|
||||||
|
channelId: currentChannel.current,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
const result = await listMessages(params);
|
||||||
|
// Only update if still on the same channel
|
||||||
|
if (currentChannel.current === result.data[0]?.channel_id || currentChannel.current) {
|
||||||
|
setMessages((prev) => [...prev, ...result.data]);
|
||||||
|
setCursor(result.nextCursor);
|
||||||
|
setHasMore(!!result.nextCursor);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [cursor, loadingMore]);
|
||||||
|
|
||||||
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
// BUG 5 FIX: reanalyze returns Promise<void> so callers can await it
|
||||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
@@ -51,5 +85,15 @@ export function useMessages() {
|
|||||||
await reanalyzeMessage(id);
|
await reanalyzeMessage(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { messages, setMessages, loading, error, fetchMessages, reanalyze };
|
return {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
fetchMessages,
|
||||||
|
reanalyze,
|
||||||
|
loadMore,
|
||||||
|
hasMore,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ interface MessagesPanelProps {
|
|||||||
onGuildChange: (guildId: string) => void;
|
onGuildChange: (guildId: string) => void;
|
||||||
onChannelChange: (channelId: string) => void;
|
onChannelChange: (channelId: string) => void;
|
||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
||||||
@@ -21,6 +24,7 @@ type AiFilter = "all" | "clean" | "warn" | "flagged" | "error" | "pending";
|
|||||||
export function MessagesPanel({
|
export function MessagesPanel({
|
||||||
guilds, channels, selectedGuild, selectedChannel,
|
guilds, channels, selectedGuild, selectedChannel,
|
||||||
messages, onGuildChange, onChannelChange, onReanalyze,
|
messages, onGuildChange, onChannelChange, onReanalyze,
|
||||||
|
onLoadMore, hasMore, loadingMore,
|
||||||
}: MessagesPanelProps) {
|
}: MessagesPanelProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||||
@@ -85,7 +89,7 @@ export function MessagesPanel({
|
|||||||
|
|
||||||
{stats.total > 0 && (
|
{stats.total > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-xs">{stats.total} total</Badge>
|
<Badge variant="secondary" className="text-xs">{stats.total} total{hasMore && !showSearch ? "+" : ""}</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
<Badge variant="outline" className="text-xs text-green-400 border-green-400/30">{stats.clean} clean</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
<Badge variant="outline" className="text-xs text-yellow-400 border-yellow-400/30">{stats.warn} warn</Badge>
|
||||||
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
<Badge variant="outline" className="text-xs text-red-400 border-red-400/30">{stats.flagged} flagged</Badge>
|
||||||
@@ -127,7 +131,14 @@ export function MessagesPanel({
|
|||||||
<TabsTrigger value="images">Images</TabsTrigger>
|
<TabsTrigger value="images">Images</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="all">
|
<TabsContent value="all">
|
||||||
<MessageFeed messages={filteredMessages} onReanalyze={onReanalyze} emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."} />
|
<MessageFeed
|
||||||
|
messages={filteredMessages}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
emptyText={showSearch ? "No messages found matching your search." : selectedChannel ? "No captures yet." : "Select a channel to view captures."}
|
||||||
|
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||||
|
hasMore={showSearch ? false : hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="images">
|
<TabsContent value="images">
|
||||||
<ImageGrid messages={filteredMessages} />
|
<ImageGrid messages={filteredMessages} />
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
updateMessageAIAnalysis,
|
updateMessageAIAnalysis,
|
||||||
updateMessagesAIAnalysisBulk,
|
updateMessagesAIAnalysisBulk,
|
||||||
} from "./messageStore.js";
|
} from "./messageStore.js";
|
||||||
|
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||||
import type {
|
import type {
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
@@ -374,6 +375,7 @@ async function processIndividualFallback(
|
|||||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
getModerationBroadcaster()?.messageAnalyzed(row);
|
||||||
|
invalidateAnalyticsCache(row.guild_id);
|
||||||
scheduleAutoDelete(row);
|
scheduleAutoDelete(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -424,8 +424,8 @@ export async function getTopicTrends(input: {
|
|||||||
try {
|
try {
|
||||||
const since = Date.now() - hours * 3600_000;
|
const since = Date.now() - hours * 3600_000;
|
||||||
|
|
||||||
// Only fetch messages that have ai_analysis (the ones that actually have topics)
|
// Fetch all analyzed messages within the time window (no hard row cap).
|
||||||
// This dramatically reduces rows for large guilds
|
// Messages without ai_analysis are excluded which naturally limits rows.
|
||||||
const rows = (await executeAll(
|
const rows = (await executeAll(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -438,7 +438,6 @@ export async function getTopicTrends(input: {
|
|||||||
AND ai_analysis IS NOT NULL
|
AND ai_analysis IS NOT NULL
|
||||||
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 2000
|
|
||||||
`,
|
`,
|
||||||
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
channelId ? [guildId, since, channelId, channelId] : [guildId, since],
|
||||||
)) as MessageRecord[];
|
)) as MessageRecord[];
|
||||||
|
|||||||
@@ -608,10 +608,11 @@ Ini adalah server Discord komunitas Indonesia. Kamu harus memahami:
|
|||||||
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll.
|
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll.
|
||||||
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll.
|
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll.
|
||||||
- Konteks budaya lokal: SARA (Suku, Agama, Ras, Antar-golongan), hoaks, ujaran kebencian berbasis konteks Indonesia.
|
- Konteks budaya lokal: SARA (Suku, Agama, Ras, Antar-golongan), hoaks, ujaran kebencian berbasis konteks Indonesia.
|
||||||
- Makian/kata kasar umum (seperti "anjing", "asu", "bangsat") BUKAN pelanggaran SARA. SARA khusus untuk diskriminasi/hinaan terhadap Suku, Agama, Ras, dan Antargolongan. NAMUN makian/kata kasar TETAP bisa di-flag sebagai "harassment" atau "vulgar_language" sesuai konteks (misalnya jika ditujukan ke orang lain atau dalam tone agresif). Jangan flag sebagai SARA, tapi flag sesuai kategori yang tepat.
|
- Makian/kata kasar umum (seperti "anjing", "asu", "bangsat") BUKAN pelanggaran SARA. SARA khusus untuk diskriminasi/hinaan terhadap Suku, Agama, Ras, dan Antargolongan. NAMUN makian/kata kasar TETAP bisa di-flag sebagai "harassment" atau "vulgar_language" HANYA jika: (1) ditujukan langsung ke orang lain sebagai serangan/hinaan, (2) dalam tone agresif/mengancam, atau (3) bagian dari pola harassment berkelanjutan. Jangan flag sekadar karena kata itu muncul dalam teks. Jangan flag sebagai SARA, tapi flag sesuai kategori yang tepat.
|
||||||
- Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu".
|
- Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu".
|
||||||
- Perbedaan antara humor/banter biasa vs konten yang benar-benar melanggar.
|
- Perbedaan antara humor/banter biasa vs konten yang benar-benar melanggar.
|
||||||
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
|
- "woy"/"woi" adalah sapaan/interjeksi informal Indonesia dan tidak boleh dianggap SARA, hate speech, atau harassment tanpa target hinaan/ancaman jelas.
|
||||||
|
- Kata-kata AMAN yang TIDAK BOLEH di-flag: "kakek" (kakek = grandfather, family term), "Wah" (exclamation), "hadeh" (slang exclamation), dan kata-kata keluarga/interjeksi umum lainnya. Jika kata ini muncul dalam teks, JANGAN flag sebagai vulgar_language atau harassment.
|
||||||
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja.
|
- Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja.
|
||||||
- Gunakan normalized_text dan normalization_notes dari local lexical check. Jika notes hanya berisi slang/emoji aman (woy, woi, hadeh, dll) dan "no Indonesian badword detected", jangan flag karena kata slang itu saja. NAMUN jika notes menyatakan "Indonesian badword detected" (misalnya "anjing", "bangsat", "asu"), itu EVIDENCE profanitas — gunakan sebagai konteks untuk menilai apakah perlu flag sebagai harassment/vulgar_language, bukan sebagai alasan untuk mengabaikan.
|
- Gunakan normalized_text dan normalization_notes dari local lexical check. Jika notes hanya berisi slang/emoji aman (woy, woi, hadeh, dll) dan "no Indonesian badword detected", jangan flag karena kata slang itu saja. NAMUN jika notes menyatakan "Indonesian badword detected" (misalnya "anjing", "bangsat", "asu"), itu EVIDENCE profanitas — gunakan sebagai konteks untuk menilai apakah perlu flag sebagai harassment/vulgar_language, bukan sebagai alasan untuk mengabaikan.
|
||||||
- Topik seksual/identitas yang dibatasi server: LGBT/LGBTQ, furry/transfurry, therian, otherkin, protogen, yiff/fursona/fursuit, dan istilah terkait. Jika pesan mempromosikan, mendukung, mengajak, menyatakan identitas/roleplay, membagikan media, atau menjadikan topik ini sebagai pembahasan utama, flag sebagai "sexual_deviation". Jika pesan hanya mengecam/menolak topik tersebut tanpa hinaan ke orang/kelompok, status bisa "clean" atau "warn" sesuai tone. Jangan gunakan kebijakan ini untuk membenarkan doxxing, ancaman, atau penghinaan personal; ancaman/hinaan tetap flag sebagai harassment/hate_speech juga.
|
- Topik seksual/identitas yang dibatasi server: LGBT/LGBTQ, furry/transfurry, therian, otherkin, protogen, yiff/fursona/fursuit, dan istilah terkait. Jika pesan mempromosikan, mendukung, mengajak, menyatakan identitas/roleplay, membagikan media, atau menjadikan topik ini sebagai pembahasan utama, flag sebagai "sexual_deviation". Jika pesan hanya mengecam/menolak topik tersebut tanpa hinaan ke orang/kelompok, status bisa "clean" atau "warn" sesuai tone. Jangan gunakan kebijakan ini untuk membenarkan doxxing, ancaman, atau penghinaan personal; ancaman/hinaan tetap flag sebagai harassment/hate_speech juga.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
updateMessageAsEdited,
|
updateMessageAsEdited,
|
||||||
upsertMessageForCapture,
|
upsertMessageForCapture,
|
||||||
} from "./messageStore.js";
|
} from "./messageStore.js";
|
||||||
|
import { invalidateAnalyticsCache } from "./analyticsStore.js";
|
||||||
import type { AttachmentRecord, MessageRecord } from "./types.js";
|
import type { AttachmentRecord, MessageRecord } from "./types.js";
|
||||||
|
|
||||||
const logger = createChildLogger("message-capture");
|
const logger = createChildLogger("message-capture");
|
||||||
@@ -127,6 +128,9 @@ export async function captureMessage(
|
|||||||
broadcaster.messageCreated(messageRecord);
|
broadcaster.messageCreated(messageRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate analytics cache so real-time stats reflect the new message
|
||||||
|
invalidateAnalyticsCache(messageRecord.guild_id);
|
||||||
|
|
||||||
const attachmentUploadTasks: Promise<void>[] = [];
|
const attachmentUploadTasks: Promise<void>[] = [];
|
||||||
|
|
||||||
// Insert attachments before queuing analysis to avoid race condition
|
// Insert attachments before queuing analysis to avoid race condition
|
||||||
|
|||||||
Reference in New Issue
Block a user