diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ac8cd1..9ad009c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,7 @@ import { DashboardLayout } from "./components/layout/DashboardLayout"; import { LivePanel } from "./components/live/LivePanel"; import { MessagesPanel } from "./components/messages/MessagesPanel"; import { Tabs, TabsContent } from "./components/ui/tabs"; -import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel"; +import { AnalyticsPanel } from "./components/analytics"; import { AuthOverlay } from "./components/layout/AuthOverlay"; import { useDashboardSocket } from "./hooks/useDashboardSocket"; import { mergeMessages, useMessages } from "./hooks/useMessages"; diff --git a/frontend/src/api/analytics.ts b/frontend/src/api/analytics.ts index a7d917d..6b807fe 100644 --- a/frontend/src/api/analytics.ts +++ b/frontend/src/api/analytics.ts @@ -139,3 +139,47 @@ export async function fetchViolators(params: { }); return request(`/api/analytics/violators?${searchParams}`); } + +export interface TrendBucket { + date: string; + count: number; + clean: number; + warned: number; + flagged: number; + error: number; +} + +export interface HeatmapCell { + dayOfWeek: number; + hour: number; + count: number; + clean: number; + warned: number; + flagged: number; +} + +export async function fetchTrend(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const searchParams = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/trend?${searchParams}`); +} + +export async function fetchHeatmap(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const searchParams = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/heatmap?${searchParams}`); +} diff --git a/frontend/src/components/analytics/ActivityChart.tsx b/frontend/src/components/analytics/ActivityChart.tsx new file mode 100644 index 0000000..46ba582 --- /dev/null +++ b/frontend/src/components/analytics/ActivityChart.tsx @@ -0,0 +1,92 @@ +import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts"; +import type { HourlyBucket } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; + +interface ActivityChartProps { + hourly: HourlyBucket[]; + loading: boolean; +} + +export function ActivityChart({ hourly, loading }: ActivityChartProps) { + if (loading && !hourly?.length) { + return ; + } + + if (!hourly?.length) { + return ; + } + + const data = hourly.map((b) => { + const utcHour = parseInt(b.hour.slice(11, 13), 10); + const jakartaHour = (utcHour + 7) % 24; + return { + hour: `${String(jakartaHour).padStart(2, "0")}:00`, + clean: b.clean, + warned: b.warned, + flagged: b.flagged, + error: b.error, + total: b.count, + }; + }); + + return ( + + + Aktivitas per Jam + Distribusi pesan per jam berdasarkan status moderasi. + + + + + + + { + const v = typeof value === "number" ? value : String(value); + return [v, label(String(name))]; + }} + /> + + + + + + + + + + ); +} + +function label(key: string): string { + const map: Record = { clean: "Clean", warned: "Warned", flagged: "Flagged", error: "Error" }; + return map[key] ?? key; +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} + +function EmptyBox({ text }: { text: string }) { + return ( + + + {text} + + + ); +} diff --git a/frontend/src/components/analytics/AnalyticsPanel.tsx b/frontend/src/components/analytics/AnalyticsPanel.tsx deleted file mode 100644 index 87b7de0..0000000 --- a/frontend/src/components/analytics/AnalyticsPanel.tsx +++ /dev/null @@ -1,993 +0,0 @@ -import { useRef, useState } from "react"; -import { motion, AnimatePresence } from "motion/react"; -import { - Activity, - AlertTriangle, - BarChart3, - CheckCircle2, - Clock, - Flame, - MessageSquare, - Shield, - Siren, - TrendingDown, - TrendingUp, - Users, - XCircle, -} from "lucide-react"; -import type { Channel, Guild } from "../../types/voice"; -import { useAnalytics } from "../../hooks/useAnalytics"; -import type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat } from "../../hooks/useAnalytics"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; -import { Select } from "../ui/select"; -import { Button } from "../ui/button"; -import { Badge } from "../ui/badge"; -import { ScrollArea } from "../ui/scroll-area"; -import { cn } from "../../lib/utils"; - -const TIME_RANGES = [ - { label: "1h", value: 1 }, - { label: "3h", value: 3 }, - { label: "6h", value: 6 }, - { label: "12h", value: 12 }, - { label: "24h", value: 24 }, - { label: "48h", value: 48 }, - { label: "7d", value: 168 }, -]; - -interface AnalyticsPanelProps { - guilds: Guild[]; - channels: Channel[]; - selectedGuild: string; - selectedChannel: string; - onGuildChange: (guildId: string) => void; - onChannelChange: (channelId: string) => void; -} - -// ── Color Palette ────────────────────────────────────────────────────── -const GLOW_COLORS = { - clean: "from-emerald-500/20 via-emerald-500/5 to-transparent", - warned: "from-amber-500/20 via-amber-500/5 to-transparent", - flagged: "from-red-500/20 via-red-500/5 to-transparent", - error: "from-orange-500/20 via-orange-500/5 to-transparent", - neutral: "from-blue-500/15 via-blue-500/5 to-transparent", -}; - -export function AnalyticsPanel({ - guilds, - channels, - selectedGuild, - selectedChannel, - onGuildChange, - onChannelChange, -}: AnalyticsPanelProps) { - const [hours, setHours] = useState(24); - - const { - overview, - isLoading, - isFetching, - error, - refresh, - violators, - violatorsLoading, - violatorsFetching, - refreshViolators, - } = useAnalytics({ - guildId: selectedGuild, - channelId: selectedChannel || undefined, - hours, - }); - - // Loading is true only on first load (no cached data); fetching means background refresh - const loading = isLoading && !isFetching; - - return ( -
- {/* ── Control Bar ─────────────────────────────────────────────── */} - - -
- - - - Analytics & Insights - - - Pantau statistik moderasi, topik trending, dan aktivitas user dalam satu dasbor. - - - -
- onChannelChange(e.target.value)} - placeholder="All channels" - options={[ - { value: "", label: "All channels" }, - ...channels.map((c) => ({ value: c.id, label: c.name })), - ]} - /> -
- {TIME_RANGES.map((tr) => ( - - ))} -
- -
-
- - - - {error && ( - - - - -

{error}

-
-
-
- )} - - {!selectedGuild ? ( - - ) : ( - - - {/* ── KPI Stat Cards ─────────────────────────────────────── */} - - - {/* ── Hourly Activity Chart ──────────────────────────────── */} - - - - - Aktivitas Pesan Per Jam - - - Distribusi pesan per jam dengan breakdown status moderasi. - - - - - - - - {/* ── Topics + Leaderboard row ───────────────────────────── */} -
- - - - - Topik Trending - - - Yang paling ramai dibicarakan orang. - - - - - - - - - - - - User Paling Aktif - - - Leaderboard berdasarkan jumlah pesan. - - - - - - -
- - {/* ── VIOLATORS LEADERBOARD ──────────────────────────────── */} - - -
-
- - - Pelanggar Terbanyak - - - User dengan skor pelanggaran tertinggi (flagged × 3 + warned × 1). - -
- - {violators.length} pelanggar - -
-
- - - -
- - {/* ── Full User Leaderboard ──────────────────────────────── */} - - - - - Leaderboard Lengkap - - - Detail aktivitas user: pesan, edit, hapus, flag, dan waktu aktif terakhir. - - - - - - -
-
- )} -
- ); -} - -// ══════════════════════════════════════════════════════════════════════════ -// SUB-COMPONENTS -// ══════════════════════════════════════════════════════════════════════════ - -// ── Animated Card Wrapper ────────────────────────────────────────────── -function AnimatedCard({ - children, - glow, - className, -}: { - children: React.ReactNode; - glow?: keyof typeof GLOW_COLORS; - className?: string; -}) { - const glowClass = GLOW_COLORS[glow ?? "neutral"]; - return ( - - {/* Animated border glow on hover */} -
- -
- {children} - - - ); -} - -// ── Stats Grid ───────────────────────────────────────────────────────── -function StatsGrid({ - overview, - loading, - totalChannels, -}: { - overview: AnalyticsOverview | null; - loading: boolean; - totalChannels: number; -}) { - const cards = [ - { - label: "Total Pesan", - value: overview?.messages.total ?? null, - icon: MessageSquare, - color: "text-blue-400", - bg: "bg-blue-500/10", - border: "border-blue-500/20", - sub: totalChannels > 0 ? `${totalChannels} channel` : "", - trend: null, - }, - { - label: "Clean", - value: overview?.messages.clean ?? null, - icon: CheckCircle2, - color: "text-emerald-400", - bg: "bg-emerald-500/10", - border: "border-emerald-500/20", - sub: overview ? `${pct(overview.messages.clean, overview.messages.total)}%` : "", - trend: "up", - }, - { - label: "Warned", - value: overview?.messages.warned ?? null, - icon: AlertTriangle, - color: "text-amber-400", - bg: "bg-amber-500/10", - border: "border-amber-500/20", - sub: overview ? `${pct(overview.messages.warned, overview.messages.total)}%` : "", - trend: overview && overview.messages.warned > 0 ? "down" : null, - }, - { - label: "Flagged", - value: overview?.messages.flagged ?? null, - icon: Siren, - color: "text-red-400", - bg: "bg-red-500/10", - border: "border-red-500/20", - sub: overview ? `${pct(overview.messages.flagged, overview.messages.total)}%` : "", - trend: overview && overview.messages.flagged > 0 ? "down" : null, - }, - { - label: "Error", - value: overview?.messages.error ?? null, - icon: XCircle, - color: "text-orange-400", - bg: "bg-orange-500/10", - border: "border-orange-500/20", - sub: null, - trend: null, - }, - { - label: "Pending", - value: overview?.messages.pending ?? null, - icon: Clock, - color: "text-slate-400", - bg: "bg-slate-500/10", - border: "border-slate-500/20", - sub: null, - trend: null, - }, - { - label: "Rata-rata Skor", - value: overview?.messages.average_score ?? null, - icon: Shield, - color: "text-cyan-400", - bg: "bg-cyan-500/10", - border: "border-cyan-500/20", - sub: null, - trend: null, - }, - { - label: "User Aktif", - value: overview?.active_users_count ?? null, - icon: Users, - color: "text-violet-400", - bg: "bg-violet-500/10", - border: "border-violet-500/20", - sub: null, - trend: null, - }, - ]; - - return ( -
- {cards.map((card, i) => { - const Icon = card.icon; - return ( - - - {/* Background pulse */} -
- -
-

- {card.label} -

- -
-
- - {loading ? ( - - … - - ) : ( - card.value ?? "—" - )} - - {card.trend && ( - - {card.trend === "up" ? ( - - ) : ( - - )} - - )} -
- {card.sub && ( -

{card.sub}

- )} -
- - - ); - })} -
- ); -} - -// ── Hourly Chart ─────────────────────────────────────────────────────── -function HourlyChart({ hourly, loading }: { hourly: HourlyBucket[] | undefined; loading: boolean }) { - const containerRef = useRef(null); - - if (loading && !hourly?.length) { - return ; - } - - if (!hourly?.length) { - return ( -
- -

