diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f2c1a5..6555f4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { Tabs, TabsContent } from "./components/ui/tabs"; import { VoicePanel } from "./components/voice/VoicePanel"; import { RecordingsPanel } from "./components/recordings/RecordingsPanel"; import { AuthOverlay } from "./components/layout/AuthOverlay"; +import { AnalyticsPanel } from "./components/analytics/AnalyticsPanel"; import { useDashboardSocket } from "./hooks/useDashboardSocket"; import { mergeMessages, useMessages } from "./hooks/useMessages"; import { useMediaControl } from "./hooks/useMediaControl"; @@ -183,7 +184,7 @@ export default function App() { await patchUIState({ isListening: true }); }, [isListening, patchUIState]); - const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "review"] as DashboardTab[], []); + const tabs = useMemo(() => ["voice", "media", "messages", "recordings", "analytics", "review"] as DashboardTab[], []); return (
patchUIState({ activeTab: value as DashboardTab })}> -
+
{tabs.map((tab) => ( + ))} +
+ +
+ + + + + {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); + const labels = hourly.map((b) => b.hour.slice(11, 16)); + + 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 */} +
+ {bucket.hour.slice(11, 16)} — {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 { + const diff = Date.now() - 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/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index b16a7e2..649cb1a 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -9,6 +9,7 @@ const titles: Record = { media: "Media Player", messages: "Messages", recordings: "Voice Recordings", + analytics: "Analytics & Insights", review: "Moderation Review", }; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4789618..f856212 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Bot, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react"; +import { Bot, BarChart3, MessageSquare, Music2, ShieldAlert, Volume2, Mic } from "lucide-react"; import type { DashboardTab } from "../../types/ui"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; @@ -8,6 +8,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Volume2 }> { id: "media", label: "Media", icon: Music2 }, { id: "messages", label: "Messages", icon: MessageSquare }, { id: "recordings", label: "Recordings", icon: Mic }, + { id: "analytics", label: "Analytics", icon: BarChart3 }, { id: "review", label: "Review", icon: ShieldAlert }, ]; diff --git a/frontend/src/hooks/useAnalytics.ts b/frontend/src/hooks/useAnalytics.ts new file mode 100644 index 0000000..ae14be3 --- /dev/null +++ b/frontend/src/hooks/useAnalytics.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchAnalyticsOverview, type AnalyticsOverview, type HourlyBucket, type TopicTrend, type UserStat } from "../api/analytics"; + +interface UseAnalyticsOptions { + guildId: string; + channelId?: string; + hours?: number; + autoRefresh?: boolean; + refreshIntervalMs?: number; +} + +export function useAnalytics({ guildId, channelId, hours = 24, autoRefresh = true, refreshIntervalMs = 60_000 }: UseAnalyticsOptions) { + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const intervalRef = useRef | null>(null); + + const load = useCallback(async () => { + if (!guildId) return; + setLoading(true); + setError(null); + try { + const data = await fetchAnalyticsOverview({ guildId, channelId, hours }); + setOverview(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load analytics"); + } finally { + setLoading(false); + } + }, [guildId, channelId, hours]); + + useEffect(() => { + load(); + }, [load]); + + // Auto-refresh + useEffect(() => { + if (!autoRefresh || !guildId) return; + intervalRef.current = setInterval(load, refreshIntervalMs); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [load, autoRefresh, refreshIntervalMs, guildId]); + + return { + overview, + loading, + error, + refresh: load, + // Convenience accessors + hourly: overview?.hourly ?? ([] as HourlyBucket[]), + topics: overview?.topics ?? ([] as TopicTrend[]), + topUsers: overview?.top_users ?? ([] as UserStat[]), + messages: overview?.messages ?? null, + period: overview?.period ?? null, + activeUsersCount: overview?.active_users_count ?? 0, + totalChannels: overview?.total_channels ?? 0, + }; +} diff --git a/frontend/src/types/ui.ts b/frontend/src/types/ui.ts index 9fe6b00..80cf2ec 100644 --- a/frontend/src/types/ui.ts +++ b/frontend/src/types/ui.ts @@ -1,4 +1,4 @@ -export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings"; +export type DashboardTab = "voice" | "media" | "messages" | "review" | "recordings" | "analytics"; export interface UIState { selectedGuild?: string; diff --git a/package.json b/package.json index 04b60ef..172b8dd 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "indonesian-badwords": "^1.0.1", "libsodium-wrappers": "^0.8.4", "lucide-react": "^1.16.0", + "motion": "^12.40.0", "openai": "^6.38.0", "opusscript": "^0.0.8", "p-retry": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb1506..8684284 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 8.20.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(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)) + version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -65,6 +65,9 @@ importers: lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.6) + motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) openai: specifier: ^6.38.0 version: 6.38.0(ws@8.20.1)(zod@4.4.3) @@ -100,7 +103,7 @@ importers: version: 3.6.0 vite: specifier: ^8.0.13 - version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) + version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) winston: specifier: ^3.19.0 version: 3.19.0 @@ -155,7 +158,7 @@ importers: version: 5.9.3 vitest: specifier: latest - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(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)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) vendor/discord-video-stream: dependencies: @@ -2925,6 +2928,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3556,6 +3573,26 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mp4box@0.5.4: resolution: {integrity: sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==} @@ -6266,10 +6303,10 @@ snapshots: dependencies: '@types/node': 25.8.0 - '@vitejs/plugin-react@6.0.2(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))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': dependencies: '@rolldown/pluginutils': 1.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) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) '@vitest/expect@4.1.7': dependencies: @@ -6280,13 +6317,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(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))': + '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - 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) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) '@vitest/pretty-format@4.1.7': dependencies: @@ -7193,6 +7230,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -7794,6 +7840,20 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + mp4box@0.5.4: {} ms@2.1.3: {} @@ -8866,7 +8926,7 @@ snapshots: vary@1.1.2: {} - 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): + vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8879,12 +8939,11 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.2 - yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(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)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(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)) + '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -8901,7 +8960,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - 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) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/http/app.ts b/src/http/app.ts index e869098..ce78485 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -12,6 +12,7 @@ import type { createChildLogger } from "../logger.js"; import type { MediaController } from "../media/mediaController.js"; import type { ModerationBroadcaster } from "../moderation/types.js"; import { createAnalysisRoutes } from "../routes/analysisRoutes.js"; +import { createAnalyticsRoutes } from "../routes/analyticsRoutes.js"; import { createMediaRoutes } from "../routes/mediaRoutes.js"; import { createMessageRoutes } from "../routes/messageRoutes.js"; import { createRecordingsRoutes } from "../routes/recordingsRoutes.js"; @@ -113,6 +114,7 @@ export function createHttpApp(options: CreateHttpAppOptions) { ); app.use("/api", createMessageRoutes()); app.use("/api", createAnalysisRoutes()); + app.use("/api", createAnalyticsRoutes()); app.use("/api", createSyncRoutes(options.client)); app.use("/api", createRecordingsRoutes()); app.use( diff --git a/src/moderation/analyticsStore.ts b/src/moderation/analyticsStore.ts new file mode 100644 index 0000000..431e9cb --- /dev/null +++ b/src/moderation/analyticsStore.ts @@ -0,0 +1,591 @@ +import { and, asc, desc, eq, gte, isNull, or, type SQL } from "drizzle-orm"; +import { getDatabase } from "../database/drizzle.js"; +import { messagesTable } from "../database/schema.js"; +import { createChildLogger } from "../logger.js"; +import type { MessageRecord } from "./types.js"; + +const logger = createChildLogger("analytics-store"); + +// ── DB helper ────────────────────────────────────────────────────────── +function db() { + return getDatabase() as { + select(fields?: Record): { + from(table: unknown): { + where(cond: SQL | undefined): { + orderBy(...cols: unknown[]): { + limit(n: number): Promise; + } & Promise; + groupBy(...cols: unknown[]): Promise; + } & Promise; + limit(n: number): Promise; + } & Promise; + }; + }; +} + +// ── Shared condition helper ──────────────────────────────────────────── +function channelFilter(channelId: string): SQL { + return or( + eq(messagesTable.channel_id, channelId), + eq(messagesTable.thread_id, channelId), + ) as SQL; +} + +// ── Types ────────────────────────────────────────────────────────────── + +export interface HourlyBucket { + hour: string; + count: number; + clean: number; + warned: number; + flagged: number; + error: number; +} + +export interface TopicTrend { + topic: string; + count: number; + score: number; +} + +export interface UserStat { + user_id: string; + username: string; + avatar_url: string | null; + message_count: number; + edited_count: number; + deleted_count: number; + flagged_count: number; + last_active: number; +} + +export interface ModerationBreakdown { + total: number; + clean: number; + warned: number; + flagged: number; + error: number; + pending: number; + average_score: number; +} + +export interface AnalyticsOverview { + period: { start: number; end: number }; + messages: ModerationBreakdown; + hourly: HourlyBucket[]; + topics: TopicTrend[]; + top_users: UserStat[]; + active_users_count: number; + total_channels: number; +} + +// ── Hourly Message Stats ─────────────────────────────────────────────── + +export async function getHourlyStats(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + try { + const { guildId, channelId, hours = 24 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const conditions: SQL[] = [ + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ]; + + if (channelId) { + conditions.push(channelFilter(channelId)); + } + + const rows = (await database + .select() + .from(messagesTable) + .where(and(...conditions) as SQL) + .orderBy(asc(messagesTable.created_at))) as MessageRecord[]; + + // Initialize all hour buckets + const buckets = new Map< + string, + { count: number; clean: number; warned: number; flagged: number; error: number } + >(); + + for (let h = 0; h < hours; h++) { + const ts = new Date(since + h * 3600_000); + ts.setMinutes(0, 0, 0); + const key = ts.toISOString().slice(0, 13) + ":00:00Z"; + buckets.set(key, { count: 0, clean: 0, warned: 0, flagged: 0, error: 0 }); + } + + for (const row of rows) { + const d = new Date(row.created_at); + d.setMinutes(0, 0, 0); + const key = d.toISOString().slice(0, 13) + ":00:00Z"; + + const bucket = buckets.get(key); + if (!bucket) continue; + + bucket.count++; + const status = row.ai_status || "pending"; + if (status === "clean") bucket.clean++; + else if (status === "warn") bucket.warned++; + else if (status === "flagged") bucket.flagged++; + else if (status === "error") bucket.error++; + } + + return Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([hour, data]) => ({ hour, ...data })); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get hourly stats", + ); + return []; + } +} + +// ── 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", +]); + +function extractTopics(messages: MessageRecord[], topN = 15): TopicTrend[] { + const topicScores = new Map(); + const wordFreq = new Map(); + const flaggedWordFreq = new Map(); + + for (const msg of messages) { + if (msg.ai_analysis) { + try { + const analysis = JSON.parse(msg.ai_analysis); + const topics = analysis.topics; + if (topics && Array.isArray(topics)) { + for (const topic of topics) { + const key = typeof topic === "string" ? topic : topic.name || topic.topic; + if (!key) continue; + const k = key.toLowerCase(); + const score = msg.ai_moderation_score || 0; + const existing = topicScores.get(k); + if (existing) { existing.count++; existing.score += score; } + else { topicScores.set(k, { count: 1, score }); } + } + } + if (analysis.category) { + const cat = String(analysis.category).toLowerCase(); + const existing = topicScores.get(cat); + if (existing) { existing.count++; existing.score += msg.ai_moderation_score || 0; } + else { topicScores.set(cat, { count: 1, score: msg.ai_moderation_score || 0 }); } + } + } catch { /* not valid JSON */ } + } + + if (msg.content) { + const words = msg.content + .toLowerCase() + .replace(/[^\w\s]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)); + + for (const word of words) { + wordFreq.set(word, (wordFreq.get(word) || 0) + 1); + if (msg.ai_status === "flagged" || msg.ai_status === "warn") { + flaggedWordFreq.set(word, (flaggedWordFreq.get(word) || 0) + 1); + } + } + } + } + + const results: TopicTrend[] = []; + for (const [topic, data] of topicScores) { + results.push({ topic, count: data.count, score: data.score }); + } + + const sortedWords = Array.from(wordFreq.entries()) + .sort(([, a], [, b]) => b - a) + .slice(0, topN); + + for (const [word, count] of sortedWords) { + if (!topicScores.has(word)) { + results.push({ topic: word, count, score: flaggedWordFreq.get(word) || 0 }); + } + } + + return results.sort((a, b) => b.count - a.count).slice(0, topN); +} + +export async function getTopicTrends(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + try { + const { guildId, channelId, hours = 24 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const conditions: SQL[] = [ + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ]; + + if (channelId) { + conditions.push(channelFilter(channelId)); + } + + const rows = (await database + .select() + .from(messagesTable) + .where(and(...conditions) as SQL) + .orderBy(desc(messagesTable.created_at)) + .limit(1000)) as MessageRecord[]; + + return extractTopics(rows); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get topic trends", + ); + return []; + } +} + +// ── User Leaderboard ──────────────────────────────────────────────────── + +export async function getUserLeaderboard(input: { + guildId: string; + channelId?: string; + hours?: number; + limit?: number; +}): Promise { + try { + const { guildId, channelId, hours = 24, limit = 20 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const conditions: SQL[] = [ + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ]; + + if (channelId) { + conditions.push(channelFilter(channelId)); + } + + const rows = (await database + .select() + .from(messagesTable) + .where(and(...conditions) as SQL) + .orderBy(asc(messagesTable.created_at))) as MessageRecord[]; + + const userMap = new Map(); + + for (const msg of rows) { + const existing = userMap.get(msg.user_id); + if (existing) { + existing.message_count++; + if (msg.type === "edited") existing.edited_count++; + if (msg.type === "deleted") existing.deleted_count++; + if (msg.ai_status === "flagged" || msg.ai_status === "warn") existing.flagged_count++; + if (msg.created_at > existing.last_active) { + existing.last_active = msg.created_at; + } + } else { + userMap.set(msg.user_id, { + user_id: msg.user_id, + username: msg.username, + avatar_url: msg.avatar_url, + message_count: 1, + edited_count: msg.type === "edited" ? 1 : 0, + deleted_count: msg.type === "deleted" ? 1 : 0, + flagged_count: msg.ai_status === "flagged" || msg.ai_status === "warn" ? 1 : 0, + last_active: msg.created_at, + }); + } + } + + return Array.from(userMap.values()) + .sort((a, b) => b.message_count - a.message_count) + .slice(0, limit); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get user leaderboard", + ); + return []; + } +} + +// ── Moderation Stats ─────────────────────────────────────────────────── + +export async function getModerationStats(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + try { + const { guildId, channelId, hours = 24 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const conditions: SQL[] = [ + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ]; + + if (channelId) { + conditions.push(channelFilter(channelId)); + } + + const rows = (await database + .select() + .from(messagesTable) + .where(and(...conditions) as SQL)) as MessageRecord[]; + + const breakdown: ModerationBreakdown = { + total: rows.length, + clean: 0, warned: 0, flagged: 0, error: 0, pending: 0, + average_score: 0, + }; + + let scoreSum = 0; + let scoreCount = 0; + + for (const msg of rows) { + const status = msg.ai_status || "pending"; + if (status === "clean") breakdown.clean++; + else if (status === "warn") breakdown.warned++; + else if (status === "flagged") breakdown.flagged++; + else if (status === "error") breakdown.error++; + else breakdown.pending++; + + if (msg.ai_moderation_score != null) { + scoreSum += msg.ai_moderation_score; + scoreCount++; + } + } + + breakdown.average_score = + scoreCount > 0 ? Math.round((scoreSum / scoreCount) * 100) / 100 : 0; + + return breakdown; + } catch (error) { + logger.error( + { 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, + }; + } +} + +// ── Active Channels Count ────────────────────────────────────────────── + +export async function getActiveChannelCount(input: { + guildId: string; + hours?: number; +}): Promise { + try { + const { guildId, hours = 24 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const rows = (await database + .select({ channel_id: messagesTable.channel_id }) + .from(messagesTable) + .where( + and( + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ) as SQL, + ) + .groupBy(messagesTable.channel_id)) as Array<{ channel_id: string }>; + + return rows.length; + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get active channel count", + ); + return 0; + } +} + +// ── Top Violators ───────────────────────────────────────────────────── + +export interface ViolatorStat { + user_id: string; + username: string; + avatar_url: string | null; + total_messages: number; + flagged_count: number; + warned_count: number; + violation_score: number; // weighted: flagged*3 + warned*1 + worst_flags: string[]; // unique flag types + last_violation: number; +} + +export async function getTopViolators(input: { + guildId: string; + channelId?: string; + hours?: number; + limit?: number; +}): Promise { + try { + const { guildId, channelId, hours = 24, limit = 20 } = input; + const since = Date.now() - hours * 3600_000; + const database = db(); + + const conditions: SQL[] = [ + eq(messagesTable.guild_id, guildId), + gte(messagesTable.created_at, since), + isNull(messagesTable.deleted_at), + ]; + + if (channelId) { + conditions.push(channelFilter(channelId)); + } + + const rows = (await database + .select() + .from(messagesTable) + .where(and(...conditions) as SQL) + .orderBy(asc(messagesTable.created_at))) as MessageRecord[]; + + const userMap = new Map; + last_violation: number; + }>(); + + for (const msg of rows) { + let entry = userMap.get(msg.user_id); + if (!entry) { + entry = { + user_id: msg.user_id, + username: msg.username, + avatar_url: msg.avatar_url, + total_messages: 0, + flagged_count: 0, + warned_count: 0, + flags_set: new Set(), + last_violation: 0, + }; + userMap.set(msg.user_id, entry); + } + + entry.total_messages++; + + const isViolation = msg.ai_status === "flagged" || msg.ai_status === "warn"; + + if (msg.ai_status === "flagged") { + entry.flagged_count++; + } + + if (msg.ai_status === "warn") { + entry.warned_count++; + } + + if (isViolation && msg.ai_moderation_flags) { + try { + const flags = JSON.parse(msg.ai_moderation_flags); + if (Array.isArray(flags)) { + for (const f of flags) entry.flags_set.add(String(f)); + } + } catch { /* ignore */ } + } + + if (isViolation && msg.created_at > entry.last_violation) { + entry.last_violation = msg.created_at; + } + } + + const violators: ViolatorStat[] = []; + + for (const entry of userMap.values()) { + if (entry.flagged_count === 0 && entry.warned_count === 0) continue; + + violators.push({ + user_id: entry.user_id, + username: entry.username, + avatar_url: entry.avatar_url, + total_messages: entry.total_messages, + flagged_count: entry.flagged_count, + warned_count: entry.warned_count, + violation_score: entry.flagged_count * 3 + entry.warned_count * 1, + worst_flags: Array.from(entry.flags_set).slice(0, 5), + last_violation: entry.last_violation, + }); + } + + return violators + .sort((a, b) => b.violation_score - a.violation_score) + .slice(0, limit); + } catch (error) { + logger.error( + { error: error instanceof Error ? error.message : String(error) }, + "Failed to get top violators", + ); + return []; + } +} + +// ── Combined Overview ────────────────────────────────────────────────── + +export async function getAnalyticsOverview(input: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const { guildId, hours = 24 } = 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 }), + ]); + + return { + period: { start: since, end: now }, + messages, + hourly, + topics, + top_users: topUsers, + active_users_count: topUsers.length, + total_channels: totalChannels, + }; +} diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index ede35ca..21f22fd 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -57,7 +57,21 @@ export function detectIndonesianBadwords(text: string): string[] { try { const result = badwords.analyze?.(text); if (Array.isArray(result?.badwords)) { - return Array.from(new Set(result.badwords.map((word) => word.toLowerCase()))); + let hits = Array.from(new Set(result.badwords.map((word) => word.toLowerCase()))); + + const lowerText = text.toLowerCase(); + hits = hits.filter(hit => { + if (hit === "asu") { + const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || []; + return words.some(w => + w.includes("asu") && + !["asus", "masuk", "termasuk", "dimasukkan", "memasukkan", "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan"].includes(w) + ); + } + return true; + }); + + return hits; } } catch { // Keep moderation pipeline resilient if dependency changes shape. diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 15adbda..4d24413 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -667,6 +667,8 @@ Ini adalah server Discord komunitas Indonesia. Kamu harus memahami: - 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. - 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. +- Kata "asus" adalah merk teknologi, jangan pernah dianggap sebagai makian "asu". - 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. - Discord custom emoji seperti <:hadeh:123> atau [emoji:hadeh] adalah ekspresi/emoji, bukan pelanggaran teks. Gunakan sebagai konteks ekspresi saja. diff --git a/src/routes/analyticsRoutes.ts b/src/routes/analyticsRoutes.ts new file mode 100644 index 0000000..55a1853 --- /dev/null +++ b/src/routes/analyticsRoutes.ts @@ -0,0 +1,241 @@ +import type { Router } from "express"; +import express from "express"; +import { AppError } from "../errors.js"; +import { + getAnalyticsOverview, + getHourlyStats, + getModerationStats, + getTopViolators, + getTopicTrends, + getUserLeaderboard, +} from "../moderation/analyticsStore.js"; + +export function createAnalyticsRoutes(): Router { + const router = express.Router(); + + // GET /api/analytics/overview - Full analytics dashboard data + // Query params: guildId (required), channelId, hours (default 24) + router.get("/analytics/overview", 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) || 24, 168) : 24; + + const overview = await getAnalyticsOverview({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(overview); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/hourly - Hourly message stats + // Query params: guildId (required), channelId, hours (default 24) + router.get("/analytics/hourly", 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) || 24, 168) : 24; + + const stats = await getHourlyStats({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(stats); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/topics - Topic trends + // Query params: guildId (required), channelId, hours (default 24) + router.get("/analytics/topics", 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) || 24, 168) : 24; + + const topics = await getTopicTrends({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(topics); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/leaderboard - User leaderboard + // Query params: guildId (required), channelId, hours (default 24), limit (default 20) + router.get("/analytics/leaderboard", async (req, res, next) => { + try { + const { + guildId, + channelId, + hours, + limit, + } = req.query as { + guildId?: string; + channelId?: string; + hours?: string; + limit?: string; + }; + + if (!guildId) { + throw new AppError( + "guildId query parameter is required", + "MISSING_GUILD_ID", + 400, + ); + } + + const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20; + + const users = await getUserLeaderboard({ + guildId, + channelId, + hours: hoursNum, + limit: limitNum, + }); + + res.json(users); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/stats - Moderation stats breakdown + // Query params: guildId (required), channelId, hours (default 24) + router.get("/analytics/stats", 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) || 24, 168) : 24; + + const stats = await getModerationStats({ + guildId, + channelId, + hours: hoursNum, + }); + + res.json(stats); + } catch (error) { + next(error); + } + }); + + // GET /api/analytics/violators - Top violators leaderboard + // Query params: guildId (required), channelId, hours (default 24), limit (default 20) + router.get("/analytics/violators", async (req, res, next) => { + try { + const { + guildId, + channelId, + hours, + limit, + } = req.query as { + guildId?: string; + channelId?: string; + hours?: string; + limit?: string; + }; + + if (!guildId) { + throw new AppError( + "guildId query parameter is required", + "MISSING_GUILD_ID", + 400, + ); + } + + const hoursNum = hours ? Math.min(parseInt(hours) || 24, 168) : 24; + const limitNum = limit ? Math.min(parseInt(limit) || 20, 100) : 20; + + const violators = await getTopViolators({ + guildId, + channelId, + hours: hoursNum, + limit: limitNum, + }); + + res.json(violators); + } catch (error) { + next(error); + } + }); + + return router; +} diff --git a/test_filter.js b/test_filter.js new file mode 100644 index 0000000..e0119f5 --- /dev/null +++ b/test_filter.js @@ -0,0 +1,19 @@ +function filterHits(text, hits) { + const lowerText = text.toLowerCase(); + return hits.filter(hit => { + if (hit === "asu") { + const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || []; + return words.some(w => + w.includes("asu") && + !["asus", "masuk", "termasuk", "dimasukkan", "memasukkan", "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan"].includes(w) + ); + } + return true; + }); +} + +console.log(filterHits("Gua aja mau membeli asus", ["asu"])); // [] +console.log(filterHits("asus asu", ["asu"])); // ["asu"] +console.log(filterHits("masuk", ["asu"])); // [] +console.log(filterHits("asuuu", ["asu"])); // ["asu"] +console.log(filterHits("ngasu", ["asu"])); // ["asu"]