Belum ada data untuk periode ini.

-
- ); - } - - const maxCount = Math.max(...hourly.map((b) => b.count), 1); - // Convert UTC hour buckets to Jakarta time (UTC+7) - const labels = hourly.map((b) => { - const utcHour = parseInt(b.hour.slice(11, 13), 10); - const jakartaHour = (utcHour + 7) % 24; - return `${String(jakartaHour).padStart(2, "0")}:00`; - }); - - return ( -
-
- {/* Grid lines */} - {[0.25, 0.5, 0.75, 1].map((pct) => ( -
- ))} - {hourly.map((bucket, i) => { - const heightPct = (bucket.count / maxCount) * 100; - const total = bucket.clean + bucket.warned + bucket.flagged + bucket.error || 1; - const cleanH = (bucket.clean / total) * heightPct; - const warnedH = (bucket.warned / total) * heightPct; - const flaggedH = (bucket.flagged / total) * heightPct; - const errorH = (bucket.error / total) * heightPct; - - return ( - - {/* Stacked segments */} -
-
-
-
-
-
- {/* Hover tooltip */} -
- {labels[hourly.indexOf(bucket)]} — {bucket.count} msgs -
- - ); - })} -
- {/* X-axis labels */} -
- {labels.filter((_, i) => i % Math.max(1, Math.floor(labels.length / 6)) === 0 || i === labels.length - 1).map((label, i) => ( - {label} - ))} -
- {/* Legend */} -
- - - - -
-
- ); -} - -function Legend({ color, label }: { color: string; label: string }) { - return ( - - - {label} - - ); -} - -// ── Topic Cloud ──────────────────────────────────────────────────────── -const TOPIC_GRADIENTS = [ - "from-blue-500/30 via-blue-500/15 to-blue-600/20", - "from-emerald-500/30 via-emerald-500/15 to-emerald-600/20", - "from-violet-500/30 via-violet-500/15 to-violet-600/20", - "from-amber-500/30 via-amber-500/15 to-amber-600/20", - "from-cyan-500/30 via-cyan-500/15 to-cyan-600/20", - "from-pink-500/30 via-pink-500/15 to-pink-600/20", - "from-teal-500/30 via-teal-500/15 to-teal-600/20", - "from-orange-500/30 via-orange-500/15 to-orange-600/20", -]; - -const TOPIC_TEXT = [ - "text-blue-300", - "text-emerald-300", - "text-violet-300", - "text-amber-300", - "text-cyan-300", - "text-pink-300", - "text-teal-300", - "text-orange-300", -]; - -const TOPIC_BORDER = [ - "border-blue-500/30", - "border-emerald-500/30", - "border-violet-500/30", - "border-amber-500/30", - "border-cyan-500/30", - "border-pink-500/30", - "border-teal-500/30", - "border-orange-500/30", -]; - -function TopicCloud({ topics, loading }: { topics: TopicTrend[] | undefined; loading: boolean }) { - if (loading && !topics?.length) { - return ; - } - - if (!topics?.length) { - return ( -
- -

Topik akan muncul setelah AI selesai menganalisis.

-
- ); - } - - const maxCount = Math.max(...topics.map((t) => t.count), 1); - - return ( -
- {topics.map((topic, i) => { - const scale = 0.65 + (topic.count / maxCount) * 1.35; - return ( - 0 ? ` · Skor: ${topic.score}` : ""}`} - > - {/* Sparkle dot */} - - {topic.topic} - - {topic.count} - - - ); - })} -
- ); -} - -// ── User Leaderboard ─────────────────────────────────────────────────── -function UserLeaderboard({ - users, - loading, - compact, -}: { - users: UserStat[] | undefined; - loading: boolean; - compact?: boolean; -}) { - if (loading && !users?.length) { - return ; - } - - if (!users?.length) { - return ( -
- -

Belum ada aktivitas user.

-
- ); - } - - const maxMsgs = Math.max(...users.map((u) => u.message_count), 1); - const medals = ["🥇", "🥈", "🥉"]; - - const displayUsers = compact ? users.slice(0, 5) : users; - - return ( - - - - - - - {!compact && ( - <> - - - - - - - )} - {compact && ( - - )} - - - - {displayUsers.map((user, i) => ( - - - - {!compact && ( - <> - - - - - - - )} - {compact && ( - - )} - - ))} - -
#UserPesanEditHapusFlagAktifPesan
- {medals[i] ?? i + 1} - -
- {user.avatar_url ? ( - - ) : ( -
- {user.username.charAt(0).toUpperCase()} -
- )} - - {user.username} - -
-
-
-
- -
- {user.message_count} -
-
- {user.edited_count > 0 ? user.edited_count : "—"} - - {user.deleted_count > 0 ? user.deleted_count : "—"} - - {user.flagged_count > 0 ? ( - - {user.flagged_count} - - ) : ( - - )} - - {formatTimeAgo(user.last_active)} - - {user.message_count} - msg -
- {compact && users.length > 5 && ( -
- +{users.length - 5} user lainnya — lihat leaderboard lengkap di bawah -
- )} -
- ); -} - -// ── Violators Leaderboard ────────────────────────────────────────────── -function ViolatorsLeaderboard({ - users, - loading, -}: { - users: ViolatorStat[] | undefined; - loading: boolean; -}) { - if (loading && !users?.length) { - return ; - } - - if (!users?.length) { - return ( -
- -

Tidak ada pelanggaran terdeteksi. 🎉

-
- ); - } - - const maxScore = Math.max(...users.map((u) => u.violation_score), 1); - - // Danger level colors - interface DangerLevel { bg: string; border: string; text: string; label: string } - function dangerLevel(score: number): DangerLevel { - if (score >= 10) return { bg: "bg-red-500/15 border-red-500/40", border: "border-red-500/40", text: "text-red-300", label: "HIGH" }; - if (score >= 5) return { bg: "bg-amber-500/10 border-amber-500/30", border: "border-amber-500/30", text: "text-amber-300", label: "MED" }; - return { bg: "bg-yellow-500/10 border-yellow-500/20", border: "border-yellow-500/20", text: "text-yellow-300", label: "LOW" }; - } - - return ( - -
- {users.map((user, i) => { - const danger = dangerLevel(user.violation_score); - return ( - - {/* Rank + Danger indicator */} -
-
- {i + 1} -
- {i === 0 && ( - 🔥 - )} -
- - {/* Avatar */} - {user.avatar_url ? ( - - ) : ( -
- {user.username.charAt(0).toUpperCase()} -
- )} - - {/* Info */} -
-
- {user.username} - - {danger.label} - -
-
- {user.worst_flags.length > 0 ? ( - user.worst_flags.map((flag) => ( - - {flag} - - )) - ) : ( - no flags - )} -
-
- - {/* Stats */} -
-
-
Pesan
-
{user.total_messages}
-
-
-
Warned
-
{user.warned_count}
-
-
-
Flagged
-
{user.flagged_count}
-
-
-
Skor
-
- = 10 - ? "bg-gradient-to-r from-red-600 to-red-400" - : user.violation_score >= 5 - ? "bg-gradient-to-r from-amber-500 to-amber-400" - : "bg-gradient-to-r from-yellow-500 to-yellow-400", - )} - initial={{ width: 0 }} - animate={{ width: `${(user.violation_score / maxScore) * 100}%` }} - transition={{ delay: i * 0.05 + 0.2, duration: 0.6 }} - /> -
-
- {user.violation_score} -
-
-
-
- ); - })} -
-
- ); -} - -// ── Empty State ──────────────────────────────────────────────────────── -function EmptyState({ icon: Icon, text }: { icon: typeof BarChart3; text: string }) { - return ( - - -
- -
-

{text}

-
-
- ); -} - -// ── Loading Skeleton ─────────────────────────────────────────────────── -function LoadingSkeleton() { - return ( -
- - - Memuat data... - -
- ); -} - -// ── Helpers ──────────────────────────────────────────────────────────── -function pct(part: number, total: number): number { - if (!total) return 0; - return Math.round((part / total) * 100); -} - -function formatTimeAgo(ts: number): string { - // Use Jakarta time as reference for "ago" calculations - const jakartaNow = new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Jakarta" })); - const diff = jakartaNow.getTime() - ts; - const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "baru saja"; - if (minutes < 60) return `${minutes}m lalu`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}j lalu`; - const days = Math.floor(hours / 24); - return `${days}h lalu`; -} diff --git a/frontend/src/components/analytics/ControlBar.tsx b/frontend/src/components/analytics/ControlBar.tsx new file mode 100644 index 0000000..7fbb829 --- /dev/null +++ b/frontend/src/components/analytics/ControlBar.tsx @@ -0,0 +1,113 @@ +import { Activity, BarChart3 } from "lucide-react"; +import type { Channel, Guild } from "../../types/voice"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Select } from "../ui/select"; +import { Button } from "../ui/button"; +import { cn } from "../../lib/utils"; + +const TIME_RANGES = [ + { label: "1j", value: 1 }, + { label: "3j", value: 3 }, + { label: "6j", value: 6 }, + { label: "12j", value: 12 }, + { label: "24j", value: 24 }, + { label: "48j", value: 48 }, + { label: "7h", value: 168 }, +]; + +interface ControlBarProps { + guilds: Guild[]; + channels: Channel[]; + selectedGuild: string; + selectedChannel: string; + hours: number; + isFetching: boolean; + onGuildChange: (guildId: string) => void; + onChannelChange: (channelId: string) => void; + onHoursChange: (hours: number) => void; + onRefresh: () => void; +} + +export function ControlBar({ + guilds, + channels, + selectedGuild, + selectedChannel, + hours, + isFetching, + onGuildChange, + onChannelChange, + onHoursChange, + onRefresh, +}: ControlBarProps) { + return ( + + + + + Analisis Moderasi + + + Pantau statistik, tren topik, dan aktivitas user. + + + +
+ onChannelChange(e.target.value)} + placeholder="Semua channel" + options={[ + { value: "", label: "Semua channel" }, + ...channels.map((c) => ({ value: c.id, label: c.name })), + ]} + className="min-w-[160px]" + /> +
+ {TIME_RANGES.map((tr) => ( + + ))} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/analytics/Heatmap.tsx b/frontend/src/components/analytics/Heatmap.tsx new file mode 100644 index 0000000..13735af --- /dev/null +++ b/frontend/src/components/analytics/Heatmap.tsx @@ -0,0 +1,112 @@ +import { useMemo } from "react"; +import type { HeatmapCell } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { cn } from "../../lib/utils"; + +const DAYS = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"]; + +interface HeatmapProps { + cells: HeatmapCell[]; + loading: boolean; +} + +export function Heatmap({ cells, loading }: HeatmapProps) { + const maxCount = useMemo(() => Math.max(1, ...cells.map((c) => c.count)), [cells]); + + if (loading && !cells?.length) { + return ; + } + + if (!cells?.length) { + return ; + } + + const cellMap = new Map(); + for (const c of cells) cellMap.set(`${c.dayOfWeek}-${c.hour}`, c); + + function getIntensity(day: number, hour: number): number { + return (cellMap.get(`${day}-${hour}`)?.count ?? 0) / maxCount; + } + + function getHeatClass(intensity: number): string { + if (intensity === 0) return "bg-muted/30"; + if (intensity < 0.1) return "bg-blue-500/10"; + if (intensity < 0.2) return "bg-blue-500/20"; + if (intensity < 0.35) return "bg-blue-500/30"; + if (intensity < 0.5) return "bg-blue-500/45"; + if (intensity < 0.7) return "bg-blue-500/60"; + return "bg-blue-500/80"; + } + + return ( + + + Heatmap Aktivitas + Hari × jam — area biru = lebih ramai. + + +
+
+ {/* Header row */} +
+ {Array.from({ length: 24 }, (_, h) => ( +
+ {h % 3 === 0 ? `${h}` : ""} +
+ ))} +
+ {/* Rows */} + {DAYS.map((day, d) => ( +
+
+ {day} +
+ {Array.from({ length: 24 }, (_, h) => { + const intensity = getIntensity(d, h); + const cell = cellMap.get(`${d}-${h}`); + return ( +
+ ); + })} +
+ ))} +
+
+ {/* Legend */} +
+ Sepi + + + + + Ramai +
+ + + ); +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} + +function EmptyBox() { + return ( + + + Belum ada data heatmap. + + + ); +} diff --git a/frontend/src/components/analytics/SummaryCards.tsx b/frontend/src/components/analytics/SummaryCards.tsx new file mode 100644 index 0000000..a4ab9a3 --- /dev/null +++ b/frontend/src/components/analytics/SummaryCards.tsx @@ -0,0 +1,54 @@ +import type { ModerationBreakdown } from "../../api/analytics"; +import { Card, CardContent } from "../ui/card"; +import { cn } from "../../lib/utils"; + +interface SummaryCardsProps { + messages: ModerationBreakdown | null; + activeUsersCount: number; + totalChannels: number; + loading: boolean; +} + +export function SummaryCards({ messages, activeUsersCount, totalChannels, loading }: SummaryCardsProps) { + const avgPerHour = messages ? Math.round(messages.total / Math.max(1, 24)) : 0; + const cleanPct = messages && messages.total > 0 ? Math.round((messages.clean / messages.total) * 100) : 0; + const warnedPct = messages && messages.total > 0 ? Math.round((messages.warned / messages.total) * 100) : 0; + const flaggedPct = messages && messages.total > 0 ? Math.round((messages.flagged / messages.total) * 100) : 0; + + const cards = [ + { label: "Total Pesan", value: formatNum(messages?.total), accent: "text-foreground" }, + { label: "Rata-rata/jam", value: formatNum(avgPerHour), accent: "text-muted-foreground" }, + { label: "Clean", value: cleanPct > 0 ? `${cleanPct}%` : "—", accent: "text-emerald-400" }, + { label: "Warned", value: warnedPct > 0 ? `${warnedPct}%` : "—", accent: "text-amber-400" }, + { label: "Flagged", value: flaggedPct > 0 ? `${flaggedPct}%` : "—", accent: "text-red-400" }, + { label: "Pending", value: formatNum(messages?.pending), accent: "text-slate-400" }, + { label: "User Aktif", value: formatNum(activeUsersCount), accent: "text-violet-400" }, + { label: "Channel", value: formatNum(totalChannels), accent: "text-blue-400" }, + ]; + + return ( +
+ {cards.map((card) => ( + + +
+ {card.label} +
+
+ {loading ? ( + + ) : ( + card.value + )} +
+
+
+ ))} +
+ ); +} + +function formatNum(v: number | undefined | null): string { + if (v == null || v === 0) return "—"; + return v.toLocaleString("id-ID"); +} diff --git a/frontend/src/components/analytics/TopicList.tsx b/frontend/src/components/analytics/TopicList.tsx new file mode 100644 index 0000000..aebe55c --- /dev/null +++ b/frontend/src/components/analytics/TopicList.tsx @@ -0,0 +1,77 @@ +import { Flame } from "lucide-react"; +import type { TopicTrend } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { ScrollArea } from "../ui/scroll-area"; + +interface TopicListProps { + topics: TopicTrend[]; + loading: boolean; +} + +export function TopicList({ topics, loading }: TopicListProps) { + if (loading && !topics?.length) { + return ; + } + + if (!topics?.length) { + return ( + + + Topik akan muncul setelah AI selesai menganalisis. + + + ); + } + + const maxCount = Math.max(...topics.map((t) => t.count), 1); + + return ( + + + + + Topik Trending + + + Yang paling ramai dibicarakan. + + + + +
+ {topics.map((topic, i) => ( +
+ + {i + 1} + + {topic.topic} +
+
+
+
+ + {topic.count} + +
+
+ ))} +
+ + + + ); +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/frontend/src/components/analytics/TrendChart.tsx b/frontend/src/components/analytics/TrendChart.tsx new file mode 100644 index 0000000..99e6cf5 --- /dev/null +++ b/frontend/src/components/analytics/TrendChart.tsx @@ -0,0 +1,73 @@ +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from "recharts"; +import type { TrendBucket } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; + +interface TrendChartProps { + trend: TrendBucket[]; + loading: boolean; +} + +export function TrendChart({ trend, loading }: TrendChartProps) { + if (loading && !trend?.length) { + return ; + } + + if (!trend?.length) { + return null; + } + + const data = trend.map((b) => ({ + date: b.date, + clean: b.clean, + warned: b.warned, + flagged: b.flagged, + error: b.error, + total: b.count, + })); + + return ( + + + Tren Harian + Volume pesan per hari dengan status moderasi. + + + + + d.slice(5)} /> + + { + const v = typeof value === "number" ? value : String(value); + return [v, label(String(name))]; + }} + labelFormatter={(l: unknown) => String(l)} + /> + + + + + + + + + + ); +} + +function label(key: string): string { + const map: Record = { total: "Total", clean: "Clean", warned: "Warned", flagged: "Flagged" }; + return map[key] ?? key; +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/frontend/src/components/analytics/UserTable.tsx b/frontend/src/components/analytics/UserTable.tsx new file mode 100644 index 0000000..0960e6f --- /dev/null +++ b/frontend/src/components/analytics/UserTable.tsx @@ -0,0 +1,117 @@ +import { Users } from "lucide-react"; +import type { UserStat } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Badge } from "../ui/badge"; +import { ScrollArea } from "../ui/scroll-area"; + +interface UserTableProps { + users: UserStat[]; + loading: boolean; +} + +export function UserTable({ users, loading }: UserTableProps) { + if (loading && !users?.length) { + return ; + } + + if (!users?.length) { + return ( + + + Belum ada aktivitas user. + + + ); + } + + const maxMsgs = Math.max(...users.map((u) => u.message_count), 1); + const medals = ["🥇", "🥈", "🥉"]; + + return ( + + + + + User Paling Aktif + + + Leaderboard berdasarkan jumlah pesan. + + + + + + + + + + + + + + + + + {users.map((user, i) => ( + + + + + + + + + ))} + +
#UserPesanEditHapusFlag
+ {medals[i] ?? i + 1} + +
+ {user.avatar_url ? ( + + ) : ( +
+ {user.username.charAt(0).toUpperCase()} +
+ )} + {user.username} +
+
+
+
+
+
+ {user.message_count} +
+
+ {user.edited_count > 0 ? user.edited_count : "—"} + + {user.deleted_count > 0 ? user.deleted_count : "—"} + + {user.flagged_count > 0 ? ( + + {user.flagged_count} + + ) : ( + + )} +
+
+
+
+ ); +} + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/frontend/src/components/analytics/ViolatorTable.tsx b/frontend/src/components/analytics/ViolatorTable.tsx new file mode 100644 index 0000000..2c25827 --- /dev/null +++ b/frontend/src/components/analytics/ViolatorTable.tsx @@ -0,0 +1,132 @@ +import { Siren } from "lucide-react"; +import type { ViolatorStat } from "../../api/analytics"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Badge } from "../ui/badge"; +import { ScrollArea } from "../ui/scroll-area"; + +interface ViolatorTableProps { + users: ViolatorStat[]; + loading: boolean; +} + +export function ViolatorTable({ users, loading }: ViolatorTableProps) { + if (loading && !users?.length) { + return ; + } + + if (!users?.length) { + return ( + + + Tidak ada pelanggaran terdeteksi. + + + ); + } + + const maxScore = Math.max(...users.map((u) => u.violation_score), 1); + + function dangerLabel(score: number) { + if (score >= 10) return { variant: "destructive" as const, text: "HIGH" }; + if (score >= 5) return { variant: "warning" as const, text: "MED" }; + return { variant: "secondary" as const, text: "LOW" }; + } + + return ( + + +
+
+ + + Pelanggar Terbanyak + + + Skor: flagged × 3 + warned × 1. + +
+ {users.length} pelanggar +
+
+ + + + + + + + + + + + + + {users.map((user, i) => { + const danger = dangerLabel(user.violation_score); + return ( + + + + + + + + ); + })} + +
#UserWarnedFlaggedSkor
+ {i + 1} + +
+ {user.avatar_url ? ( + + ) : ( +
+ {user.username.charAt(0).toUpperCase()} +
+ )} + {user.username} + + {danger.text} + +
+
+ {user.warned_count} + + {user.flagged_count} + +
+
+
= 10 + ? "bg-gradient-to-r from-red-600 to-red-400" + : user.violation_score >= 5 + ? "bg-gradient-to-r from-amber-500 to-amber-400" + : "bg-gradient-to-r from-yellow-500 to-yellow-400", + )} + style={{ width: `${(user.violation_score / maxScore) * 100}%` }} + /> +
+ {user.violation_score} +
+
+
+
+
+ ); +} + +import { cn } from "../../lib/utils"; + +function LoadingBox() { + return ( + + + + Memuat data... + + + ); +} diff --git a/frontend/src/components/analytics/index.tsx b/frontend/src/components/analytics/index.tsx new file mode 100644 index 0000000..2404b64 --- /dev/null +++ b/frontend/src/components/analytics/index.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import type { Channel, Guild } from "../../types/voice"; +import { useAnalytics } from "../../hooks/useAnalytics"; +import { ControlBar } from "./ControlBar"; +import { SummaryCards } from "./SummaryCards"; +import { ActivityChart } from "./ActivityChart"; +import { TrendChart } from "./TrendChart"; +import { Heatmap } from "./Heatmap"; +import { TopicList } from "./TopicList"; +import { UserTable } from "./UserTable"; +import { ViolatorTable } from "./ViolatorTable"; + +interface AnalyticsPanelProps { + guilds: Guild[]; + channels: Channel[]; + selectedGuild: string; + selectedChannel: string; + onGuildChange: (guildId: string) => void; + onChannelChange: (channelId: string) => void; +} + +export function AnalyticsPanel({ + guilds, + channels, + selectedGuild, + selectedChannel, + onGuildChange, + onChannelChange, +}: AnalyticsPanelProps) { + const [hours, setHours] = useState(24); + + const { + messages, + hourly, + topics, + topUsers, + activeUsersCount, + totalChannels, + violators, + trend, + heatmap, + isLoading, + isFetching, + error, + refresh, + refreshViolators, + } = useAnalytics({ guildId: selectedGuild, channelId: selectedChannel || undefined, hours }); + + const loading = isLoading && !isFetching; + + if (error && !messages) { + return ( +
+ {error} +
+ ); + } + + if (!selectedGuild) { + return ( +
+

Pilih guild untuk melihat analitik.

+
+ ); + } + + return ( +
+ {/* Control bar */} + { refresh(); refreshViolators(); }} + /> + + {/* Summary cards */} + + + {/* Hourly chart */} +
+ +
+ +
+
+ + {/* Trend chart — only show when enough data */} + {hours >= 48 && ( + + )} + + {/* Heatmap + leaderboard */} +
+ +
+ +
+
+ + {/* Violators */} + +
+ ); +} diff --git a/frontend/src/hooks/useAnalytics.ts b/frontend/src/hooks/useAnalytics.ts index c210722..48c63bd 100644 --- a/frontend/src/hooks/useAnalytics.ts +++ b/frontend/src/hooks/useAnalytics.ts @@ -3,11 +3,15 @@ import { useCallback, useEffect } from "react"; import { fetchAnalyticsOverview, fetchViolators, + fetchTrend, + fetchHeatmap, type AnalyticsOverview, type HourlyBucket, type TopicTrend, type UserStat, type ViolatorStat, + type TrendBucket, + type HeatmapCell, } from "../api/analytics"; interface UseAnalyticsOptions { @@ -21,6 +25,8 @@ function analyticsKeys(guildId: string, channelId: string | undefined, hours: nu return { overview: ["analytics", "overview", guildId, channelId ?? "", hours] as const, violators: ["analytics", "violators", guildId, channelId ?? "", hours] as const, + trend: ["analytics", "trend", guildId, channelId ?? "", hours] as const, + heatmap: ["analytics", "heatmap", guildId, channelId ?? "", hours] as const, all: ["analytics"] as const, }; } @@ -34,8 +40,8 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt queryKey: keys.overview, queryFn: () => fetchAnalyticsOverview({ guildId, channelId, hours }), enabled: !!guildId, - staleTime: 30_000, // 30s — data is fresh enough; WebSocket invalidates on change - placeholderData: keepPreviousData, // show previous data while fetching new params + staleTime: 30_000, + placeholderData: keepPreviousData, }); // ── Violators query ────────────────────────────────────────────────── @@ -48,12 +54,32 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt placeholderData: keepPreviousData, }); + // ── Trend query ────────────────────────────────────────────────────── + const trendQuery = useQuery({ + queryKey: keys.trend, + queryFn: () => fetchTrend({ guildId, channelId, hours }), + enabled: !!guildId, + staleTime: 60_000, + placeholderData: keepPreviousData, + }); + + // ── Heatmap query ──────────────────────────────────────────────────── + const heatmapQuery = useQuery({ + queryKey: keys.heatmap, + queryFn: () => fetchHeatmap({ guildId, channelId, hours }), + enabled: !!guildId, + staleTime: 60_000, + placeholderData: keepPreviousData, + }); + // ── Refresh: invalidate & refetch ──────────────────────────────────── const refresh = useCallback(() => { if (!guildId) return; queryClient.invalidateQueries({ queryKey: keys.overview }); queryClient.invalidateQueries({ queryKey: keys.violators }); - }, [queryClient, keys.overview, keys.violators, guildId]); + queryClient.invalidateQueries({ queryKey: keys.trend }); + queryClient.invalidateQueries({ queryKey: keys.heatmap }); + }, [queryClient, keys, guildId]); // Real-time refresh via WebSocket-triggered custom event useEffect(() => { @@ -68,9 +94,7 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt return { overview, - // isLoading = true only on first load with no cached data isLoading, - // isFetching = true on background refetch while showing stale data isFetching, error: overviewQuery.error instanceof Error ? overviewQuery.error.message : null, refresh, @@ -83,7 +107,17 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt if (guildId) queryClient.invalidateQueries({ queryKey: keys.violators }); }, - // Convenience accessors (safe navigation into nullable overview) + // Trend + trend: trendQuery.data ?? [], + trendLoading: trendQuery.isLoading && !trendQuery.data, + trendFetching: trendQuery.isFetching && !trendQuery.isLoading, + + // Heatmap + heatmap: heatmapQuery.data ?? [], + heatmapLoading: heatmapQuery.isLoading && !heatmapQuery.data, + heatmapFetching: heatmapQuery.isFetching && !heatmapQuery.isLoading, + + // Convenience accessors hourly: overview?.hourly ?? ([] as HourlyBucket[]), topics: overview?.topics ?? ([] as TopicTrend[]), topUsers: overview?.top_users ?? ([] as UserStat[]), @@ -95,4 +129,4 @@ export function useAnalytics({ guildId, channelId, hours = 24 }: UseAnalyticsOpt } // Re-export for convenience -export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat }; +export type { AnalyticsOverview, HourlyBucket, TopicTrend, UserStat, ViolatorStat, TrendBucket, HeatmapCell }; diff --git a/package.json b/package.json index f6a3c15..eb0e0eb 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "prom-client": "^15.1.3", "react": "^19.2.6", "react-dom": "^19.2.6", + "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", "vite": "^8.0.13", "winston": "^3.19.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 795084e..bf2841c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -1573,6 +1576,17 @@ packages: '@types/react': optional: true + '@reduxjs/toolkit@2.12.0': + resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.1': resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1841,6 +1855,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -1960,6 +1977,33 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -2037,6 +2081,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2478,6 +2525,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + date-fns@2.30.0: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} @@ -2512,6 +2603,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-uri-component@0.4.1: resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} engines: {node: '>=14.16'} @@ -2736,6 +2830,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2827,6 +2924,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -3152,6 +3252,12 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3181,6 +3287,10 @@ packages: int64-buffer@1.1.0: resolution: {integrity: sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + ip@2.0.1: resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} @@ -4091,6 +4201,18 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -4110,6 +4232,14 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -4134,6 +4264,14 @@ packages: resolution: {integrity: sha512-zQv5y/cf85sxvdrKPlfcRzlDn/OqKFThNimYmsS3flmkioKvkUGn2Qg9cJVoQiEvdxFGLE0MQER/9fZ9sUqdxg==} engines: {node: '>=0.10.0'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4151,6 +4289,9 @@ packages: requizzle@0.2.4: resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4509,6 +4650,9 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4719,6 +4863,11 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4736,6 +4885,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6005,6 +6157,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.1': optional: true @@ -6174,6 +6338,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -6275,6 +6441,30 @@ snapshots: dependencies: '@types/node': 25.8.0 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -6361,6 +6551,8 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.8.0 @@ -6793,6 +6985,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + date-fns@2.30.0: dependencies: '@babel/runtime': 7.29.2 @@ -6826,6 +7056,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + decode-uri-component@0.4.1: {} decompress-response@6.0.0: @@ -6958,6 +7190,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es-toolkit@1.47.0: {} + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -7143,6 +7377,8 @@ snapshots: etag@1.8.1: {} + eventemitter3@5.0.4: {} + execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -7514,6 +7750,10 @@ snapshots: ignore@5.3.2: {} + immer@10.2.0: {} + + immer@11.1.8: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7550,6 +7790,8 @@ snapshots: int64-buffer@1.1.0: {} + internmap@2.0.3: {} + ip@2.0.1: {} ipaddr.js@1.9.1: {} @@ -8386,6 +8628,15 @@ snapshots: react-is@18.3.1: {} + react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + react@19.2.6: {} read-pkg-up@7.0.1: @@ -8417,6 +8668,26 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 18.3.1 + react-redux: 9.3.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.6) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -8436,6 +8707,12 @@ snapshots: dependencies: test-value: 2.1.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} regexpp@2.0.1: {} @@ -8448,6 +8725,8 @@ snapshots: dependencies: lodash: 4.18.1 + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8854,6 +9133,8 @@ snapshots: thunky@1.1.0: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -9020,6 +9301,10 @@ snapshots: url-join@5.0.0: {} + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + util-deprecate@1.0.2: {} v8-compile-cache@2.4.0: {} @@ -9033,6 +9318,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/src/moderation/analyticsStore.ts b/src/moderation/analyticsStore.ts index 7a1dc38..d307177 100644 --- a/src/moderation/analyticsStore.ts +++ b/src/moderation/analyticsStore.ts @@ -1,6 +1,6 @@ +import { config } from "../config.js"; import { executeAll, executeGet } from "../database/drizzle.js"; import { createChildLogger } from "../logger.js"; -import { config } from "../config.js"; import type { MessageRecord } from "./types.js"; const logger = createChildLogger("analytics-store"); @@ -130,15 +130,19 @@ export async function getHourlyStats(input: { GROUP BY (created_at / 3600000) ORDER BY hour ASC `, - channelId - ? [guildId, since, channelId, channelId] - : [guildId, since], + channelId ? [guildId, since, channelId, channelId] : [guildId, since], ); // Initialize all hour buckets (fill gaps with zeros) const buckets = new Map< string, - { count: number; clean: number; warned: number; flagged: number; error: number } + { + count: number; + clean: number; + warned: number; + flagged: number; + error: number; + } >(); for (let h = 0; h < hours; h++) { @@ -178,25 +182,156 @@ export async function getHourlyStats(input: { // ── Topic Trends ─────────────────────────────────────────────────────── const STOP_WORDS = new Set([ - "yang", "dan", "itu", "ini", "dengan", "akan", "pada", "dari", "di", "ke", - "untuk", "tidak", "ada", "juga", "sudah", "saya", "kamu", "dia", "mereka", - "kami", "aku", "lo", "lu", "gua", "gue", "org", "orang", "aja", "sama", - "kalo", "kalau", "bisa", "karena", "gak", "nggak", "ga", "tak", "belum", - "udah", "dah", "lah", "kah", "pun", "nih", "tuh", "deh", "dong", "si", - "nya", "kan", "ya", "yah", "yuk", "kok", "loh", "nah", "wow", "eh", - "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", - "have", "has", "had", "having", "do", "does", "did", "doing", - "will", "would", "could", "should", "may", "might", "must", "shall", - "i", "you", "he", "she", "it", "we", "they", - "me", "him", "her", "us", "them", - "my", "your", "his", "its", "our", "their", - "and", "but", "or", "nor", "not", "so", "yet", "for", "if", - "to", "of", "in", "on", "at", "by", "as", "with", - "about", "just", "then", "now", "here", "there", - "when", "where", "why", "how", - "all", "both", "each", "few", "more", "most", "other", - "some", "such", "only", "own", "same", "too", "very", - "can", "go", "ok", "okay", "yeah", "yes", "no", + "yang", + "dan", + "itu", + "ini", + "dengan", + "akan", + "pada", + "dari", + "di", + "ke", + "untuk", + "tidak", + "ada", + "juga", + "sudah", + "saya", + "kamu", + "dia", + "mereka", + "kami", + "aku", + "lo", + "lu", + "gua", + "gue", + "org", + "orang", + "aja", + "sama", + "kalo", + "kalau", + "bisa", + "karena", + "gak", + "nggak", + "ga", + "tak", + "belum", + "udah", + "dah", + "lah", + "kah", + "pun", + "nih", + "tuh", + "deh", + "dong", + "si", + "nya", + "kan", + "ya", + "yah", + "yuk", + "kok", + "loh", + "nah", + "wow", + "eh", + "the", + "a", + "an", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "having", + "do", + "does", + "did", + "doing", + "will", + "would", + "could", + "should", + "may", + "might", + "must", + "shall", + "i", + "you", + "he", + "she", + "it", + "we", + "they", + "me", + "him", + "her", + "us", + "them", + "my", + "your", + "his", + "its", + "our", + "their", + "and", + "but", + "or", + "nor", + "not", + "so", + "yet", + "for", + "if", + "to", + "of", + "in", + "on", + "at", + "by", + "as", + "with", + "about", + "just", + "then", + "now", + "here", + "there", + "when", + "where", + "why", + "how", + "all", + "both", + "each", + "few", + "more", + "most", + "other", + "some", + "such", + "only", + "own", + "same", + "too", + "very", + "can", + "go", + "ok", + "okay", + "yeah", + "yes", + "no", ]); function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { @@ -232,7 +367,10 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { existing.count++; existing.score += msg.ai_moderation_score || 0; } else { - topicScores.set(cat, { count: 1, score: msg.ai_moderation_score || 0 }); + topicScores.set(cat, { + count: 1, + score: msg.ai_moderation_score || 0, + }); } } } catch { @@ -267,7 +405,11 @@ function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { for (const [word, count] of sortedWords) { if (!topicScores.has(word)) { - results.push({ topic: word, count, score: flaggedWordFreq.get(word) || 0 }); + results.push({ + topic: word, + count, + score: flaggedWordFreq.get(word) || 0, + }); } } @@ -289,7 +431,7 @@ export async function getTopicTrends(input: { // Only fetch messages that have ai_analysis (the ones that actually have topics) // This dramatically reduces rows for large guilds - const rows = await executeAll( + const rows = (await executeAll( ` SELECT id, content, ai_status, ai_analysis, ai_moderation_score, @@ -303,10 +445,8 @@ export async function getTopicTrends(input: { ORDER BY created_at DESC LIMIT 2000 `, - channelId - ? [guildId, since, channelId, channelId] - : [guildId, since], - ) as MessageRecord[]; + channelId ? [guildId, since, channelId, channelId] : [guildId, since], + )) as MessageRecord[]; const result = extractTopics(rows); setCache(cacheKey, result, TOPIC_CACHE_TTL_MS); @@ -329,7 +469,12 @@ export async function getUserLeaderboard(input: { limit?: number; }): Promise { const { guildId, channelId, hours = 24, limit = 20 } = input; - const cacheKey = makeCacheKey("leaderboard", { guildId, channelId, hours, limit }); + const cacheKey = makeCacheKey("leaderboard", { + guildId, + channelId, + hours, + limit, + }); const cached = getCached(cacheKey); if (cached) return cached; @@ -408,9 +553,7 @@ export async function getModerationStats(input: { AND deleted_at IS NULL ${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""} `, - channelId - ? [guildId, since, channelId, channelId] - : [guildId, since], + channelId ? [guildId, since, channelId, channelId] : [guildId, since], ); const result: ModerationBreakdown = row @@ -423,7 +566,15 @@ export async function getModerationStats(input: { pending: row.pending ?? 0, average_score: row.average_score ?? 0, } - : { total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0 }; + : { + total: 0, + clean: 0, + warned: 0, + flagged: 0, + error: 0, + pending: 0, + average_score: 0, + }; setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS); return result; @@ -432,7 +583,15 @@ export async function getModerationStats(input: { { error: error instanceof Error ? error.message : String(error) }, "Failed to get moderation stats", ); - return { total: 0, clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, average_score: 0 }; + return { + total: 0, + clean: 0, + warned: 0, + flagged: 0, + error: 0, + pending: 0, + average_score: 0, + }; } } @@ -493,7 +652,12 @@ export async function getTopViolators(input: { limit?: number; }): Promise { const { guildId, channelId, hours = 24, limit = 20 } = input; - const cacheKey = makeCacheKey("violators", { guildId, channelId, hours, limit }); + const cacheKey = makeCacheKey("violators", { + guildId, + channelId, + hours, + limit, + }); const cached = getCached(cacheKey); if (cached) return cached; @@ -551,6 +715,192 @@ export async function getTopViolators(input: { } } +// ── Daily Trend (for multi-day line chart) ──────────────────────────── + +export interface TrendBucket { + date: string; + count: number; + clean: number; + warned: number; + flagged: number; + error: number; +} + +export async function getDailyTrend(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const { guildId, channelId, hours = 168 } = input; + const cacheKey = makeCacheKey("daily_trend", { guildId, channelId, hours }); + const cached = getCached(cacheKey); + if (cached) return cached; + + try { + const since = Date.now() - hours * 3600_000; + const isPg = config.DATABASE_TYPE === "postgres"; + + const dateExpr = isPg + ? `to_char(date_trunc('day', to_timestamp(created_at / 1000)), 'YYYY-MM-DD') as date` + : `date(created_at / 1000, 'unixepoch') as date`; + + const rows = await executeAll( + ` + SELECT + ${dateExpr}, + count(*) as count, + count(case when ai_status = 'clean' then 1 end) as clean, + count(case when ai_status = 'warn' then 1 end) as warned, + count(case when ai_status = 'flagged' then 1 end) as flagged, + count(case when ai_status = 'error' then 1 end) as error + FROM messages + WHERE guild_id = ? + AND created_at >= ? + AND deleted_at IS NULL + ${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""} + GROUP BY date(created_at / 1000, 'unixepoch') + ORDER BY date ASC + `, + channelId ? [guildId, since, channelId, channelId] : [guildId, since], + ); + + // Initialize all day buckets (fill gaps with zeros) + const buckets = new Map< + string, + { + count: number; + clean: number; + warned: number; + flagged: number; + error: number; + } + >(); + const msPerDay = 86400_000; + const startDay = Math.floor(since / msPerDay) * msPerDay; + const endDay = Math.floor(Date.now() / msPerDay) * msPerDay; + + for (let d = startDay; d <= endDay; d += msPerDay) { + const key = new Date(d).toISOString().slice(0, 10); + buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 }); + } + + for (const row of rows) { + const bucket = buckets.get(row.date); + if (!bucket) continue; + bucket.count = row.count; + bucket.clean = row.clean; + bucket.warned = row.warned; + bucket.flagged = row.flagged; + bucket.error = row.error; + } + + const result = Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, data]) => ({ date, ...data })); + + setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS); + return result; + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get daily trend", + ); + return []; + } +} + +// ── Activity Heatmap (day-of-week × hour-of-day) ────────────────────── + +export interface HeatmapCell { + dayOfWeek: number; // 0=Senin, 6=Minggu + hour: number; // 0-23 + count: number; + clean: number; + warned: number; + flagged: number; +} + +export async function getActivityHeatmap(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const { guildId, channelId, hours = 168 } = input; + const cacheKey = makeCacheKey("heatmap", { guildId, channelId, hours }); + const cached = getCached(cacheKey); + if (cached) return cached; + + try { + const since = Date.now() - hours * 3600_000; + const isPg = config.DATABASE_TYPE === "postgres"; + + // SQLite: cast to int for modulo; Postgres: use extract() + const dayExpr = isPg + ? `(extract(isodow from to_timestamp(created_at / 1000)) % 7)::int as day_of_week` + : `(cast((created_at / 86400000) as integer) % 7) as day_of_week`; + const hourExpr = isPg + ? `extract(hour from to_timestamp(created_at / 1000))::int as hour` + : `(cast((created_at / 3600000) as integer) % 24) as hour`; + + const rows = await executeAll( + ` + SELECT + ${dayExpr}, + ${hourExpr}, + count(*) as count, + count(case when ai_status = 'clean' then 1 end) as clean, + count(case when ai_status = 'warn' then 1 end) as warned, + count(case when ai_status = 'flagged' then 1 end) as flagged + FROM messages + WHERE guild_id = ? + AND created_at >= ? + AND deleted_at IS NULL + ${channelId ? `AND (channel_id = ? OR thread_id = ?)` : ""} + GROUP BY day_of_week, hour + ORDER BY day_of_week, hour + `, + channelId ? [guildId, since, channelId, channelId] : [guildId, since], + ); + + // Initialize all 7×24 cells with zeros + const cells = new Map< + string, + { count: number; clean: number; warned: number; flagged: number } + >(); + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + cells.set(`${d}-${h}`, { count: 0, clean: 0, warned: 0, flagged: 0 }); + } + } + + for (const row of rows) { + const key = `${row.day_of_week}-${row.hour}`; + const cell = cells.get(key); + if (!cell) continue; + cell.count = row.count; + cell.clean = row.clean; + cell.warned = row.warned; + cell.flagged = row.flagged; + } + + const result = Array.from(cells.entries()) + .map(([key, data]) => { + const [dayOfWeek, hour] = key.split("-").map(Number); + return { dayOfWeek, hour, ...data }; + }) + .sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.hour - b.hour); + + setCache(cacheKey, result, AGGREGATE_CACHE_TTL_MS); + return result; + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get activity heatmap", + ); + return []; + } +} + // ── Cache Invalidation (called when new messages arrive) ─────────────── export function invalidateAnalyticsCache(guildId: string): void { @@ -574,13 +924,15 @@ export async function getAnalyticsOverview(input: { const now = Date.now(); const since = now - hours * 3600_000; - const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all([ - getModerationStats(input), - getHourlyStats(input), - getTopicTrends(input), - getUserLeaderboard(input), - getActiveChannelCount({ guildId, hours }), - ]); + const [messages, hourly, topics, topUsers, totalChannels] = await Promise.all( + [ + getModerationStats(input), + getHourlyStats(input), + getTopicTrends(input), + getUserLeaderboard(input), + getActiveChannelCount({ guildId, hours }), + ], + ); return { period: { start: since, end: now }, diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index 244e7dd..e33b0df 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -1,7 +1,9 @@ import axios from "axios"; +import OpenAI from "openai"; import { config } from "../config.js"; import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js"; import { createChildLogger } from "../logger.js"; +import { retryWithBackoff } from "../retry.js"; const log = createChildLogger("indonesianTextNormalizer"); @@ -36,6 +38,46 @@ const CATEGORY_TO_BADWORD_LABEL: Record = { insult: "harassment", }; +const VALID_PRIMARY_AI_FLAGS = new Set([ + "spam", + "hate_speech", + "sara", + "hoaks", + "harassment", + "vulgar_language", + "sexual_content", + "sexual_deviation", + "violence", + "self_harm", + "doxxing", + "scam", + "misinformation", + "nsfw_image", + "gore_image", + "illegal_content", + "gambling", + "drugs", + "child_safety", + "financial_scam", + "religious_insult", + "self_promo", +]); + +const BADWORD_CACHE_TTL_MS = 10 * 60 * 1000; +const NEMOTRON_RATE_LIMIT_COOLDOWN_MS = 60 * 1000; +const PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS = 30 * 1000; + +interface BadwordCacheEntry { + value: string[]; + expiresAt: number; +} + +const badwordCache = new Map(); +const inFlightBadwordLookups = new Map>(); +let nemotronUnavailableUntil = 0; +let primaryAiUnavailableUntil = 0; +let primaryModerationClient: OpenAI | null = null; + export interface ModerationTextEvidence { raw: string; normalized: string; @@ -159,6 +201,174 @@ function detectLocalBadwords(text: string): string[] { return Array.from(new Set(hits)); } +function normalizeBadwordCacheKey(text: string): string { + return text.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function getCachedBadwords(key: string): string[] | null { + const entry = badwordCache.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + badwordCache.delete(key); + return null; + } + return [...entry.value]; +} + +function setCachedBadwords(key: string, value: string[]): void { + badwordCache.set(key, { + value: [...new Set(value)], + expiresAt: Date.now() + BADWORD_CACHE_TTL_MS, + }); + + if (badwordCache.size > 500) { + const now = Date.now(); + for (const [cacheKey, entry] of badwordCache) { + if (entry.expiresAt <= now) { + badwordCache.delete(cacheKey); + } + } + + if (badwordCache.size > 500) { + const oldestKeys = Array.from(badwordCache.entries()) + .sort((a, b) => a[1].expiresAt - b[1].expiresAt) + .slice(0, badwordCache.size - 500) + .map(([cacheKey]) => cacheKey); + for (const cacheKey of oldestKeys) { + badwordCache.delete(cacheKey); + } + } + } +} + +function getPrimaryModerationClient(): OpenAI | null { + if (!config.AI_LLM_API_KEY) { + return null; + } + + if (!primaryModerationClient) { + primaryModerationClient = new OpenAI({ + apiKey: config.AI_LLM_API_KEY, + baseURL: config.AI_LLM_BASE_URL, + maxRetries: 0, + timeout: 15000, + }); + } + + return primaryModerationClient; +} + +function normalizePrimaryAiFlag(value: string): string | null { + const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (!lower) return null; + + if (VALID_PRIMARY_AI_FLAGS.has(lower)) { + return lower; + } + + return CATEGORY_TO_BADWORD_LABEL[lower] ?? null; +} + +function extractFlagsFromPrimaryAiContent(content: string): string[] { + const flags = new Set(); + let parsed: unknown; + + try { + parsed = JSON.parse(content); + } catch { + parsed = null; + } + + const addValue = (value: unknown) => { + if (typeof value !== "string") return; + const normalized = normalizePrimaryAiFlag(value); + if (normalized) flags.add(normalized); + }; + + if (Array.isArray(parsed)) { + for (const item of parsed) { + addValue(item); + } + } else if (parsed && typeof parsed === "object") { + const candidate = parsed as Record; + for (const key of ["flags", "categories", "badwords"]) { + const value = candidate[key]; + if (Array.isArray(value)) { + for (const item of value) addValue(item); + } else { + addValue(value); + } + } + } + + if (flags.size > 0) { + return Array.from(flags); + } + + const lowerContent = content.toLowerCase(); + for (const flag of VALID_PRIMARY_AI_FLAGS) { + if (lowerContent.includes(flag)) { + flags.add(flag); + } + } + + for (const category of Object.keys(CATEGORY_TO_BADWORD_LABEL)) { + if (lowerContent.includes(category)) { + const mapped = CATEGORY_TO_BADWORD_LABEL[category]; + if (mapped) flags.add(mapped); + } + } + + return Array.from(flags); +} + +async function callPrimaryAiModeration(text: string): Promise { + const client = getPrimaryModerationClient(); + if (!client) { + return []; + } + + const completion = await retryWithBackoff( + async () => { + return client.chat.completions.create({ + model: config.AI_LLM_MODEL, + messages: [ + { + role: "user", + content: + "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + + "Balas hanya JSON object dengan format {\"flags\":[...]} dan gunakan hanya flag valid ini: " + + Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + + ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + + text, + }, + ], + temperature: 0.1, + top_p: 0.9, + max_tokens: 200, + stream: false, + response_format: { type: "json_object" }, + chat_template_kwargs: { enable_thinking: false }, + reasoning_budget: 0, + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming); + }, + { + retries: 1, + minTimeout: 500, + maxTimeout: 2000, + factor: 2, + logger: log, + }, + ); + + const content = completion.choices[0]?.message?.content?.trim(); + if (!content) { + return []; + } + + return extractFlagsFromPrimaryAiContent(content); +} + // --------------------------------------------------------------------------- // NVIDIA Nemotron-3 Content Safety API // --------------------------------------------------------------------------- @@ -236,25 +446,81 @@ async function callNemotronContentSafety(text: string): Promise { export async function detectIndonesianBadwords( text: string, ): Promise { - // Always run local detection first (fast, no network dependency) - const localHits = detectLocalBadwords(text); - - // Try NVIDIA API if key is configured - const apiKey = config.NVIDIA_NEMOTRON_API_KEY; - if (apiKey) { - try { - const apiCategories = await callNemotronContentSafety(text); - const allHits = Array.from(new Set([...localHits, ...apiCategories])); - return allHits; - } catch (error) { - log.warn( - { error }, - "NVIDIA Nemotron API call failed, falling back to local detection", - ); - } + const cacheKey = normalizeBadwordCacheKey(text); + const cached = getCachedBadwords(cacheKey); + if (cached) { + return cached; } - return localHits; + const inFlight = inFlightBadwordLookups.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const lookupPromise = (async () => { + // Always run local detection first (fast, no network dependency) + const localHits = detectLocalBadwords(text); + + // If we already have explicit local badword hits, avoid unnecessary API calls. + if (localHits.length > 0) { + setCachedBadwords(cacheKey, localHits); + return localHits; + } + + const hits = new Set(localHits); + + // Try NVIDIA API if key is configured and it is not rate limited. + const apiKey = config.NVIDIA_NEMOTRON_API_KEY; + if (apiKey && Date.now() >= nemotronUnavailableUntil) { + try { + const apiCategories = await callNemotronContentSafety(text); + for (const hit of apiCategories) { + hits.add(hit); + } + } catch (error) { + const status = axios.isAxiosError(error) ? error.response?.status : null; + if (status === 429) { + nemotronUnavailableUntil = Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS; + } + log.warn( + { error }, + "NVIDIA Nemotron API call failed, falling back to primary AI then local detection", + ); + } + } + + // Try the main AI model next, mirroring the image-analysis fallback path. + if (hits.size === 0 && Date.now() >= primaryAiUnavailableUntil) { + try { + const primaryHits = await callPrimaryAiModeration(text); + for (const hit of primaryHits) { + hits.add(hit); + } + } catch (error) { + const status = axios.isAxiosError(error) ? error.response?.status : null; + if (status === 429) { + primaryAiUnavailableUntil = + Date.now() + PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS; + } + log.warn( + { error }, + "Primary AI badword detection failed, falling back to local detection", + ); + } + } + + const finalHits = Array.from(hits); + setCachedBadwords(cacheKey, finalHits); + return finalHits; + })(); + + inFlightBadwordLookups.set(cacheKey, lookupPromise); + + try { + return await lookupPromise; + } finally { + inFlightBadwordLookups.delete(cacheKey); + } } // --------------------------------------------------------------------------- diff --git a/src/routes/analyticsRoutes.ts b/src/routes/analyticsRoutes.ts index 91d68b2..63994d0 100644 --- a/src/routes/analyticsRoutes.ts +++ b/src/routes/analyticsRoutes.ts @@ -2,11 +2,13 @@ import type { Router } from "express"; import express from "express"; import { AppError } from "../errors.js"; import { + getActivityHeatmap, getAnalyticsOverview, + getDailyTrend, getHourlyStats, getModerationStats, - getTopViolators, getTopicTrends, + getTopViolators, getUserLeaderboard, } from "../moderation/analyticsStore.js"; @@ -211,5 +213,69 @@ export function createAnalyticsRoutes(): Router { } }); + // GET /api/analytics/trend - Daily trend data (for line chart) + // Query params: guildId (required), channelId, hours (default 168) + router.get("/analytics/trend", async (req, res, next) => { + try { + const { guildId, channelId, hours } = req.query as { + guildId?: string; + channelId?: string; + hours?: string; + }; + + if (!guildId) { + throw new AppError( + "guildId query parameter is required", + "MISSING_GUILD_ID", + 400, + ); + } + + const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168; + + const trend = await getDailyTrend({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(trend); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/heatmap - Activity heatmap (day × hour) + // Query params: guildId (required), channelId, hours (default 168) + router.get("/analytics/heatmap", async (req, res, next) => { + try { + const { guildId, channelId, hours } = req.query as { + guildId?: string; + channelId?: string; + hours?: string; + }; + + if (!guildId) { + throw new AppError( + "guildId query parameter is required", + "MISSING_GUILD_ID", + 400, + ); + } + + const hoursNum = hours ? Math.min(parseInt(hours) || 168, 720) : 168; + + const heatmap = await getActivityHeatmap({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(heatmap); + } catch (error) { + next(error); + } + }); + return router; } diff --git a/tests/moderation/conversationContext.test.ts b/tests/moderation/conversationContext.test.ts index a1c9689..a063e94 100644 --- a/tests/moderation/conversationContext.test.ts +++ b/tests/moderation/conversationContext.test.ts @@ -1,4 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/moderation/indonesianTextNormalizer.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + formatModerationTextEvidenceForPrompt: vi.fn(async (content: string) => { + // Deterministic mock evidence — length tuned for the "tight budget" test: + // maxTokens=300, target ~88, c3 ~108, c2 ~108, c1 ~108 + // Expectation: target+c3 fits (196), target+c3+c2 overflows (304) + return "[text_evidence] categories=[\"offensive\",\"profanity\",\"sexual_violence\"] severity=high confidence=0.92 language=id detected=badword normalized=false metadata_v2=true context=true"; + }), + }; +}); + import { buildConversationContext, estimateTokens, @@ -6,6 +20,10 @@ import { } from "../../src/moderation/conversationContext"; import type { MessageRecord } from "../../src/moderation/types"; +beforeEach(() => { + vi.clearAllMocks(); +}); + function message( id: string, content: string, diff --git a/tests/moderation/indonesianTextNormalizer.remote.test.ts b/tests/moderation/indonesianTextNormalizer.remote.test.ts new file mode 100644 index 0000000..9ce91f4 --- /dev/null +++ b/tests/moderation/indonesianTextNormalizer.remote.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { config } from "../../src/config"; + +const mocks = vi.hoisted(() => ({ + axiosPost: vi.fn(), + openaiCreate: vi.fn(), +})); + +vi.mock("axios", () => ({ + default: { + post: mocks.axiosPost, + isAxiosError: (error: unknown) => + Boolean( + error && + typeof error === "object" && + "isAxiosError" in error && + (error as { isAxiosError?: unknown }).isAxiosError, + ), + }, +})); + +vi.mock("openai", () => ({ + default: class MockOpenAI { + chat = { + completions: { + create: mocks.openaiCreate, + }, + }; + }, +})); + +describe("detectIndonesianBadwords remote fallback", () => { + beforeEach(() => { + mocks.axiosPost.mockReset(); + mocks.openaiCreate.mockReset(); + config.NVIDIA_NEMOTRON_API_KEY = "test-nemotron-key"; + config.AI_LLM_API_KEY = "test-primary-key"; + }); + + it("falls back to primary AI after Nemotron rate limits and caches the result", async () => { + mocks.axiosPost.mockRejectedValue({ + isAxiosError: true, + response: { status: 429 }, + message: "Too Many Requests", + }); + mocks.openaiCreate.mockResolvedValue({ + choices: [ + { + message: { + content: JSON.stringify({ flags: ["harassment"] }), + }, + }, + ], + }); + + const { detectIndonesianBadwords } = await import( + "../../src/moderation/indonesianTextNormalizer" + ); + + const first = await detectIndonesianBadwords("squad jump soalnya"); + const second = await detectIndonesianBadwords("squad jump soalnya"); + + expect(first).toEqual(["harassment"]); + expect(second).toEqual(["harassment"]); + expect(mocks.axiosPost).toHaveBeenCalledTimes(1); + expect(mocks.openaiCreate).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/tests/moderation/indonesianTextNormalizer.test.ts b/tests/moderation/indonesianTextNormalizer.test.ts index c6894b1..2606615 100644 --- a/tests/moderation/indonesianTextNormalizer.test.ts +++ b/tests/moderation/indonesianTextNormalizer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; import { buildModerationTextEvidence, detectIndonesianBadwords, @@ -6,6 +6,26 @@ import { normalizeDiscordCustomEmoji, normalizeIndonesianSlang, } from "../../src/moderation/indonesianTextNormalizer"; +import { config } from "../../src/config"; + +const originalNemotronKey = config.NVIDIA_NEMOTRON_API_KEY; +const originalPrimaryAiKey = config.AI_LLM_API_KEY; + +function disableRemoteModeration(): void { + config.NVIDIA_NEMOTRON_API_KEY = undefined; + config.AI_LLM_API_KEY = undefined; +} + +disableRemoteModeration(); + +afterEach(() => { + disableRemoteModeration(); +}); + +afterAll(() => { + config.NVIDIA_NEMOTRON_API_KEY = originalNemotronKey; + config.AI_LLM_API_KEY = originalPrimaryAiKey; +}); describe("normalizeDiscordCustomEmoji", () => { it("replaces static custom emoji", () => {