feat: Implement video capture improvements and mobile navigation fixes

- Added eager selfbot voice connection establishment to ensure video capture works seamlessly during voice channel joins.
- Introduced a manual video watch command for selfbots to allow operators to initiate screen recording of other members' streams.
- Enhanced video recording functionality to split recordings into segments based on user activity, similar to voice recordings.
- Fixed mobile navigation issues by extending the navbar to include all items and ensuring responsive form controls.
- Standardized error handling across frontend components to improve user experience during failures.
This commit is contained in:
asepharyana committed 2026-09-11 18:56:28 +07:00
1 parent 0eb089d9dc
commit b3418ad799
26 files changed

No files matched your search

@@ -0,0 +1,418 @@
# GMW Frontend — Greenfield Rebuild (Visual-System Overhaul + Custom UI + Motion/3D)
> **For Hermes:** Execute with `subagent-driven-development` (one fresh subagent per task, two-stage review). Each task is 1–3 min, atomic, independently verifiable, committed after each. Reuse `src/lib/api/*`, `src/lib/ws/*`, `src/lib/types/*`, hooks verbatim. Never invent endpoints.
**Goal:** Rebuild the GMW Discord-automod dashboard frontend from scratch — drop shadcn/ui + glass/teal/purple aesthetic entirely, replace with a custom, distinctive design system where every page has its own visual metaphor (no uniform bordered-card grid), and push the presentation layer with **Framer Motion (`motion`) choreography + signature Three.js scenes**. Re-integrate to the EXISTING backend API + WebSocket contract (do NOT touch the backend).
**Architecture:** Next.js 16 App Router (SSR) + React 19 + TS strict + Tailwind v4. Keep the *plumbing* (data contract), rebuild the *skin + primitives + motion*. Each route = one self-contained `page.tsx` (server fetch + client view in the same file via a `"use client"` sibling export). Custom SVG charts (no recharts). Custom micro-primitives (no shadcn/base-ui). New token system in `globals.css`. **Motion:** `motion/react` app-wide for page transitions, spring micro-interactions, layout animaLine truncated
**Tech Stack:** next@16, react@19, tailwindcss@4 (`@import "tailwindcss"`), `next/font/google` (Bricolage Grotesque + Inter + JetBrains Mono), `swr` (data revalidation), `lucide-react` (icons only), `motion` (Framer Motion successor — `motion/react`), `three` + `@types/three` (signature scenes only), `clsx` + `tailwind-merge`. **Removed:** `@shadcn/react`, `@base-ui/react`, `recharts`, `shadcn` CLI, `cmdk`, `sonner`, `react-day-picker`, `embla-carousel-react`, `react-resizable-panels`, `inputLine truncated
---
## 0. Design System (the creative core — read before coding)
**Persona:** Controlled UX Designer + a tactical "ops console" voice. Material honesty: hierarchy via **scale/weight/tonal blocks**, NOT borders/shadows. Per `frontend-design` skill principle — spend the boldness in ONE signature place per page, keep the rest disciplined. Motion is choreography, not confetti: **one orchestrated moment per page**, everything else quiet.
### Palette (warm, signal-driven — NO teal/cyan/purple/blue gradients)
Light mode (`:root`):
```
--canvas: oklch(0.96 0.012 80) /* warm off-white, not cream */
--surface: oklch(0.92 0.014 80) /* tonal block, replaces bordered card */
--surface-2: oklch(0.88 0.016 80)
--ink: oklch(0.22 0.02 70) /* primary text */
--ink-soft: oklch(0.46 0.02 70) /* secondary text */
--hairline: oklch(0.22 0.02 70 / 0.10) /* structural rules ONLY, sparse */
--signal: oklch(0.78 0.17 125) /* lime — OK / live / primary accent */
--signal-ink: oklch(0.20 0.03 70) /* text ON signal */
--amber: oklch(0.80 0.15 70) /* WARN */
--vermilion: oklch(0.62 0.21 25) /* FLAGGED / destructive */
--ring: var(--signal)
```
Dark mode (`.dark`, default theme per `next-themes`):
```
--canvas: oklch(0.13 0.015 70) /* warm charcoal, not blue-black */
--surface: oklch(0.18 0.02 70)
--surface-2: oklch(0.23 0.022 70)
--ink: oklch(0.93 0.01 75)
--ink-soft: oklch(0.62 0.02 75)
--hairline: oklch(1 0 0 / 0.09)
--signal: oklch(0.88 0.18 125)
--signal-ink: oklch(0.18 0.03 70)
--amber: oklch(0.85 0.15 70)
--vermilion: oklch(0.68 0.21 25)
```
Three semantic signals reused everywhere: **lime = OK/live, amber = warn, vermilion = flag/danger**. This kills the purple-accent + teal-primary monotony.
### Typography (3 roles, deliberate pairing — not "Inter everywhere")
- **Display:** `Bricolage Grotesque` (700–800) — characterful grotesque for headers/big numbers.
- **Body/UI:** `Inter` (400–600).
- **Data/label:** `JetBrains Mono` (500/700) — all stats, timestamps, channel IDs, metrics.
Load all three via `next/font/google` with CSS variables (keep current `--font-inter`/`--font-jetbrains-mono` names + add `--font-display`).
### Layout & signature
- **No `card` with border.** Use tonal `--surface` blocks with generous radius (`--r: 14px`) and internal padding; separate blocks with whitespace + sparse hairlines only where structurally meaningful.
- **Signature element = "scan-tick":** a 1px animated pulse line (CSS keyframe `scan`) that marks every live/section header — NOT a card outline. Global canvas carries a faint warm dot-grid texture (low opacity) instead of the current bluish dotted radial-gradient.
- **Nav = left "spine":** vertical rail of icon nodes joined by a hairline; active node gets a `--signal` dot + label reveal. Collapses to a bottom tab-bar < 768px (CSS only, no JS sidebar primitive).
- **Header = "status bar":** connection state (WS dot), guild selector, live clock — mono font, reads like an instrument readout.
## 0.1 Motion & 3D Layer (the "lebih kreatif" addition)
### Motion rules (from `motion/react`)
- **Page transitions:** one shared `RouteTransition` in `(dashboard)/layout.tsx` — `AnimatePresence mode="popLayout"` + `motion.div key={pathname}` (fade + 8px rise + slight blur-out, ~220ms, `easeOut`). Consistent everywhere, zero per-page boilerplate.
- **Enter choreography (per page, ONE signature moment):** staggered rise-in for the hero/ticker group using `staggerChildren` variants; afterwards, quiet springs for hover/tap (`scale: 1.03` on interactive blocks, `whileTap` on buttons).
- **Layout animation:** `layout` prop on list items (messages rows, recording rows, queue) so add/remove/filter reflows smoothly; `layoutId` for shared-element transitions (ticker → detail modal on dashboard).
- **Live pulse:** `motion` drives the severity ticks / speaker rings with springs, not CSS `transition` alone.
- **`useReducedMotion()`** (from `motion/react`) gates ALL heavy motion; CSS `@media (prefers-reduced-motion: reduce)` additionally kills `scan`/`spin-disc` keyframes. Accessibility floor, non-negotiable.
- **No scroll-jacking, no marquee loops, no per-element confetti.** One moment per page. (`frontend-design` skill: "Satu momen orkestrasi biasanya lebih mengena daripada efek tersebar.")
### 3D rules (raw `three`, no R3F — lean bundle)
- Exactly **two** scenes, chosen because they carry real data meaning: **Dashboard hero** (`SignalField` — a particle field whose pulse density reflects live activity) and **Voice page** (`OrbField` — speakers as glowing orbs whose height/ring radius reacts to who is speaking). Everything else stays 2D/motion.
- **Lazy + client-only:** `next/dynamic(() => import("./SignalField"), { ssr: false, loading: () => <StaticFallback/> })`. Three ships in its own chunk, loaded only on those two routes.
- **WebGL guard:** if `!window.WebGLRenderingContext` or context creation fails → render the static SVG/CSS fallback (a stylized 2D version of the same visual). Never blank.
- **Perf guardrails:** `dpr: [1, 1.75]`, `powerPreference: "high-performance"`, `antialias: true`; RAF loop paused on `document.hidden`; `dispose()` geometries/materials on unmount; particle count capped by `navigator.hardwareConcurrency` + viewport (`Math.min(900, w*h/2000)`).
- **Style:** warm palette ONLY — signal-lime particles, amber/vermilion for flag/warn states; fog + soft additive blending for glow (no harsh white lights, no metallic PBR).
- **Interactivity:** subtle pointer parallax (camera lerp toward cursor) + gentle idle rotation. No drag/drop, no raycasting menus.
### Per-page metaphor (kills monotony — each page feels different)
| Route | Metaphor | Signature visual | Motion / 3D |
|---|---|---|---|
| `/dashboard` | Live ops overview | **3D signal particle field** hero + asymmetric ticker blocks + radial moderation gauge | **3D SignalField** (reacts to activity), staggered ticker rise-in, gauge draws on mount |
| `/messages` | Transcript | Left channel **timeline spine**; right = flowing message entries with left **severity tick** (no bordered cards); search = command palette | `layout` on message rows, spring severity ticks, palette types in |
| `/voice` | Stage | **3D orb field** of speakers + equalizer rings; activity = horizontal **session ribbon** | **3D OrbField** (speaking → orb rises + ring pulses), session ribbon draws sequentially |
| `/media` | Turntable | Rotating **disc** now-playing; queue = borderless list | CSS 3D disc spin (spring on play/pause), queue `layout` reflow, progress bar springs |
| `/recordings` | Tape library | Rows with **waveform thumbnail** (custom SVG from duration) | Waveform bars spring on hover; new upload animates in (AnimatePresence) |
| `/moderation` | Security log | Vertical **event flow** with status nodes (dot + line), not a table of cards | Nodes pulse on live action; timeline draws in sequence |
| `/analysis` | Query console | Terminal-style search panel | Typing cursor + results stagger |
---
## 1. What to KEEP (reuse verbatim — correct + integrates to backend)
- `src/lib/api/server.ts` — 11 server fetchers (`getDashboardStats`, `getActivity`, `getMediaStatus`, `getConfig`, `getModerationStats/Actions`, `getGuilds`, `getVoiceStatus`, `getRecordings`, `getMessages`). **No change.**
- `src/lib/api/client.ts` — `apiRequest` + `ApiError`. **No change.**
- `src/lib/ws/*` — `connection.ts`, `context.tsx`, `types.ts` (17 typed events: `message_created/updated/deleted/analyzed`, `voice_*`, `media_state`, `voice_pcm_data` binary). **No change.**
- `src/lib/types/*` — all interfaces. **No change.**
- `src/lib/format.ts`, `src/lib/utils.ts` (`cn`). **No change.**
- `src/lib/navigation.ts` — `navItems`. Keep but extend icons/labels if needed.
- Hooks: `src/hooks/*` (use-dashboard, use-media, use-messages, use-voice, use-moderation, use-recordings, use-guilds, use-config, use-chatbot-user, use-action, use-mobile), `src/lib/hooks/use-mounted.ts`. **Reuse** (verify no bad imports into deleted barrels).
- Feature logic kept but **reskinned**: `src/components/media/music-player.tsx`, `src/components/voice/*`, `src/components/chatbot/*`, `src/components/messages/*`, `src/components/recordings/*`, `src/components/moderation/moderation-section.tsx`, `src/components/analysis/search-panel.tsx`, `src/components/dashboard/*` (charts → rewritten as custom SVG).
## 2. What to DELETE
- `src/components/ui/*` (all 50 shadcn primitives).
- `components.json`, `@shadcn/react` + `@base-ui/react` + `shadcn` deps.
- `recharts` (replace with custom SVG chart helpers in `src/components/charts/`).
- `src/app/globals.css` → rewrite (no `@import "shadcn/tailwind.css"`, no `--color-primary` teal, no `.glass*`, no `.text-gradient`/`.gradient-border` teal/purple, no bluish body texture).
- `src/components/layout/app-sidebar.tsx` (shadcn Sidebar) → replace with custom `Spine` nav.
- All `page.tsx`+`view.tsx` pairs → merge into single `page.tsx` per route.
## 3. What to BUILD (new)
- New `globals.css` (tokens above + utilities + keyframes `scan`, `eq`, `fade-up`, `spin-disc`).
- `src/components/primitives/` — minimal custom: `Button`, `Input`, `Select`, `Dialog`, `Tooltip`, `Badge`, `Progress`, `Avatar`, `Skeleton`, `Toast`, `Sheet`.
- `src/components/motion/` — `RouteTransition.tsx`, `Stagger.tsx`, `variants.ts`.
- `src/components/three/` — `SignalField.tsx`, `OrbField.tsx`, `WebGLGuard.tsx`, `StaticFallback.tsx`, `useThreeScene.ts`.
- `src/components/charts/` — `Sparkline`, `AreaActivity`, `RadialGauge`, `SessionRibbon`, `Waveform`.
- `src/components/layout/` — `Spine.tsx`, `StatusBar.tsx`, `ThemeToggle.tsx` (reskin).
- 7 merged `page.tsx` files (one per route) implementing the metaphors above.
- `src/app/layout.tsx` — root (fonts + ThemeProvider + Toaster), `src/app/(dashboard)/layout.tsx` — providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot, `src/app/page.tsx` → redirect `/dashboard`.
---
## 4. Target File & Folder Structure (authoritative)
Everything below `services/frontend/src/` is the new tree. `REWRITE` replaces existing; `NEW` creates; `DELETE` removes. The `app/` route tree collapses `page.tsx`+`view.tsx` into single `page.tsx` files containing BOTH server fetch (default export) and client view (`"use client"` named export in same file).
```
services/frontend/
├─ package.json REWRITE (drop shadcn/base-ui/recharts; add motion, three, @types/three)
├─ pnpm-workspace.yaml REWRITE (onlyBuiltDependencies: keep build list minimal)
├─ components.json DELETE (shadcn registry config — no longer used)
├─ next.config.ts KEEP (output: standalone, trailingSlash, images.unoptimized)
├─ tsconfig.json KEEP (paths "@/*" → src/*, strict)
├─ postcss.config.mjs KEEP (@tailwindcss/postcss)
├─ biome.json KEEP
└─ src/
├─ app/
│ ├─ layout.tsx REWRITE (3 fonts + ThemeProvider + custom Toaster; rm sonner)
│ ├─ globals.css REWRITE (new token system §0; rm glass/teal/purple)
│ ├─ page.tsx KEEP (redirect → /dashboard/)
│ └─ (dashboard)/
│ ├─ layout.tsx REWRITE (providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot; rm shadcn Sidebar)
│ ├─ dashboard/ page.tsx REWRITE (server fetch + <DashboardView/> client; 3D SignalField hero)
│ ├─ messages/ page.tsx REWRITE (server seeds + <MessagesView/>; spine + severity ticks)
│ ├─ voice/ page.tsx REWRITE (server seeds + <VoiceView/>; 3D OrbField hero)
│ ├─ media/ page.tsx REWRITE (server seeds + <MediaView/>; turntable disc)
│ ├─ recordings/ page.tsx REWRITE (server seeds + <RecordingsView/>; waveform rows)
│ ├─ moderation/ page.tsx REWRITE (server seeds + <ModerationView/>; event-flow)
│ └─ analysis/ page.tsx REWRITE (client <AnalysisView/>; query console)
├─ components/
│ ├─ ui/ DELETE (all 50 shadcn primitives)
│ ├─ primitives/ NEW (Button, Input, Select, Dialog, Tooltip, Badge, Progress, Avatar, Skeleton, Toast, Sheet, index.ts)
│ ├─ motion/ NEW (variants.ts, Stagger.tsx, RouteTransition.tsx)
│ ├─ three/ NEW (WebGLGuard, SignalField, OrbField, StaticFallback, useThreeScene)
│ ├─ charts/ NEW (Sparkline, AreaActivity, RadialGauge, SessionRibbon, Waveform)
│ ├─ layout/ REWRITE (Spine NEW, StatusBar NEW, ThemeToggle REWRITE, app-sidebar DELETE)
│ ├─ dashboard/ REWRITE (stat-card DELETE; activity-chart/hourly/top-channels/moderation-donut/users/channels/reactions REWRITE)
│ ├─ messages/ REWRITE (message-card DELETE; message-list/detail/detail-view/ai-status-badge/ai-analysis-panel/attachments-grid/lightbox/search-overlay REWRITE)
│ ├─ voice/ REWRITE (voice-connection-card/connection-card/microphone-card DELETE; speaker-waveform/active-speakers-panel/activity-timeline/mic-control/listen-control REWRITE)
│ ├─ media/ REWRITE (music-player, mini-player REWRITE)
│ ├─ recordings/ REWRITE (recording-card DELETE; recording-player REWRITE)
│ ├─ moderation/ REWRITE (moderation-section REWRITE)
│ ├─ analysis/ REWRITE (search-panel REWRITE)
│ ├─ chatbot/ REWRITE (chatbot-container, chat-panel REWRITE; chatbot-context, index KEEP)
│ └─ shared/ REWRITE (empty-state, error-state, loading-skeleton, error-boundary, guild-selector REWRITE; index KEEP)
├─ hooks/ KEEP (verify no bad imports)
├─ lib/
│ ├─ api/ KEEP (server.ts, client.ts, index.ts)
│ ├─ ws/ KEEP (connection.ts, context.tsx, types.ts, ws-hook.ts)
│ ├─ types/ KEEP (all interfaces)
│ ├─ hooks/ KEEP (use-media-player.tsx, use-mounted.ts)
│ ├─ audio/ KEEP (voice PCM decode helpers if present)
│ ├─ format.ts KEEP
│ ├─ utils.ts KEEP (cn)
│ └─ navigation.ts KEEP
└─ (public assets) KEEP
```
### 4.1 Single-file page pattern (mandatory)
```tsx
// server component (default export) — runs on the server, fetches initial data
import { getX, getY } from "@/lib/api/server";
import { XView } from "./page"; // self-import of the named client export
export default async function Page() {
const [a, b] = await Promise.allSettled([getX(), getY()]);
return <XView initialA={a.status === "fulfilled" ? a.value : undefined}
initialB={b.status === "fulfilled" ? b.value : undefined} />;
}
// client component (named export) — hydrated, takes initialData as SWR fallback
"use client";
export function XView({ initialA, initialB }: Props) {
const { data } = useX(initialA); // SWR fallbackData = initialA
}
```
Self-referencing the named export keeps the file single-artifact while satisfying Next's RSC boundary (default = server, named = client). Tabs live inside `XView`.
### 4.2 Import rules (lint gate)
- No `@/components/ui/*` (deleted) — all UI via `@/components/primitives`.
- `three` only imported inside `src/components/three/*`; pages import those via `next/dynamic({ ssr: false })`.
- `motion` imported from `motion/react` only.
- All data: `@/lib/api/server` (server) / `@/lib/api/client` (client) — never invented endpoints.
---
## TASKS (granular — every file is its own task)
### PHASE 0 — Dependency surgery
- **T0.1** Edit `package.json`: remove `dependencies["@base-ui/react"]`.
- **T0.2** Remove `dependencies["@shadcn/react"]`.
- **T0.3** Remove `dependencies["shadcn"]`.
- **T0.4** Remove `dependencies["recharts"]`.
- **T0.5** Remove `dependencies["cmdk"]`.
- **T0.6** Remove `dependencies["sonner"]`.
- **T0.7** Remove `dependencies["react-day-picker"]`.
- **T0.8** Remove `dependencies["embla-carousel-react"]`.
- **T0.9** Remove `dependencies["react-resizable-panels"]`.
- **T0.10** Remove `dependencies["input-otp"]`.
- **T0.11** Add `dependencies["motion"]: "^12.0.0"`, `dependencies["three"]: "^0.180.0"`, `devDependencies["@types/three"]: "^0.180.0"`.
- **T0.12** `rm -f pnpm-lock.yaml && pnpm install` (regenerate lockfile).
- **T0.13** Verify `pnpm ls recharts @shadcn/react @base-ui/react` → empty; `pnpm ls motion three @types/three` → present.
- **T0.14** `cat pnpm-workspace.yaml`: confirm `onlyBuiltDependencies` keeps needed native builds, no broken shadcn postinstall.
### PHASE 1 — Design tokens (globals.css)
- **T1.1** Rewrite `@theme { }` head: light `:root` palette from §0 (canvas/surface/ink/ink-soft/hairline/signal/signal-ink/amber/vermilion/ring).
- **T1.2** Add radius tokens `--r: 14px`, `--r-panel: 12px`, `--r-control: 8px`, `--r-pill: 9999px`.
- **T1.3** Add `--font-display` token; keep `--font-sans`/`--font-mono`.
- **T1.4** Add `.dark { }` override block with §0 dark values (warm charcoal).
- **T1.5** Replace `@layer base body` bg: warm dot-grid `radial-gradient(oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px)` + faint warm glow; remove old bluish radial layers.
- **T1.6** Retint scrollbar thumb to warm `oklch(0.4 0.02 70 / 0.2)`; keep `::selection` signal-tinted.
- **T1.7** Delete `.glass`, `.glass-elevated`, `.glass-intense`, `.dark .glass-intense` utilities.
- **T1.8** Delete `.text-gradient` and `.gradient-border`.
- **T1.9** Add `.surface` utility (bg var(--surface), radius var(--r), padding).
- **T1.10** Add `.scan-tick` (1px animated pulse line, keyframe `scan`).
- **T1.11** Add `.ticker`, `.pill`, `.mono` utilities.
- **T1.12** Add keyframes `scan`, `eq`, `fade-up`, `spin-disc` (keep used existing ones if still referenced).
- **T1.13** Add `@media (prefers-reduced-motion: reduce)` kill switch for scan/eq/spin-disc/pulse-ring/shimmer.
- **T1.14** Remove `@import "shadcn/tailwind.css";` (line 3); verify nothing else depends on shadcn CSS vars.
- **T1.15** Verify `grep -c "0.52 0.17 215\|0.55 0.2 280" src/app/globals.css` → `0`.
- **T1.16** `pnpm biome check src/app/globals.css` → no errors.
### PHASE 2 — Root layout + fonts
- **T2.1** In `layout.tsx` add `Bricolage_Grotesque` (`variable: "--font-display"`, subsets `["latin"]`, `display: "swap"`).
- **T2.2** Apply `inter.variable`, `jetbrainsMono.variable`, `bricolage.variable` to `<html>`.
- **T2.3** Remove `import { Toaster } from "@/components/ui/sonner"`.
- **T2.4** Comment out `<Toaster />` temporarily (re-enabled after T3.10).
- **T2.5** Keep `suppressHydrationWarning`, `ThemeProvider` (defaultTheme dark, enableSystem false).
- **T2.6** `npx tsc --noEmit` (fonts only; rest may still error until primitives exist).
### PHASE 3 — Custom primitives (replace 50 shadcn ui)
- **T3.1** `primitives/Button.tsx`: `motion.button`, variants `primary`/`ghost`/`danger`, `cn()` merge, `cursor-pointer`, `focus-visible:ring-2 ring-signal`, `whileTap` scale 0.97 gated by `useReducedMotion()`.
- **T3.2** `primitives/Input.tsx`: native `<input>`, `bg-surface`, `rounded-[var(--r-control)]`, `mono` prop.
- **T3.3** `primitives/Select.tsx`: native `<select>` styled, `bg-surface`.
- **T3.4** `primitives/Dialog.tsx`: native `<dialog>` + `showModal()`, warm `::backdrop`, `AnimatePresence`, `onClose`.
- **T3.5** `primitives/Tooltip.tsx`: CSS group-hover popover.
- **T3.6** `primitives/Badge.tsx`: tonal pill, `variant` → `bg-{tone}/15 text-{tone}` (signal/amber/vermilion/neutral).
- **T3.7** `primitives/Progress.tsx`: SVG track + `motion` fill, `value`/`max`, signal color.
- **T3.8** `primitives/Avatar.tsx`: `<img>` + initials fallback, signal bg, size prop.
- **T3.9** `primitives/Skeleton.tsx`: shimmer block (signal-tinted), `aria-hidden`.
- **T3.10** `primitives/Toast.tsx`: `ToastProvider` context + portal, `useToast()`, motion slide-in, auto-dismiss.
- **T3.11** `primitives/Sheet.tsx`: mobile drawer (`translate-x` spring), overlay, `open`/`onClose`.
- **T3.12** `primitives/index.ts` re-export all 11.
- **T3.13** Re-enable `<Toaster />` in `layout.tsx` (T2.4).
- **T3.14** Verify `npx tsc --noEmit` on primitives; `grep -rl "@/components/ui/" src/components/primitives` → empty.
### PHASE 4 — Motion foundation
- **T4.1** `motion/variants.ts`: export `spring`, `ease`, `fadeUp`, `stagger` (per §0.1).
- **T4.2** `motion/Stagger.tsx`: `StaggerGroup` + `StaggerItem` (`"use client"`).
- **T4.3** `motion/RouteTransition.tsx`: `"use client"`, `usePathname`, `AnimatePresence mode="popLayout"`, reduced-motion fallback to plain `<div>`.
- **T4.4** Verify `npx tsc --noEmit` on motion; `motion/react` import resolves.
### PHASE 5 — Custom SVG charts (replace recharts)
- **T5.1** `charts/Sparkline.tsx`: `<svg>` polyline from `points:number[]`, signal stroke, no axes.
- **T5.2** `charts/AreaActivity.tsx`: filled `<path>` area, low-opacity signal gradient, `pathLength` draw gated by reduced-motion.
- **T5.3** `charts/RadialGauge.tsx`: `<circle>` arc `stroke-dasharray`, center mono label.
- **T5.4** `charts/SessionRibbon.tsx`: horizontal segments per speaker duration.
- **T5.5** `charts/Waveform.tsx`: bars from deterministic seed, spring scaleY on hover.
- **T5.6** Verify `npx tsc --noEmit` on charts; no `recharts` import.
### PHASE 6 — Three.js foundation (lazy, guarded)
- **T6.1** `three/WebGLGuard.tsx`: `"use client"`, detect webgl2/webgl, render `children` or `fallback`.
- **T6.2** `three/useThreeScene.ts`: shared hook — renderer init (`dpr:[1,1.75]`, `powerPreference`), RAF with `document.hidden` pause, `dispose()` on unmount, resize observer.
- **T6.3** `three/SignalField.tsx`: `Points` BufferGeometry (~min(900, w*h/2000)), additive blend, signal-lime, fog, idle rotation + sine drift, `activity` prop, pointer parallax. Uses `useThreeScene`.
- **T6.4** `three/OrbField.tsx`: per-speaker `Sphere`, y-scale + ring lerp to speaking, tones signal/idle/vermilion.
- **T6.5** `three/StaticFallback.tsx`: 2D SVG/CSS silhouette for both scenes.
- **T6.6** Verify `grep -rl "from \"three\"" src | grep -v "components/three"` → empty; `npx tsc --noEmit` on three.
### PHASE 7 — Layout shell
- **T7.1** `layout/Spine.tsx`: `"use client"`, vertical rail from `navItems`, icon node + hairline, active signal dot + label reveal (motion spring), `max-md:` bottom tab-bar.
- **T7.2** `layout/StatusBar.tsx`: `"use client"`, page title + WS status dot (motion pulse) + `GuildSelector` + live clock (mono) + `ThemeToggle`.
- **T7.3** `layout/ThemeToggle.tsx`: restyle, keep `next-themes` logic, motion icon swap.
- **T7.4** Rewrite `(dashboard)/layout.tsx`: keep SWRConfig/WsProvider/MediaPlayerProvider/ChatbotProvider + sync functions verbatim; swap `AppSidebar`→`Spine`, header→`StatusBar`, wrap children in `RouteTransition`; remove `SidebarInset`/`SidebarTrigger`/`Separator`; keep MiniPlayer+ChatbotContainer.
- **T7.5** Delete `layout/app-sidebar.tsx`.
- **T7.6** Verify `grep -rl "components/ui/sidebar\|app-sidebar" src` → empty; `npx tsc --noEmit`.
### PHASE 8 — Dashboard page + components
- **T8.1** Rewrite `dashboard/page.tsx`: default async `getDashboardStats`+`getActivity` → `<DashboardView>`; named `"use client"` view with useStats/useActivity, 3D hero + tickers + tabs.
- **T8.2** Add `WebGLGuard`+`SignalField` hero with `activity` ratio; overlay headline (Bricolage) + `<RadialGauge>`.
- **T8.3** Build asymmetric ticker row with `StaggerGroup` + 4 `.surface` blocks (mono number + label + `<Sparkline>`); inline (replaces stat-card).
- **T8.4** Delete `dashboard/stat-card.tsx`.
- **T8.5** Reskin `dashboard/activity-chart.tsx` → `charts/AreaActivity` (daily).
- **T8.6** Reskin `dashboard/hourly-activity-chart.tsx` → `charts/AreaActivity` (hourly).
- **T8.7** Reskin `dashboard/top-channels-chart.tsx` → `charts/` + `.surface`.
- **T8.8** Reskin `dashboard/moderation-donut.tsx` → `charts/RadialGauge`.
- **T8.9** Reskin `dashboard/users-section.tsx` → `.surface`.
- **T8.10** Reskin `dashboard/channels-section.tsx` → `.surface`.
- **T8.11** Reskin `dashboard/reactions-section.tsx` → `.surface`.
- **T8.12** Verify `grep -rl "components/ui/card" src/app/\(dashboard\)/dashboard src/components/dashboard` → empty; `npx tsc --noEmit`.
### PHASE 9 — Messages page + components
- **T9.1** Rewrite `messages/page.tsx`: default `getMessages(guildId)`(+channels) → `<MessagesView>`; client spine + entries.
- **T9.2** Delete `messages/message-card.tsx`.
- **T9.3** Rewrite `messages/message-list.tsx`: left timeline spine + right severity-tick entries (`surface` + `border-l-2` lime/amber/vermilion, motion spring tick), `layout` reflow.
- **T9.4** Rewrite `messages/message-detail.tsx` → `.surface`.
- **T9.5** Rewrite `messages/message-detail-view.tsx` → `.surface` pane.
- **T9.6** Rewrite `messages/ai-status-badge.tsx` → `primitives/Badge`.
- **T9.7** Rewrite `messages/ai-analysis-panel.tsx` → `.surface`.
- **T9.8** Rewrite `messages/attachments-grid.tsx` → `.surface` grid.
- **T9.9** Rewrite `messages/lightbox.tsx` → `primitives/Dialog`.
- **T9.10** Rewrite `messages/search-overlay.tsx` → console palette, type-in animation, `primitives/Dialog`.
- **T9.11** Verify no `components/ui/card` in messages tree; `npx tsc --noEmit`.
### PHASE 10 — Voice page + components
- **T10.1** Rewrite `voice/page.tsx`: default `getVoiceStatus()` → `<VoiceView>`; client `WebGLGuard`+`OrbField` hero + ribbon + tabs.
- **T10.2** Delete `voice/voice-connection-card.tsx`, `connection-card.tsx`, `microphone-card.tsx`.
- **T10.3** Rewrite `voice/speaker-waveform.tsx` → SVG ring / eq bars.
- **T10.4** Rewrite `voice/active-speakers-panel.tsx` → `.surface`.
- **T10.5** Rewrite `voice/activity-timeline.tsx` → `charts/SessionRibbon`.
- **T10.6** Rewrite `voice/mic-control.tsx` → `primitives/Button`.
- **T10.7** Rewrite `voice/listen-control.tsx` → `primitives/Button`.
- **T10.8** Verify `npx tsc --noEmit`; no `components/ui/card` in voice tree.
### PHASE 11 — Media page + components
- **T11.1** Rewrite `media/page.tsx`: default `getMediaStatus()` → `<MediaView>`; client turntable disc + transport + queue.
- **T11.2** Rewrite `media/music-player.tsx`: CSS-3D disc (spin-disc, pause when not playing, spring on play/pause), mono meta, `primitives/Button` transport, `.surface` queue rows with `layout`.
- **T11.3** Rewrite `media/mini-player.tsx` → compact `.surface`.
- **T11.4** Verify `npx tsc --noEmit`; no `components/ui/card` in media tree.
### PHASE 12 — Recordings page + components
- **T12.1** Rewrite `recordings/page.tsx`: default `getRecordings(50)` → `<RecordingsView>`; client rows `AnimatePresence`+`layout`, live `voice_recording_uploaded` prepend.
- **T12.2** Delete `recordings/recording-card.tsx`.
- **T12.3** Rewrite `recordings/recording-player.tsx` → `.surface` row + `charts/Waveform` + `primitives/Button`/`Dialog` play/delete.
- **T12.4** Verify `npx tsc --noEmit`.
### PHASE 13 — Moderation + Analysis pages
- **T13.1** Rewrite `moderation/page.tsx`: default `getModerationStats/Actions` → `<ModerationView>`; client vertical event-flow, live actions pulse-in.
- **T13.2** Rewrite `moderation/moderation-section.tsx` → event-flow, no Card/table.
- **T13.3** Rewrite `analysis/page.tsx`: client `<AnalysisView/>` terminal console (`primitives/Input` mono + blinking caret, `.surface` results staggered).
- **T13.4** Rewrite `analysis/search-panel.tsx` → terminal style.
- **T13.5** Verify `npx tsc --noEmit`.
### PHASE 14 — Chatbot + shared + final cleanup
- **T14.1** Rewrite `chatbot/chatbot-container.tsx` → `.surface`, keep drag/minimize.
- **T14.2** Rewrite `chatbot/chat-panel.tsx` → bubbles via `AnimatePresence`, `primitives/*`.
- **T14.3** Keep `chatbot/chatbot-context.tsx` + `index.ts`.
- **T14.4** Rewrite `shared/empty-state.tsx` → tonal.
- **T14.5** Rewrite `shared/error-state.tsx`.
- **T14.6** Rewrite `shared/loading-skeleton.tsx` → `primitives/Skeleton`.
- **T14.7** Rewrite `shared/error-boundary.tsx`.
- **T14.8** Rewrite `shared/guild-selector.tsx` → `primitives/Select`.
- **T14.9** `rm -rf src/components/ui && rm -f components.json`.
- **T14.10** `grep -rn "components/ui/\|@shadcn\|@base-ui\|recharts" src` → MUST be empty.
- **T14.11** `grep -rl "from \"recharts\"\|@base-ui\|@shadcn" src` → empty (double-check).
- **T14.12** Verify `npx tsc --noEmit` across whole `src`.
### PHASE 15 — Build + lint gate
- **T15.1** `cd services/frontend && npx tsc --noEmit` → 0 errors.
- **T15.2** `pnpm biome check` → fix all issues (no `any` in new files).
- **T15.3** `pnpm build` (standalone) → success, emits `.next/standalone/server.js`.
- **T15.4** Inspect `.next/static/chunks/` for three-heavy chunk loaded only on dashboard/voice; confirm NOT in `/dashboard/` initial SSR HTML.
- **T15.5** Confirm `pnpm-lock.yaml` present (reproducible flake install).
- **T15.6** `grep -c "0.52 0.17 215\|0.55 0.2 280" .next/static/css/*.css` → 0.
### PHASE 16 — Local runtime smoke test (no prod)
- **T16.1** Start local standalone: `GMW_BACKEND_URL=http://127.0.0.1:4001 PORT=4017 node .next/standalone/server.js &`.
- **T16.2** `curl -s -o /dev/null -w "%{http_code}"` for all 7 routes → 200.
- **T16.3** `curl /dashboard/ | grep -o "Bricolage\|signal\|surface"` → present.
- **T16.4** `curl /_next/static/css/*.css | grep "0.52 0.17 215\|0.55 0.2 280"` → empty.
- **T16.5** Headless browser `/dashboard/`+`/voice/` WebGL on: no console errors, `<canvas>` present, `<StaticFallback/>` NOT rendered.
- **T16.6** Same pages WebGL off: `<StaticFallback/>` renders, no crash.
- **T16.7** Kill local server. Do NOT touch prod unit.
- **T16.8** Confirm 7 routes 200 + no console errors in T16.5/16.6.
### PHASE 17 — Flake + staging deploy
- **T17.1** Inspect `flake.nix` frontend drv: `filterSource` ignores `out/.next/node_modules`; `pnpm-lock.yaml` included.
- **T17.2** `nix build .#gmw-frontend --impure --sandbox-off` → succeeds.
- **T17.3** `nix copy` frontend drv to VPS into staging profile (test port e.g. 4217).
- **T17.4** Create/adjust staging systemd unit with `PORT=4217` exported BEFORE `node server.js` (LIDM PORT bug).
- **T17.5** `sudo systemctl restart gmw-frontend-staging`; `curl` staging → 200.
- **T17.6** Browser-check staging `/dashboard/`+`/voice/` (3D visible, fallback test).
- **T17.7** Verify staging CSS has no old teal/purple; new design renders.
### PHASE 18 — Production swap (CONFIRM WITH USER FIRST)
- **T18.1** STOP — send staging screenshots/URL; await explicit approval before touching prod.
- **T18.2** On approval: `nix-env --profile /nix/var/nix/profiles/gmw-frontend --set <new-drv>`.
- **T18.3** Confirm `gmw-frontend.service` exports `PORT=4017` before exec.
- **T18.4** `sudo systemctl restart gmw-frontend`.
- **T18.5** `curl` all 7 routes on `https://imphnen.asepharyana.my.id` → 200.
- **T18.6** Browser verify prod: new design, old teal gone, 3D scenes render.
- **T18.7** `journalctl -u gmw-frontend -f` 5 min; confirm WS reconnect + live features.
- **T18.8** Notify user with before/after notes; keep rollback plan (`nix-env --set <previous>; systemctl restart`).
---
## Risks / Trade-offs
- **Scope:** 7 pages + charts + primitives + motion + 2 three scenes + layout. Big but mechanical; each task is isolated (~90 atomic tasks).
- **Bundle weight:** `three` adds ~150KB gz but ONLY on dashboard/voice routes (lazy chunk, `ssr:false`). `motion` ~35KB gz app-wide — acceptable.
- **WebGL compatibility:** covered by `WebGLGuard` + static fallback. Old devices / strict privacy browsers never blank.
- **Motion excess:** risk of "AI-generated" scattered animation. Guard: one signature moment per page, shared variants, reduced-motion gates.
- **Feature regressions:** Voice PCM playback, media transport, chatbot drag — logic preserved, only skin changes. Smoke test (T16) catches SSR breaks; live WS/3D needs real backend (staging T17).
- **Removed deps:** dropping `recharts`/`sonner`/`cmdk` means rewriting charts + toasts + search palette — accounted for in Phases 3/5/9.
- **Next standalone PORT bug:** `server.js` may not read `PORT` — ensure unit exports `PORT=4017` before exec (T17.4/T18.3).
- **three + React 19:** raw three avoids R3F compat surface; lifecycle (dispose + RAF) handled in T6.2.
- **next-themes:** keep (light/dark toggle); default dark.
## Open questions (answer before T18)
- Q1: Deploy to prod now or staging-only first? (Recommend staging + screenshot review.)
- Q2: Keep `react-day-picker`/`embla` if any page still needs them? (Plan assumes no — verify in T14.10 grep.)
- Q3: Any brand name/wordmark change from "Discord Automod"? (Keep "Bete" identity unless told.)
- Q4: 3D depth — full 3D scenes on dashboard+voice as specced, or also a 3D accent on media (disc)? (Default: dashboard+voice only; media disc stays CSS 3D.)
@@ -0,0 +1,500 @@
# GMW — Moderation Explainability (#1) + Semantic Search (#3) Implementation Plan
> **For Hermes:** Use subagent-driven-development to implement task-by-task.
> Hard constraint from user (2026-08-18): web is PUBLIC, read-only, for USERS not admins. Moderation MUST stay FULLY AUTOMATIC. Rules stay in CODE (no per-channel config UI).
**Goal:** Make GMW transparent (users see why a message was moderated) and searchable (users can semantic-search the message corpus), via two fully-automatic, code-driven, read-only-public features.
**Architecture:**
- **#1 Explainability:** Persist the structured moderation verdict that already exists in `AnalysisResult` (`flags[]`, `categories[]`, `severity`, `confidence`, `evidence[]`) into new columns on `moderation_actions`, surface them through the existing public moderation oRPC + the existing public `moderation` dashboard view. No new behavior — only new *data* + new *read* paths.
- **#3 Semantic Search:** Add a SECOND persistent Qdrant collection (`gmw_message_archive`) keyed by message id (NOT the TTL cache). Embed each captured text message at capture time (reuse `embedText`) and upsert. Add a public `messages.semanticSearch` oRPC + a read-only search UI on the public `messages` view. Best-effort / non-blocking — embed failures never affect moderation or capture.
**Tech Stack:** TypeScript (discord-gateway + backend + frontend monorepo), Drizzle ORM + Postgres (PgBouncer on imrnes), Qdrant (100.121.180.82:6333), Next.js 16 App Router + shadcn/ui, oRPC over `/trpc`. pnpm. Deploy via GitHub Actions Nix build + `systemctl restart`.
**Critical existing facts (verified in repo):**
- `AnalysisResult` shape (`src/modules/ai-moderation/ai-analysis-worker.ts:55`): `messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?`. The shared `AnalysisResult` (`src/shared/moderation-types.ts:142`) ALSO has `evidence?: string[]` and `policyVersion?: string`. **THESE ARE ALREADY COMPUTED but only logged, never persisted to `moderation_actions`.**
- `moderation_actions` schema is DEFINED TWICE with a divergence:
- `src/shared/database/schema.ts:638` → `pgModerationActionsTable` (authoritative, has `reset_nickname` in `action_type` enum).
- `src/shared/database/schema/messages.ts:25` → another `pgModerationActionsTable` (NO `reset_nickname`; gateway-local copy).
- The gateway's `ModerationActionsDb` (`src/modules/message-capture/moderationActionsDb.ts`) imports from `schema.ts` (the authoritative one). The `messages.ts` copy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm the `messages.ts` copy is dead and delete it. **Decision: add columns to `schema.ts` (authoritative) AND the `messages.ts` copy to keep `$inferInsert`/`$inferSelect` in sync (the gateway `ModerationAction` type flows from shared).** Line truncated
- Migration mechanism: Drizzle-managed via `drizzle/migrations/*.sql` (journal `_journal.json`) applied by `runMigrations()` → `migratePostgres`. **New tables/columns must be added with `drizzle-kit generate` to produce a numbered `.sql` + journal entry**, OR (simpler, matches `0013_rename_*.sql` manual style) write a raw idempotent `.sql` under `drizzle/migrations/` AND register it in `_journal.json`. **Preferred here: use `pnpm drizzle-kit generate` so the journal stays consistent.** The leLine truncated
- **Historical lesson (MUST respect):** a prior migration (`0004_drop_unused_ai_columns.sql` = old `001_drop`) DELETED `ai_evidence`, `ai_policy_version`, `ai_moderation_raw` from `messages` with the note "written but never read". → Our new `moderation_actions` columns MUST be read (serializer + FE render). No write-only columns.
- Embedding client: `embedText(text)` / `embedTexts(texts[])` in `src/modules/ai-moderation/embeddingClient.ts`. Returns `null` if `AI_LLM_EMBEDDING_MODEL` not configured. Reuses `config.AI_LLM_BASE_URL` + `config.AI_LLM_API_KEY`. OpenAI SDK v6 → `encoding_format: "float"` REQUIRED (Nvidia rejects base64).
- Qdrant client: `src/modules/ai-moderation/qdrantClient.ts`. Has `ensureQdrantCollection(vectorSize)`, `upsertQdrantPoint(cacheKey, vector, payload)`, `searchQdrant(vector, limit, scoreThreshold)`. These are hardcoded to the cache collection name `config.QDRANT_COLLECTION ?? "gmw_text_moderation"`. **#3 needs a second collection** → generalize the client to accept a collection name param (add `ensureQdrantCollectionV2(name, size)` / `upsertQdrantPointV2(name, id, vector, payload)` / `searchQLine truncated
- Capture hook: `captureMessage()` (`src/modules/message-capture/messageCapture.ts:201`) calls `messageStore.upsertMessageForCapture(messageRecord)` then (if not backlog) `queueMessageAnalysis`. **#3 embed must happen here**, async + fire-and-forget, after successful insert.
- Public moderation view: `services/frontend/src/app/(dashboard)/moderation/view.tsx` renders `ActionRow` per action. The `ModerationAction` FE type is in `services/frontend/src/lib/types/moderation.ts` (NO new fields yet). The backend `moderationService.listActions` SQL is in `services/backend/src/modules/moderation/moderation.repository.ts:68` (raw SQL, selects fixed columns, joins `messages`).
- oRPC wiring: `services/backend/src/orpc/router.ts` → `moderationRouter` (stats, actions) and `messagesRouter` (list, byChannel, getById, review, attachments). New procedures added here.
---
## TASK 1 — Schema: add explainability columns to `moderation_actions`
**Objective:** Persist structured verdict on moderation actions so it can be surfaced (read) later.
**Files:**
- Modify: `services/discord-gateway/src/shared/database/schema.ts` (authoritative `pgModerationActionsTable`, ~line 638)
- Modify: `services/discord-gateway/src/shared/database/schema/messages.ts` (`pgModerationActionsTable` copy, ~line 25) to keep type in sync
- Create: `services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql`
- Update: `services/discord-gateway/drizzle/migrations/meta/_journal.json` (add new entry)
**Step 1: Add columns to both schema definitions**
Add after `executed_at` in BOTH `pgModerationActionsTable` definitions:
```ts
// ── Explainability (structured verdict, surfaced read-only to public web) ──
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
categories: pgText("categories"), // JSON array of category strings
severity: pgText("severity", {
enum: ["none", "low", "medium", "high", "critical"],
}),
confidence: pgReal("confidence"), // 0..1
score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string
```
Note: `flags`/`categories`/`evidence` stored as JSON-stringified TEXT (consistent with how `messages.ai_moderation_flags`/`ai_categories` are stored as TEXT elsewhere — confirm storage format in `updateMessageAIAnalysis`). Keep nullable.
**Step 2: Generate/author the migration SQL**
`0015_add_moderation_explainability.sql` (idempotent):
```sql
-- Add structured explainability columns to moderation_actions (read-only surfaced to public web).
ALTER TABLE IF EXISTS "moderation_actions"
ADD COLUMN IF NOT EXISTS "flags" text,
ADD COLUMN IF NOT EXISTS "categories" text,
ADD COLUMN IF NOT EXISTS "severity" text
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
ADD COLUMN IF NOT EXISTS "confidence" real,
ADD COLUMN IF NOT EXISTS "score" real,
ADD COLUMN IF NOT EXISTS "evidence" text,
ADD COLUMN IF NOT EXISTS "policy_version" text;
```
Register in `_journal.json`: append an entry with `idx: 15`, a new unique `tag` (hash), `version`, `when` = Date.now(), `tag` short, `breakpoints: false`. Use `pnpm drizzle-kit generate` if possible to get a correct tag; otherwise hand-edit the journal carefully (copy an existing entry's shape).
**Step 3: Type-check gateway**
Run: `cd services/discord-gateway && pnpm typecheck`
Expected: PASS (no new compile errors).
**Step 4: Commit**
```bash
git add services/discord-gateway/src/shared/database/schema.ts \
services/discord-gateway/src/shared/database/schema/messages.ts \
services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql \
services/discord-gateway/drizzle/migrations/meta/_journal.json
git commit -m "feat(db): add explainability columns to moderation_actions"
```
---
## TASK 2 — Persist verdict at the auto-delete + command-handler call sites
**Objective:** Populate the new columns from the already-computed `AnalysisResult` when a moderation action is logged. Fully automatic, no new behavior.
**Files:**
- Modify: `services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts` (`logAutoDeleteAttempt` ~line 166, and the second `createModerationAction` call ~line 234 for nickname/mute paths)
- Modify: `services/discord-gateway/src/modules/command-handler/moderation.handler.ts` (`createModerationAction` ~line 95)
- Helper (create): `services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts` — shared mapper so all 3 call sites stay DRY.
**Step 1: Create the mapper helper**
`verdictToActionFields.ts`:
```ts
import type { AnalysisResult } from "@/modules/ai-moderation/ai-analysis-worker";
import type { ModerationActionInsert } from "@/shared/index"; // or inline shape
/**
* Map a computed AI verdict into the explainability columns of a moderation
* action. Null-safe: missing fields stay null (e.g. manual admin actions have
* no AnalysisResult). This is read-only structured data — it does NOT change
* any enforcement decision.
*/
export function verdictToActionFields(result?: {
flags?: string[];
categories?: string[];
severity?: string;
confidence?: number;
score?: number;
evidence?: string[];
policyVersion?: string;
}): {
flags: string | null;
categories: string | null;
severity: string | null;
confidence: number | null;
score: number | null;
evidence: string | null;
policy_version: string | null;
} {
if (!result) {
return { flags: null, categories: null, severity: null, confidence: null,
score: null, evidence: null, policy_version: null };
}
const j = (v: unknown) => (v == null ? null : JSON.stringify(v));
return {
flags: j(result.flags),
categories: j(result.categories),
severity: result.severity ?? null,
confidence: result.confidence ?? null,
score: result.score ?? null,
evidence: j(result.evidence),
policy_version: result.policyVersion ?? null,
};
}
```
**Step 2: Wire `logAutoDeleteAttempt`**
Find the `createModerationAction({...})` in `logAutoDeleteAttempt` and spread the verdict fields:
```ts
await messageStore.createModerationAction({
message_id: message.id,
user_id: message.user_id,
guild_id: message.guild_id,
action_type: "delete_message",
reason: result.reason,
...verdictToActionFields(result.analysisResult), // <-- pass the AnalysisResult through
executed_by: "auto-delete-manager",
status: ...,
});
```
**IMPORTANT:** `result` here is `AutoDeleteResult` — verify it carries the `AnalysisResult` (or the verdict). If `AutoDeleteResult` does NOT carry the full `AnalysisResult`, trace where `attemptAutoDeleteFlaggedMessage` is called from and pass the `AnalysisResult` down (it is available in the analysis worker that triggered the delete). Confirm by reading `AutoDeleteResult` type + its producer. If the verdict is only available at the orchestrator level, add an optional `verdict?: AnalysisResulLine truncated
**Step 3: Wire the second call site in `autoDeleteManager.ts`** (the mute/nickname path ~line 234) similarly, if it has an `AnalysisResult` available; otherwise leave fields null (manual-style action).
**Step 4: Wire `moderation.handler.ts`** command path (~line 95) — pass `verdictToActionFields(verdict)` if the command handler has the `AnalysisResult` for the target message; otherwise nulls. Confirm what the handler receives.
**Step 5: Type-check + lint**
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
Expected: PASS.
**Step 6: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts \
services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts \
services/discord-gateway/src/modules/command-handler/moderation.handler.ts
git commit -m "feat(mods): persist structured verdict into moderation_actions"
```
---
## TASK 3 — Backend: surface explainability in `moderation.actions`
**Objective:** Read the new columns in the public oRPC so the frontend can render them. (Read path — satisfies the "never write-only" rule.)
**Files:**
- Modify: `services/backend/src/modules/moderation/moderation.repository.ts` (`listActions` raw SQL ~line 68) — add new columns to SELECT + map.
- Modify: `services/frontend/src/lib/types/moderation.ts` (`ModerationAction` interface) — add new fields.
- Modify: `services/frontend/src/app/(dashboard)/moderation/view.tsx` (`ActionRow`) — render flags/categories badges + severity + confidence + evidence snippet.
**Step 1: Extend backend SELECT**
In `listActions`, add to the SELECT list: `a.flags, a.categories, a.severity, a.confidence, a.score, a.evidence, a.policy_version`. In the `.map(...)` add:
```ts
flags: r.flags ? safeJsonArray(String(r.flags)) : null,
categories: r.categories ? safeJsonArray(String(r.categories)) : null,
severity: r.severity ? String(r.severity) : null,
confidence: r.confidence != null ? Number(r.confidence) : null,
score: r.score != null ? Number(r.score) : null,
evidence: r.evidence ? safeJsonArray(String(r.evidence)) : null,
policy_version: r.policy_version ? String(r.policy_version) : null,
```
where `safeJsonArray(s)` = `JSON.parse(s)` wrapped in try/catch returning `[]` on failure (define a tiny local helper in the repository file).
**Step 2: Extend FE type**
In `services/frontend/src/lib/types/moderation.ts` `ModerationAction`:
```ts
flags: string[] | null;
categories: string[] | null;
severity: "none" | "low" | "medium" | "high" | "critical" | null;
confidence: number | null;
score: number | null;
evidence: string[] | null;
policy_version: string | null;
```
**Step 3: Render in `ActionRow`**
After the existing reason block, add (using existing `Badge` + `aiTone` from `@/lib/ai-status`):
```tsx
{a.severity && (
<Badge tone={aiTone(a.severity === "none" ? "clean" : a.severity)}>
{a.severity}
</Badge>
)}
{a.flags?.length ? (
<div className="mt-1 flex flex-wrap gap-1">
{a.flags.map((f) => <Badge key={f} tone="amber">{f}</Badge>)}
</div>
) : null}
{a.evidence?.length ? (
<div className="mt-1 text-xs text-ink-faint border-l-2 border-hairline pl-2">
“{a.evidence[0]}”
</div>
) : null}
{a.confidence != null && (
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
conf {(a.confidence * 100).toFixed(0)}%
</div>
)}
```
Keep `ActionRow` read-only. No admin controls.
**Step 4: Type-check both services**
Run: `cd services/backend && pnpm typecheck && pnpm lint` and `cd services/frontend && pnpm typecheck && pnpm lint`
Expected: PASS.
**Step 5: Commit**
```bash
git add services/backend/src/modules/moderation/moderation.repository.ts \
services/frontend/src/lib/types/moderation.ts \
services/frontend/src/app/\(dashboard\)/moderation/view.tsx
git commit -m "feat(web): surface moderation explainability (flags/severity/evidence)"
```
---
## TASK 4 — Qdrant client: support a second persistent collection
**Objective:** Generalize the Qdrant client so #3 can use a dedicated archive collection without disturbing the automod cache.
**Files:**
- Modify: `services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts`
**Step 1: Add collection-aware variants**
Refactor `collectionName()` to accept an optional name, and add V2 functions that take an explicit collection:
```ts
function collectionName(fallback = config.QDRANT_COLLECTION ?? "gmw_text_moderation"): string {
return fallback;
}
export const ARCHIVE_COLLECTION = config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
export async function ensureQdrantCollectionV2(name: string, vectorSize: number): Promise<boolean> {
// same body as ensureQdrantCollection but uses `name` instead of collectionName()
}
export async function upsertQdrantPointV2(
name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
): Promise<boolean> { /* PUT /collections/{name}/points with wait:true */ }
export async function searchQdrantV2(
name: string, vector: number[], limit: number, scoreThreshold: number,
): Promise<QdrantSearchHit[]> { /* POST /collections/{name}/points/search */ }
```
Keep all existing `ensureQdrantCollection` / `upsertQdrantPoint` / `searchQdrant` UNCHANGED (cache path). V2 functions mirror them with the `name` param. Reuse `request()` and the existing payload/score types.
**Step 2: Type-check**
Run: `cd services/discord-gateway && pnpm typecheck`
Expected: PASS.
**Step 3: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
git commit -m "feat(qdrant): add collection-aware V2 upsert/search for archive"
```
---
## TASK 5 — Capture-time embed + archive upsert
**Objective:** Make every (non-backlog, text) captured message searchable in the persistent archive. Non-blocking / best-effort.
**Files:**
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` (`captureMessage` ~line 201)
- Create: `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` — wraps embed + upsert with fire-and-forget + rate-limit guard.
**Step 1: Create `archiveEmbedder.ts`**
```ts
import { createChildLogger } from "@/shared/logger/index";
import { embedText } from "@/modules/ai-moderation/embeddingClient";
import { ARCHIVE_COLLECTION, ensureQdrantCollectionV2, upsertQdrantPointV2, qdrantPointId } from "@/modules/ai-moderation/qdrantClient";
import { config } from "@/shared/config/config";
const log = createChildLogger("archive-embedder");
/**
* Fire-and-forget: embed a captured message and upsert into the persistent
* archive collection. Failures are swallowed — searching is a nice-to-have,
* never a precondition for capture or moderation.
*/
export function archiveMessageEmbedded(message: {
id: string; content: string; username: string; channel_id: string; guild_id: string; created_at: number;
}): void {
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
if (!message.content || message.content.trim().length < 3) return;
void (async () => {
try {
const vector = await embedText(message.content);
if (!vector) return;
const ok = await ensureQdrantCollectionV2(ARCHIVE_COLLECTION, vector.length);
if (!ok) return;
await upsertQdrantPointV2(ARCHIVE_COLLECTION, qdrantPointId(`archive:${message.id}`), vector, {
text: message.content.slice(0, 4000),
flags: "", // not a verdict payload; keep shape compatible
analyzed_at: Date.now(),
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, // 5y persistent
content_hash: undefined,
});
} catch (err) {
log.debug({ messageId: message.id, error: err instanceof Error ? err.message : String(err) }, "archive embed skipped");
}
})();
}
```
Payload type reuse: `QdrantVerdictPayload` has `text`, `flags`, `analyzed_at`, `expires_at`, `content_hash?`. For the archive we only need `text` + timestamps; set `flags: ""` (empty, ignored by search filter which keys on `expires_at`). **Acceptable:** the search path filters `expires_at >= now` — 5y window satisfies that.
**Step 2: Call from `captureMessage`**
In `captureMessage`, after `const inserted = await messageStore.upsertMessageForCapture(messageRecord); if (!inserted) return;` and BEFORE the backlog branch, add:
```ts
if (!isBacklog && messageRecord.content) {
archiveMessageEmbedded(messageRecord);
}
```
(`messageRecord` is the `MessageRecord` from `buildMessageRecord`; confirm it carries `content`, `channel_id`, `guild_id`, `created_at`. It does — see `messagesCrud`/`types`.)
**Step 3: Type-check + lint**
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
Expected: PASS.
**Step 4: Commit**
```bash
git add services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts \
services/discord-gateway/src/modules/message-capture/messageCapture.ts
git commit -m "feat(archive): embed captured messages into persistent Qdrant archive"
```
---
## TASK 6 — Backend: `messages.semanticSearch` oRPC
**Objective:** Public, read-only semantic search over the message archive.
**Files:**
- Modify: `services/backend/src/modules/messages/messages.repository.ts` — add `semanticSearch(query, limit, guildId?)`.
- Modify: `services/backend/src/modules/messages/messages.service.ts` — expose `semanticSearch`.
- Modify: `services/backend/src/orpc/router.ts` — add `messages.semanticSearch` procedure.
- Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's `embeddingClient`. **Decision:** add a minimal backend embed helper `services/backend/src/modules/messages/embed.ts` that calls the same OpenAI-compatible endpoint via `config` (reuse `config.AI_LLM_BASE_URL`/`AI_LLM_API_KEY`/`AI_LLM_EMBEDDING_MODEL` if present on the backend; if not configured, return a clear "search unavailable" error). Mirror `encoding_format: "float"`.
- Modify: `services/backend/src/modules/messages/messages.schema.ts` — add `semanticSearchQuery` zod schema (limit, guildId?, query).
**Step 1: Backend embed helper** (`embed.ts`)
```ts
import OpenAI from "openai";
import { config } from "@/shared/config/index";
import { createChildLogger } from "@/shared/logger/index";
const log = createChildLogger("messages-embed");
let client: OpenAI | null = null;
function getClient() {
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
if (!client) client = new OpenAI({ apiKey: config.AI_LLM_API_KEY, baseURL: config.AI_LLM_BASE_URL, maxRetries: 0, timeout: 30_000 });
return client;
}
export async function embedQuery(text: string): Promise<number[] | null> {
const c = getClient(); if (!c) return null;
try {
const r = await c.embeddings.create({ model: config.AI_LLM_EMBEDDING_MODEL as string, input: text, encoding_format: "float" });
return r.data[0].embedding;
} catch (e) { log.warn({ error: e instanceof Error ? e.message : String(e) }, "query embed failed"); return null; }
}
```
**Step 2: Repository `semanticSearch`**
```ts
async semanticSearch(queryVector: number[], limit: number, guildId?: string) {
// Search archive collection, then join messages for text + channel.
const hits = await searchQdrantV2(ARCHIVE_COLLECTION, queryVector, limit, 0.6);
const ids = hits.map(h => h.cacheKey.replace("qdrant:", "")); // point id → we stored archive:<messageId>
// decode: qdrantPointId is a uint64; we need the original message id.
// SIMPLER: store message_id inside the payload too. → update archiveEmbedder payload to include `message_id`.
...
}
```
**REFINEMENT (important):** `qdrantPointId` is a hash, not reversible. So the archive payload MUST carry `message_id` (and `channel_id`, `guild_id`, `username`, `created_at`) so the backend can return full results without a reverse lookup. **Update `archiveEmbedder.ts` payload** to include those fields, and relax `QdrantVerdictPayload` (or create `QdrantArchivePayload`) to allow them. Then `semanticSearch` returns the payloads directly (already contain text + metadata) — no DB join needed, anLine truncated
**Step 3: Service + router**
`messages.service.ts`: `async semanticSearch(query: string, limit: number, guildId?: string)` → embed → `repository.semanticSearch`.
`orpc/router.ts` under `messagesRouter`:
```ts
semanticSearch: os
.input(z.object({ query: z.string().min(1), limit: z.coerce.number().int().positive().max(50).default(10), guildId: z.string().optional() }))
.handler(async ({ input }) => {
const results = await messagesService.semanticSearch(input.query, input.limit, input.guildId);
return { results, nextCursor: null };
}),
```
**Step 4: Type-check + lint (backend)**
Run: `cd services/backend && pnpm typecheck && pnpm lint`
Expected: PASS.
**Step 5: Commit**
```bash
git add services/backend/src/modules/messages/embed.ts \
services/backend/src/modules/messages/messages.repository.ts \
services/backend/src/modules/messages/messages.service.ts \
services/backend/src/modules/messages/messages.schema.ts \
services/backend/src/orpc/router.ts
git commit -m "feat(api): public semantic message search over archive"
```
---
## TASK 7 — Frontend: semantic search UI on `messages` view
**Objective:** Public, read-only search box + results on the existing messages dashboard.
**Files:**
- Modify: `services/frontend/src/app/(dashboard)/messages/page.tsx` + `view.tsx` — add a search input (debounced) that calls a new `useMessagesSemanticSearch` hook → `messages.semanticSearch` oRPC, renders results as message cards (reuse `GlassPanel`/`Badge`/existing message row components).
- Modify: `services/frontend/src/lib/types/message.ts` — add `SemanticSearchResult` + `SemanticSearchResponse` types.
- Modify: `services/frontend/src/lib/api/client.ts` (or `server.ts`) — add `semanticSearch` fetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add a `POST /api/messages/semantic-search` or an oRPC client call consistent with existing `messages.*` calls (follow the EXISTING pattern in `src/lib/api/` — inspect how `messages.list` is called and replicate).
**Step 1: Add FE types**
```ts
export interface SemanticSearchResult {
message_id: string;
content: string;
username: string;
channel_id: string;
guild_id: string;
created_at: number;
score: number;
}
export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: string | null; }
```
**Step 2: Add hook + wire view**
Follow the existing `use-moderation.ts` SWR pattern. Add `useMessagesSemanticSearch(query, guildId?)` returning `{ data, isLoading, error }`. In `messages/view.tsx`, add a search `Input` (from `@/components/primitives`) at the top, debounce ~300ms, and render results below the live list when a query is present. Reuse the message-row rendering already in that view (do not invent a new component).
**Step 3: Type-check + lint (frontend)**
Run: `cd services/frontend && pnpm typecheck && pnpm lint`
Expected: PASS.
**Step 4: Commit**
```bash
git add services/frontend/src/app/\(dashboard\)/messages/ \
services/frontend/src/lib/types/message.ts \
services/frontend/src/lib/api/ \
services/frontend/src/hooks/
git commit -m "feat(web): public semantic message search UI"
```
---
## TASK 8 — Build all services + deploy + verify
1. `cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lint`
2. `cd services/backend && pnpm typecheck && pnpm build && pnpm lint`
3. `cd services/frontend && pnpm typecheck && pnpm build && pnpm lint`
4. Commit any formatting fixes (biome `--unsafe` if import order), author `asepharyana`, NO Co-Authored-By.
5. `git push origin main` → watch `gh run watch` on "Build & Deploy (Nix)".
6. After deploy: verify `systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubState` reflects new timestamp; same for backend + frontend.
7. Smoke: `curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=<urlencoded json>` OR via the public web `imphnen.asepharyana.my.id` messages page → type a query → expect results (after some messages have been embedded; embeddings only run on NEW captures post-deploy, so seed a few test messages or backfill).
8. Moderation explainability: trigger/observe a flagged message → confirm `moderation_actions.flags` is populated (SQL `SELECT flags, severity FROM moderation_actions ORDER BY created_at DESC LIMIT 5;`) and the public moderation view shows badges.
---
## RISKS / TRADEOFFS / OPEN QUESTIONS
- **Embedding cost:** #3 embeds EVERY captured message → more embedding API calls. Mitigated: only text ≥3 chars, fire-and-forget, skip if model unconfigured. If cost is a concern, batch embed (reuse `embedTexts`) per capture burst — but start simple (per-message) and observe.
- **Backfill:** post-deploy, the archive is empty until new messages arrive. Optional follow-up: a one-off backfill script over existing `messages` (out of scope for this plan unless user asks).
- **Schema duplication:** the `pgModerationActionsTable` double-definition must stay in sync (Task 1 patches both). If `messages.ts` copy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk).
- **`AutoDeleteResult` verdict availability (Task 2):** requires confirming the `AnalysisResult` is reachable at the `createModerationAction` call sites. If not, we add an optional field to `AutoDeleteResult` at the orchestrator call site. This is the highest-risk integration point — verify before assuming.
- **Public exposure:** semantic search returns message text + usernames. This is INTENDED (web is public for users). No auth added. If a guild wants private, that is a future config (out of scope).
- **Qdrant payload type:** reusing `QdrantVerdictPayload` for archive is slightly awkward (carries `flags`/`expires_at` semantics). Cleaner: introduce `QdrantArchivePayload` with `message_id`, `channel_id`, `guild_id`, `username`, `content`, `created_at`, `expires_at`. **Prefer the dedicated payload type in Task 4/5** to avoid confusion.
## VERIFICATION CHECKLIST
- [ ] `moderation_actions` has 7 new columns (DB + both schema defs).
- [ ] A real auto-delete populates `flags`/`severity`/`evidence` (verified via SQL).
- [ ] Public moderation view renders badges + evidence (manual browser check on imphnen.asepharyana.my.id/moderation).
- [ ] New message capture upserts a point into `gmw_message_archive` (verify via Qdrant `/collections/gmw_message_archive/points/count`).
- [ ] `messages.semanticSearch` returns relevant results for a known phrase.
- [ ] All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.
@@ -0,0 +1,101 @@
# GMW — Fitur Publik Lanjutan (#2–#6) Implementation Plan
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service
> after its changes. Deploy via push to main (CI handles Nix build + systemd).
> Hard constraint (user 2026-08-18): public read-only web, fully automatic,
> rules in code, NO admin endpoints, NO shadow mode, NO per-channel web config.
> **EXPLICITLY EXCLUDED: User Reputation / Strike History** (user: "hapus
> sepenuhnya fitur user reputation" — it was never built; do not add it).
## Existing infra to reuse (verified)
- **WS**: backend `ws/server.ts` broadcasts JSON `{type,data,timestamp}` to
frontendClients. Backend `ws/redis-bridge.ts` subscribes Redis channels
listed in `DISCORD_CHANNEL_TO_WS_EVENT` (backend `shared/redis-channels.ts`)
and re-emits as WS events. FE `src/lib/ws` auto-reconnect typed client.
- **Gateway → Redis**: `EventBroadcaster` + `RedisEventPublisher` (
`discord-gateway/src/modules/event-broadcaster`). Publish via
`eventBroadcaster.publish(EventChannels.X, payload)`.
- **Moderation data**: `moderation_actions` table (now has explainability
cols). `moderation.repository.listActions` returns rows. `ModerationAction`
FE type at `frontend/src/lib/types/moderation.ts`.
- **Messages**: `messages.list` / `getMessagesByChannel` (backend oRPC +
repository). FE `messagesApi` + `useMessages`.
- **Charts**: NO chart lib installed. Use **pure SVG/CSS** (consistent with
repo; avoid new deps).
- **CSV**: client-side Blob download, no backend.
## Task 1 — Live Moderation Feed (#2)
**Gateway**: add `MODERATION_ACTION: "discord:moderation:action"` to
`redis-channels.ts` (shared) + `EventChannels.MODERATION_ACTION` in
`eventTypes.ts`. In `moderationActionsDb.createModerationAction`, after insert,
publish `eventBroadcaster.publish(EventChannels.MODERATION_ACTION, actionRow)`.
**Backend**: add `DISCORD_MODERATION_ACTION` constant + map
`[DISCORD_MODERATION_ACTION]: "moderation_action"` in `DISCORD_CHANNEL_TO_WS_EVENT`.
**FE**: in `src/lib/ws`, subscribe to `moderation_action`; add `useLiveModeration`
hook (SWR-style with WS push, capped buffer ~50). Add `<LiveModerationFeed>`
client component on `/moderation` page (top of list, animated new-row).
Risk: gateway publish at every action (already async insert) — fire-and-forget,
wrap in try/catch. Verify WS event reaches FE via `wscat`/curl or log.
## Task 2 — Toxic Topic Trends (#3)
**Backend**: add `moderation.trends` oRPC. Query `moderation_actions` grouped
by `categories` (jsonb text[]) over last 30 days, count per category + severity
breakdown. Also `action_type` distribution. Return
`{ categories: {name,count}[], severities: {level,count}[], actions: {type,count}[] }`.
Map jsonb array in SQL (use `unnest` or parse in JS). Reuse `getDatabase`.
**FE**: `useModerationTrends` hook + `<TopicTrends>` SVG bar chart (top 10
categories) + severity donut (SVG arcs). Place on `/moderation` as a panel.
## Task 3 — Channel Timeline / Replay (#4)
Reuse existing `messages.list` (guildId) + `getMessagesByChannel`. Add a
**Timeline tab** to `/messages` that groups messages by date (client-side
bucket from `created_at`). Load-more via cursor. No new backend (existing
`messagesRouter.list` already supports guildId+limit+cursor). If needed, add
`messages.timeline` aggregation (count per day) — but keep simple: client
groups fetched rows. Verify existing endpoint returns enough history.
## Task 4 — Export CSV (#5)
**FE only**. `lib/csv.ts` `toCsv(rows, columns)` + `downloadCsv(filename, csv)`.
Add "Export CSV" button on `/moderation` (exports current actions) and
`/messages` (exports current list). Pure client-side, read-only. No backend.
## Task 5 — Activity Heatmap (#6)
**Backend**: add `messages.activity` oRPC: per-channel message count grouped by
hour-of-day (0–23) over last 14 days. Return
`{ channels: {channelId, name, byHour: number[24]}[], max }`. Use SQL
`EXTRACT(hour from ...)` + group by channel. Channel name from
`message.metadata->'channel'->>'channelName'`.
**FE**: `useMessageActivity` hook + `<ActivityHeatmap>` SVG grid (channels ×
24h, color intensity = count/max). Place on `/messages` or `/dashboard`.
## Verification checklist
- [ ] `pnpm typecheck && pnpm lint && pnpm build` green for gateway, backend, frontend
- [ ] Backend `/trpc/moderation/trends` returns categories/severities/actions
- [ ] Backend `/trpc/messages/activity` returns byHour grids
- [ ] WS `moderation_action` received by FE (log or visible live row)
- [ ] No admin/write endpoint added; all public read-only
- [ ] No User Reputation code anywhere (grep "reputation|strike|reputasi")
- [ ] Deploy via push; all 3 services `running`; moderation + messages pages load
## Files touched (summary)
- gateway: `shared/redis-channels.ts`, `event-broadcaster/eventTypes.ts`,
`event-broadcaster/eventBroadcaster.ts`, `message-capture/moderationActionsDb.ts`
- backend: `shared/redis-channels.ts`, `orpc/router.ts`,
`modules/moderation/moderation.service.ts` (+repository),
`modules/messages/messages.service.ts` (+repository, +schema)
- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`,
`lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`,
`app/(dashboard)/messages/view.tsx`, new components under `components/`
## Status: COMPLETE (deployed + verified)
- Commit 9b3134d: features #2–#6 (live feed, trends, timeline, CSV export, heatmap)
- Commit 2a8f6d9: user reputation feature fully removed (643 deletions, no trace in src/tests)
- Migration 0016 applied: user_reputations DROPPED (DB verified: false)
- All 3 services active (gateway + backend restarted 18:29, frontend running)
- Gateway typecheck/lint/test(117 passed); backend typecheck/lint/build; FE lint/build — all GREEN
## Verification
- moderation/stats WS returns data (32 actions) → WS adapter works
- DB: user_reputations gone; moderation_actions explainability cols present
- Live Feed: gateway publishes discord:moderation:action → backend WS (same path as guild_member_*)
- Trends/Activity: backend router procedures registered (typecheck+tsc), same WS adapter
@@ -0,0 +1,119 @@
# GMW — Fitur Publik Lanjutan #7–#15 + Bug Fix Reputation Removal
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service after its
> changes. Deploy via push to main (CI handles Nix build + systemd). Apply any new
> drizzle migration MANUALLY (systemd does NOT run migrations).
> Hard constraint (user): public read-only web, fully automatic, rules in code,
> NO admin endpoints, NO shadow mode, NO per-user reputation aggregation.
## Bug fix discovered during planning (MUST do first)
`services/backend/src/modules/dashboard/dashboard.repository.ts` still references
`pgUserReputationsTable` (import line 8; JOINs at lines 173 + 457) — that table was
DROPPED in migration `0016`. `dashboard.listUsers` / `dashboard.userDetail` will
**crash at runtime** (undefined table). Remove the import + the `r.*` join columns
(`trust_score`, `clean_message_streak`, `total_infractions`) from both queries.
This is a regression introduced by the reputation removal commit.
## Features to implement (#7–#15)
All reuse existing infra: `moderation_actions`, `messages`, `channel_cultures`,
`term_glossary_cache`, `ai_analysis_runs`, `message_edits`, gateway cron (for #15),
WS (proven Live Feed pattern), oRPC over WS (proven), pure-SVG charts (no libs).
| # | Feature | Data source | Surface |
|---|---------|-------------|---------|
| 7 | Flagged Link / Scam Domain Reporter | regex URL from `moderation_actions.content`/`evidence` | `/moderation` |
| 8 | Top Flagged Channels | join `moderation_actions.message_id`→`messages.channel_id` | `/moderation` |
| 9 | Moderation Heatmap by Hour | `moderation_actions.created_at` hour-of-day | `/moderation` |
| 10 | Flag Category Drill-down | `moderation_actions.categories` (reuse Trends) | `/moderation` FE-only |
| 11 | Channel Culture Glossary | `channel_cultures` (exists) | new `/channels` panel |
| 12 | Term Knowledge Base | `term_glossary_cache` (exists) | new `/glossary` panel |
| 13 | Edit/Evasion Tracker | `message_edits` (exists) | `/messages` |
| 14 | Auto-mod Coverage Stats | `ai_analysis_runs` (exists) | `/moderation` metric tiles |
| 15 | Weekly Digest (auto, cron) | aggregate #7/#8/#9 → Discord via gateway cron | gateway cron + `/moderation` |
## Architecture per layer
### Backend (oRPC, `services/backend/src`)
- New repository methods (add to existing repos, follow `getTrends` SQL style):
- `moderation.repository.ts`:
- `getTopFlaggedDomains(days)` — `regexp_matches(content,'https?://([^/\s]+)')` on
`moderation_actions WHERE created_at>=since`, group by host, COUNT, order DESC LIMIT 20.
- `getTopFlaggedChannels(days)` — join `moderation_actions a` LEFT JOIN `messages m`
ON `m.id=a.message_id`, group by `m.channel_id`, COUNT, order DESC LIMIT 15.
Channel name via `m.metadata::jsonb->'channel'->>'channelName'`.
- `getHourlyModeration(days)` — `EXTRACT(HOUR FROM to_timestamp(created_at/1000))`
group by hour, COUNT, severity breakdown. (24 rows)
- `getFlaggedByCategory(days, category)` — list actions where `categories` contains
`category` (reuse `listActions` filter or new query), for drill-down #10.
- `getCoverage(days)` — from `ai_analysis_runs`: total runs, status breakdown
(clean/flagged/warn/error/pending), coverage % = (analyzed)/(captured in window).
- `dashboard.repository.ts` (or new `knowledge.repository.ts`):
- `listChannelCultures(limit, search?)` — `channel_cultures` rows (channel_id,
guild_id, channel_name from messages metadata, culture_summary, last_analyzed_at).
- `listGlossary(limit, search?)` — `term_glossary_cache` (term, definition, source_url,
resolved_at, hit_count) order by hit_count DESC.
- `messages.repository.ts`:
- `getEditHistory(limit, channelId?)` — `message_edits` join `messages` for
old_content + channel + username + edited_at, order DESC LIMIT.
- `moderation.service.ts` / `dashboard.service.ts` / `messages.service.ts`: thin wrappers.
- `orpc/router.ts`: add procedures (follow `trends` shape):
- `moderation.topDomains`, `moderation.topChannels`, `moderation.byHour`,
`moderation.byCategory` (input `{days,category}`), `moderation.coverage`.
- `dashboard.channelCultures`, `dashboard.glossary`.
- `messages.editHistory`.
### Frontend (`services/frontend/src`)
- `lib/types/moderation.ts`: add `FlaggedDomain`, `FlaggedChannel`, `HourlyModeration`,
`ModerationCoverage` interfaces.
- `lib/types/index.ts` (+ message.ts): add `ChannelCultureRow`, `GlossaryRow`, `EditHistoryRow`.
- `lib/api/moderation.ts`: add `topDomains`, `topChannels`, `byHour`, `byCategory`, `coverage`.
- `lib/api/dashboard.ts` (or messages.ts): add `channelCultures`, `glossary`, `editHistory`.
- `lib/api/server.ts`: add SSR seed fetchers (follow `getModerationStats`).
- `hooks/use-moderation.ts`: add `useTopDomains`, `useTopChannels`, `useHourlyModeration`,
`useByCategory`, `useCoverage`. `hooks/use-dashboard.ts`/`use-messages.ts`: add culture/glossary/edit hooks. `hooks/index.ts`: export all.
- New components (pure SVG/CSS, reuse `GlassPanel`/`SectionHeader`/`Badge`/`Donut`):
- `components/ScamDomains.tsx`, `components/TopChannels.tsx`, `components/ModerationHeatmap.tsx`,
`components/CoverageTiles.tsx`, `components/ChannelCultureGlossary.tsx`,
`components/TermGlossary.tsx`, `components/EditHistory.tsx`.
- Wire into `app/(dashboard)/moderation/view.tsx` (grid col-span-2/3/5 as space allows)
and `app/(dashboard)/messages/view.tsx` (EditHistory panel) and new route pages
`app/(dashboard)/channels/page.tsx` + `app/(dashboard)/glossary/page.tsx` with
matching `view.tsx` (follow existing page→view SSR pattern; check `app/(dashboard)/dashboard/page.tsx`).
- Export CSV buttons reuse `lib/csv.ts` `downloadCsv` (client-side) for domains/channels/edits.
### Gateway (#15 Weekly Digest)
- Add a cron/interval in `services/discord-gateway` (check existing scheduler pattern —
search `setInterval`/`cron` in `src`). On a 7-day cadence, query backend oRPC
(`dashboard.activity`, `moderation.trends`, `moderation.topChannels`) — OR compute
directly via a shared repository — and post a formatted summary to the monitor guild
channel (via existing `discordClient.channels.send` helper). Fully automatic, no UI.
## Files touched (summary)
- backend: `modules/moderation/{repository,service}.ts`, `modules/dashboard/{repository,service}.ts`,
`modules/messages/{repository,service}.ts`, `orpc/router.ts`, `shared/index.ts` (if new tables),
`lib/types/*` (FE)
- frontend: `lib/api/*`, `lib/types/*`, `hooks/*`, `components/*`, `app/(dashboard)/*`
- gateway: new digest scheduler + (none if reuse backend) maybe `shared/redis-channels.ts`
## Constraints / pitfalls (from gmw-ops skill)
- `created_at` is bigint epoch-MS — compare with `<`/`>`, do NOT divide by 1000 in SQL.
- Pure SVG only — frontend has ZERO chart libs.
- `Badge` Tone = signal|amber|vermilion|neutral (no "rose").
- Frontend WS import is `@/lib/ws/context`; method `on` not `subscribe`.
- Commit author `asepharyana`, no Co-Authored-By.
- Rebuild `dist/` after gateway changes; apply drizzle migrations manually.
## Verification
- Per service: `pnpm typecheck && pnpm lint && pnpm build` green.
- Gateway: `pnpm test` (117+ pass).
- Live: `moderation/stats` WS returns data (proves adapter); new procedures registered
(typecheck = proof). `systemctl show` new ActiveEnterTimestamp after deploy.
- DB: confirm `channel_cultures`/`term_glossary_cache`/`message_edits`/`ai_analysis_runs`
have rows before relying on them (some may be empty → components handle empty state).
## Execution order
1. Bug fix dashboard.repository (reputation JOIN) — deploy-safe.
2. Backend repositories + service + router (#7,#8,#9,#14 dashboard; #11,#12; #13).
3. FE types + api + hooks + components + wire (#7,#8,#9,#10,#11,#12,#13,#14).
4. Gateway #15 digest (if scheduler exists) — verify via log, not UI.
5. Build/lint all 3 services; commit; push; monitor CI; apply migrations; verify live.
@@ -0,0 +1,111 @@
# AI Analysis Flow — Audit & Optimization (discord-gateway)
**Goal:** Analisis alur AI analysis end-to-end, temukan bug/inconsistency yang merusak kualitas verdict, lalu perbaiki root cause-nya.
## Scope
- `services/discord-gateway/src/modules/ai-moderation/**`
- Tidak menyentuh chatbot backend / frontend.
## Alur saat ini (hasil tracing)
```
message capture → aiAnalyzer.queueMessageAnalysis(messageId)
→ batchScheduler.scheduleConversationAnalysis(conversationKey) [debounce 250ms, CB gate]
→ messageStore.getPendingMessagesByConversation(≤200)
→ skipAgeRestrictedMessages
→ pickBatchWithinBudget(14000 tokens, 50/msg)
→ processBatch [Piscina worker, ≤4 threads]
→ ai-analysis-worker.processBatch
→ getConversationContextBefore(20 msgs) + attachments
→ attachment-upload race guard (pending upload → skip)
→ runModerationAnalysis
→ Phase 1: exact-hash cache (PG text_analysis_cache, per channel/thread)
→ Phase 2: semantic cache (embedTexts → Qdrant batch search; PG fallback)
→ split text-only vs media
→ runTextOnlyBatch: URL fetch + wiki search + glossary (paralel)
→ dedup short messages → sub-batches (60/sub-batch)
→ vision evidence utk URL images (hoisted, 15s cap per image)
→ callModerationLLM per sub-batch (stream:true, retries 3, JSON parse + correction retry)
→ runMediaBatch: download → vision per image (cache LRU→DB→live, lock) → 1 LLM call
→ setCachedTextModeration (PG + Qdrant upsert w/ embedding)
→ normalizeResult (confidence clamp, fallback analysis)
→ updateMessagesAIAnalysisBulk → broadcast + scheduleAutoDelete
→ recovery worker tiap 10s: pending keys → re-schedule; incomplete → individual fallback queue
→ individual fallback: 1 msg = 1 worker job (context + full LLM)
→ cache prune tiap 6 jam (PG expired + Qdrant expired points)
```
## Temuan audit (ranked)
### F1 — Cache hit menghapus status "warn" (BUG AKURASI)
`moderationOrchestrator.ts` Phase-2 semantic hit & PG-fallback memetakan status via
`parseQdrantVerdict`: storedStatus bukan "warn"/"flagged" → dipaksa "clean".
TAPI exact-hash lookup (`getCachedTextModeration`, textCacheStore.ts:288-295) lebih parah:
hanya menerima "clean"|"flagged" — **"warn" jatuh ke branch flags.length===0 ? clean : flagged**
→ warn dengan flags=["conflict_instigation"] dibaca sebagai FLAGGED.
Efek: auto-delete eligibility (butuh recommendedAction delete/escalate + severity list) salah baca;
dashboard menampilkan flagged padahal verdict asli warn. Root cause: type narrowing legacy
(`status: "clean" | "flagged"`) tidak diupdate ketika "warn" ditambahkan ke schema.
### F2 — Exact-cache key mengabaikan edit (BUG EVASION)
Key = sha256(content)+context. Pesan yang DIEDIT (`edited_content`) menghasilkan hash berbeda,
tapi verdict lama utk konten pre-edit tetap hidup; lebih penting: pesan edited="true" adalah sinyal
evasion di prompt, sedangkan cache bisa menyajikan verdict dari konten lama jika content sama.
(Minor, tapi konsistensi: `resolveIsEdited` ada di prompt, tidak ada di cache key.)
### F3 — `pickBatchWithinBudget` skip-bukan-break (LATENSI/KUALITAS)
Loop `if (usedTokens + msgTokens <= maxTokens) {push}` — pesan BESAR di tengah list dilewati
dan iterasi lanjut mencoba msg berikutnya. Efek: batch berisi "lubang" (msg pending tetap pending,
dianalisis di gelombang berikutnya = LLM call tambahan). Ini by-design tolerable, tapi ada bug halus:
pesan >budget tunggal tidak pernah masuk (scheduler sudah punya fallback slice(0,1), OK).
Keputusan: biarkan (bukan bug nyata), catat saja.
### F4 — `callModerationLLM` max_tokens 16384 hardcoded (COST)
Sub-batch 60 pesan × output ~150 token/pesan ≈ 9k token cukup; 16k aman. Biarkan.
### F5 — Dead code builder user-profile/reputation
`buildUserProfilesBlock`, `buildUserProfileRef`, `UserProfileEntry` di moderationBuilders.ts
tidak dipakai lagi sejak context minimization (hanya tests). `<user_history>` juga tak pernah
di-inject (rules masih menyebutnya — misleading bagi model). Bersihkan referensi prompt.
### F6 — rules.ts menyebut `<user_history>` yang tidak pernah ada di payload
Model diberi instruksi tentang blok yang tak pernah muncul → pemborosan token + potensi
kelakuan aneh ("menunggu" data yang tak ada). Hapus/ubah kalimat.
### F7 — system.ts "Blok Data" menyebut `<term_glossary> (SearXNG)` — STALE
Sumber sudah Wikipedia. Komentar kode & teks prompt menyebut SearXNG. Perbaiki teks (kecil).
### F8 — output.ts typo "secifik", baris tabel `-|-` rusak
Kualitas prompt: typo + markdown table broken (`||-`) di beberapa baris. Rapikan.
### F9 — llmCaller parse-error correction tail hanya di SYSTEM
Correction tail ditambahkan ke system prompt; provider caching fine, tapi preview invalid
content (800 char) ikut SYSTEM — ok. Skip.
### F10 — `getLlmSemaphore` race kecil saat config berubah di tengah flight
Non-issue praktis (config statis per proses). Skip.
## Keputusan perbaikan (yang dieksekusi sekarang)
1. **F1 (utama):** normalisasi status di SATU tempat — `normalizeStoredStatus()` di
textCacheStore.ts yang menerima clean/warn/flagged; pakai di getCachedTextModeration
DAN parseQdrantVerdict; perluas return types ke union penuh. Orchestrator tinggal pakai.
2. **F6+F7+F8:** bersihkan stale references di prompts (user_history, SearXNG, typo).
3. **F5:** hapus dead builders + test-nya (biome/tsc yang jaga).
4. Regression test untuk F1 (vitest): warn tersimpan → warn terbaca (exact + qdrant path).
## Files touched
- services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts (F1)
- services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts (type only)
- services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts (F6)
- services/discord-gateway/src/modules/ai-moderation/prompts/system.ts (F7)
- services/discord-gateway/src/modules/ai-moderation/prompts/output.ts (F8)
- services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts (F5)
- services/discord-gateway/tests/contextEnrichment.test.ts (F5 test cleanup + F1 regression test baru)
## Verification
```
cd services/discord-gateway
npx tsc --noEmit
npx biome check --diagnostic-level=error .
npx vitest run
```
Semua harus hijau sebelum commit. Deploy via GHA (push main) — user konfirmasi belakangan.
@@ -0,0 +1,33 @@
# Optimisasi "non-issue" AI analysis pipeline
## Scope
Dua item yang sebelumnya dinyatakan non-issue, kini dioptimalkan + 1 bug ordering
yang ditemukan saat menelusuri:
1. **pickBatchWithinBudget: skip → break.** Pesan diurutkan `created_at ASC`
oleh DB. Setelah budget habis, pesan berikutnya pasti lebih besar/lebih kecil
arbitrer — skip-then-take menghasilkan batch non-kontigu (ada gap analisis
di tengah timeline). Ubah jadi stop at first overflow (break) supaya prefix
kronologis utuh; sisanya otomatis diambil gelombang berikutnya
(`shouldScheduleNext` sudah selalu true setelah sukses).
2. **max_tokens dinamis.** Hard-coded 16384 di llmCaller.ts → parameter
opsional `maxTokens?`; default tetap 16384. Caller text/media batch pass
nilai berbasis ukuran prompt (tiktoken) dengan floor/ceiling.
3. **Bug ordering UPDATE..RETURNING (bonus).** messagesAnalysis.ts
`getPendingMessagesByConversation`: SELECT ids di-order `created_at ASC`
tapi UPDATE...RETURNING tanpa ORDER BY → urutan rows balik tidak
terjamin. Konsumen pakai messages[0] sebagai anchor konteks
(beforeCreatedAt) dan pickBatchWithinBudget asumsi urutan. Fix: re-sort in
JS by created_at (stable) sebelum return.
## Files touched
- src/modules/ai-moderation/batchProcessor.ts — break bukan skip; test baru.
- src/modules/ai-moderation/llmCaller.ts — param maxTokens.
- src/modules/ai-moderation/textBatchProcessor.ts / mediaBatchProcessor.ts —
hitung token prompt & pass maxTokens.
- src/modules/message-capture/messagesAnalysis.ts — sort hasil RETURNING.
- tests/batchBudget.test.ts — baru.
## Verification
cd services/discord-gateway && bun run typecheck && bun run lint && bun run test
lalu commit+push, watch GHA, restart service via deploy pipeline.
@@ -0,0 +1,67 @@
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
## Masalah (audit)
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
play bareng (overlap).
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
dimount → dead code, user tidak lihat status musik di halaman lain.
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
## Desain
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
Custom player menggantikan `<audio controls>`:
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
clip otomatis pause yang lain.
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
### B. Recordings view — pasang player baru
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
### C. Media view polish
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
- Volume read-only tetap.
### D. MiniPlayer global
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
### E. Voice UI
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
- Listen: volume slider (input range) wired ke `listen.setVolume`.
- Mic volume slider wired ke `mic.setVolume`.
## File touched
| File | Aksi |
|---|---|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
| services/frontend/src/components/media/mini-player.tsx | new |
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
## Verifikasi
1. `pnpm lint` (biome) + `pnpm build` clean.
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
3. Commit (tanpa trailer) → push → `gh run watch` → live check
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
@@ -0,0 +1,127 @@
# Spec: Optimasi AI Analysis GMW — Naikkan Cache Hit Tanpa Kehilangan Akurasi
Tanggal: 2026-08-24 · Repo: `~/GMW` (branch `main`) · Service: `services/discord-gateway`
## Latar & Evidence (audit 2026-08-24)
State produksi:
- Qdrant `gmw_text_moderation`: **1.550 poin, status green** (vectors size 2048, Cosine).
- PG `text_analysis_cache`: 1.634 row `user_moderation`, 277 `vision_llm`; **sum(hit_count) = 0** →
hit-rate tidak pernah terukur.
- Embedding aktif (`AI_LLM_EMBEDDING_MODEL` set, Nemotron-embed, dim 2048), `AI_LLM_EMBEDDING_MIN_SIMILARITY`
tidak diset di BWS → default **0.97** (sangat konservatif).
- Messages: 9.375 total; 643 status `error` (banyak retry), 49 pending.
Temuan audit alur (`moderationOrchestrator.ts` → `textCacheStore.ts` → `qdrantClient.ts`,
`textBatchProcessor.ts`, `urlFetcher.ts`, `wikipediaClient.ts`, `visionAnalyzer.ts`):
| # | Temuan | Dampak |
|---|--------|--------|
| F1 | Exact-hash cache key menyertakan context (channel/thread) → teks sama di channel lain selalu miss | Killer hit-rate #1 |
| F2 | Semantic tier TIDAK memfilter context (Qdrant payload tak punya context) — sudah global tapi hanya aman krn sim 0.97 ketat | Inkonsisten dgn exact tier |
| F3 | Phase-1 lookup loop `await getCachedTextModeration(key)` per pesan → N round-trip PgBouncer per batch (60 msg = 60 query serial) | Latensi + beban DB |
| F4 | Verdict actionable (flagged/warn) dan clean sama-sama boleh di-serve semantic; toleransi akurasi beda | Risiko akurasi |
| F5 | `hit_count` tidak pernah di-increment oleh reader manapun | Hit-rate tak terukur |
| F6 | `wikipediaSearch()` (blok `<web_searches>`) tanpa cache — re-fetch tiap batch utk query sama | Latensi + spam ke WP |
| F7 | `fetchUrlSafely()` tanpa cache — link sama di batch berikutnya di-download lagi penuh | Latensi + bandwidth |
| F8 | Vision cache key dari data-URL base64 hasil resize → attachment sama via jalur berbeda (URL vs embed) = key beda → re-download + re-vision | Duplikasi kerja vision |
Non-goals: mengubah pipeline enforcement (auto-mute/ban trust-store writes), mengubah prompt
kebijakan moderasi, mengubah model/embedding provider.
## Desain
Semua perubahan degrade gracefully — cache gagal → perilaku lama (LLM). Akurasi dilindungi
asimetris: **hemat boleh untuk verdict non-actionable, konservatif untuk yang memicu aksi.**
### D1 — Cache metrics (F5)
- `textCacheStore.getCachedTextModeration()`: saat hit valid, increment `hit_count`
(`UPDATE ... SET hit_count = hit_count + 1`) fire-and-forget (`.catch(()=>{})`), jangan blokir return.
- Log info periodik ringkas di orchestrator sudah ada ("User moderation cache applied") — cukup.
### D2 — Batched exact-cache lookup (F3)
- Fungsi baru `getCachedTextModerations(keys: string[]): Promise<Map<string, StoredModerationVerdict>>`
di `textCacheStore.ts`: **satu** `SELECT ... WHERE text = ANY($1)` (chunk 200 key/query),
parse + `normalizeStoredStatus` per row (reuse helper existing).
- Orchestrator fase-1: kumpulkan semua key unik → satu call batched → distribusi hasil.
- Semantik identik dengan loop lama (row expired/error-artifact tetap miss); hanya jumlah round-trip
yang turun N→1.
### D3 — Global exact reuse untuk verdict non-actionable (F1)
- Key scoped-context TETAP ditulis (kompatibel, invalidasi moderator tetap presisi).
- Reader tambahan: kalau key `<ctx>:<hash>` miss, coba key legacy global `text_mod:<hash>` (bare).
- Guard akurasi (WAJIB semua terpenuhi):
- `status === "clean"` DAN `flags.length === 0`;
- `confidence >= AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE` (default 0.85);
- `recommendedAction === "none"`;
- umur entry ≤ `AI_CACHE_GLOBAL_REUSE_MAX_AGE_H` (default 72h) — cek `analyzed_at`.
- Flag baru `policyVersion: "cached-global-clean-2026-08"` supaya terlacak di dashboard/log.
- Verdict flagged/warn TETAP context-scoped (tidak pernah lintas channel).
### D4 — Semantic dua-band similarity (F2+F4)
- Config baru: `AI_LLM_EMBEDDING_MIN_SIMILARITY_ACTIONABLE` default **0.97** (perilaku lama),
`AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN` default **0.92**, keduanya coerce number 0..1.
- Satu Qdrant batch search pakai threshold RENDAH (0.92). Per hit, klasifikasi ulang:
- verdict non-actionable (clean, no flags, action=none): terima jika `score >= CLEAN_BAND`;
- verdict actionable (warn/flagged atau flags ada / action != none): terima hanya jika
`score >= ACTIONABLE_BAND` (0.97 — persis gate lama);
- di antara dua band → buang hit, pesan lanjut ke LLM (fail-open ke akurasi).
- Legacy PG fallback path: filter serupa di `findSimilarTextModeration` via parameter band.
### D5 — Cache Wikipedia search (F6)
- `wikipediaClient.wikipediaSearch(query)`: cek `cacheGet(makeCacheKey("wikisearch", q))` dulu;
miss → fetch (timeout existing) → sukses & hasil non-kosong → `cacheSet(..., TTL 6h)`.
Hasil kosong TIDAK di-cache (biar retry nanti). Redis down → langsung fetch (no-op cache).
### D6 — Cache URL text fetch (F7)
- `urlFetcher.fetchUrlSafely(url)`: wrapper async memoize in-process LRU (max 500, TTL 30 menit)
untuk `type === "text"` saja (image tetap selalu fresh-download karena dipakai sbg bukti vision
+ buffer besar; error tidak di-cache).
- Import `LRUCache` dari `lru-cache` (sudah dep gateway).
### D7 — Unified vision cache key (F8)
- `makeImageCacheKey(imageUrl)` di `textCacheStore.ts`: sebelum hash, strip query Discord CDN
(`?ex=&is=&hm=` signed tokens, `format/width/height/size`) — regex `(\?[^#]*)$` dibuang bila host
CDN discord (`cdn.discordapp.com`, `media.discordapp.net`, `images-ext-*.discordapp.net`);
URL non-Discord: hash full URL seperti sekarang.
- Efek: attachment sama yang lolos lewat jalur embed vs inline vs re-fetch dgn token beda → SATU
entry cache → skip download+vision kedua kali. Data-URL base64 tetap di-hash apa adanya.
## File yang disentuh
1. `src/shared/config/index.ts` — 3 config baru (D3×2, D4×2 — total 4 nilai, 3 baris zod + deskripsi).
2. `src/modules/ai-moderation/textCacheStore.ts` — hit_count inc (D1), batched getter (D2),
global-reuse guard helper (D3), image-key normalize (D7).
3. `src/modules/ai-moderation/moderationOrchestrator.ts` — pakai batched getter (D2),
global bare-key fallback (D3), dua-band semantic accept (D4).
4. `src/modules/ai-moderation/qdrantClient.ts` — `searchQdrantBatch` menerima threshold rendah
(sudah parametrik — mungkin tanpa perubahan; verifikasi).
5. `src/modules/ai-moderation/wikipediaClient.ts` — cache layer (D5).
6. `src/modules/ai-moderation/urlFetcher.ts` — LRU text-fetch memoize (D6).
## Schema/type changes
- Tidak ada migrasi DB (kolom `hit_count`, `analyzed_at`, `expires_at` sudah ada).
- Tidak ada perubahan kontrak WS/oRPC/frontend.
- Type baru: none public; internal `StoredModerationVerdict` dipakai ulang.
## Verification
1. Unit tests baru (`tests/`):
- `cacheBatchLookup.test.ts`: batched getter — hit/miss/expired/error-artifact mapping,
chunking >200 keys (mock executeAll), hit_count increment called.
- `globalReuseGuard.test.ts`: guard menerima clean+conf≥0.85+action none+umur ≤72h;
menolak flagged/warn/conf rendah/action≠none/stale.
- `semanticBands.test.ts`: clean @0.93 diterima, flagged @0.93 ditolak, flagged @0.98 diterima.
- `imageKeyNormalize.test.ts`: URL Discord dgn/ex token → key sama; non-Discord beda query → beda.
2. Gate service: `pnpm typecheck && pnpm exec biome check --diagnostic-level=error . && pnpm exec vitest run`.
3. Deploy via GHA (`git push origin main`) → watch `Build & Deploy (Nix)` → verifikasi
`systemctl show gmw-discord-gateway -p ActiveEnterTimestamp` baru.
4. Runtime probe pasca-deploy: journalctl level 30 normal; beberapa jam kemudian
`SELECT sum(hit_count) FROM text_analysis_cache WHERE source='user_moderation'` > 0 membuktikan
metrics jalan; log "User moderation cache applied" menunjukkan hits>0 pada traffic ramai.
## Rollback
Semua fitur behind config defaults yang mempertahankan perilaku lama pada nilai konservatif;
rollback = redeploy commit sebelumnya (tanpa migrasi DB, tanpa state eksternal).
@@ -0,0 +1,56 @@
# Spec: Perbaiki Delay Attachment 162s→<20s (GMW AI Analysis)
Tanggal: 2026-08-24 · Repo `~/GMW` · Service discord-gateway
## Evidence (audit produksi)
Klaster pesan attachment delay ~330–400 detik. Trace pesan `1541417073245290638` (.gif):
19:01:08 dibuat → 19:01:09 batch incomplete → fan-out individual → **guard upload-pending
mengembalikan `results:[]`** → diperalakukan sukses (`complete ... (undefined)`) → row
tertahan `ai_status='processing'` **tanpa penanggung jawab** → 19:06:12 cleanup mengembalikan
ke `pending` (tepat 300s) → baru dianalisis. Plus vision gagal 3× utk GIF besar
("Stream ended before producing a non-ping SSE event") → degradasi teks.
## Root causes
- **A (fatal)**: `individualFallbackProcessor.processIndividualFallback` memperlakukan
`ok:true + results:[]` sebagai sukses. Race-guard upload di `ai-analysis-worker.processIndividual`
sengaja balik `results:[]` (desain lama) → pesan yatim `processing` sampai cleanup 300s.
- **B**: `llmVision` hanya mencoba `stream:true`; kegagalan SSE truncation pada gambar besar
= 3 retry sia-sia (semua jalur sama) → bukti media hilang.
- **C**: safety-net cleanup 300s terlalu lambat sbg satu-satunya pemulih `processing`.
## Fix
1. **F1 — sinyal eksplisit upload-pending**: `IndividualOkResponse` + field opsional
`uploadPending?: boolean`. Worker set `uploadPending:true` saat race guard kena.
2. **F2 — processor menangani 3 kondisi** via helper murni baru
`classifyIndividualWorkerResult(result): "success" | "upload_pending" | "incomplete" | "error"`
(modul baru `fallbackResultClassifier.ts`, zero-dep agar mudah dites):
- `upload_pending` → tulis ulang row ke `pending` (pola sama dgn revert apiFailed di
batchProcessor) + broadcast + **re-schedule analisis percakapan segera**
(dynamic import batchScheduler, pola anti-siklus yg sudah ada) → retry dalam ~250ms
begitu upload beres. Bukan error, tidak naikkan CB counter.
- `incomplete` (flags analysis_incomplete) → perilaku lama (exhausted path).
- `error` / `results kosong tanpa penjelasan` → throw transien (retry oleh recovery),
BUKAN sukses palsu. Log "(undefined)" hilang.
3. **F3 — vision non-stream fallback**: di `llmVision`, jika error match
`/Stream ended before producing a non-ping SSE|stream ended/i` → coba SEKALI lagi dengan
`stream:false` (router agregasi penuh; timeout tetap 60s). Konversi hard-fail jadi sukses.
4. **F4 — turunkan safety net**: default `revertStuckProcessingMessages` 300000 → 120000 ms.
## File disentuh
- `src/modules/ai-moderation/fallbackResultClassifier.ts` (BARU, pure)
- `src/modules/ai-moderation/ai-analysis-worker.ts` (tipe + set flag uploadPending)
- `src/modules/ai-moderation/individualFallbackProcessor.ts` (konsumsi classifier + reschedule)
- `src/modules/ai-moderation/llmClient.ts` (fallback non-stream di llmVision)
- `src/modules/message-capture/messagesCleanup.ts` (default 120s)
## Verifikasi
- Test baru `tests/fallbackResultClassifier.test.ts` (4 klasifikasi + edge kosong).
- Gate: tsc --noEmit, biome error-level, vitest run semua hijau.
- Deploy GHA sukses; pasca-deploy: pesan attachment baru p50 < 20s
(`SELECT percentile_cont(0.5) ... WHERE metadata attachments>0 AND created_at > deploy`),
tidak ada lagi "complete ... (undefined)".
@@ -0,0 +1,205 @@
# GMW FE Rombak — "Constellation Ops" Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
> Setiap task = edit → lint → build/test → smoke → commit (author `asepharyana`, tanpa Co-Authored-By).
**Goal:** Rombak total FE GMW dari template dashboard klasik menjadi **Constellation Node Graph** — graf node interaktif (three.js force layout) di mana channel/event/moderasi adalah node bintang dan navigasi adalah terbang antar node — untuk semua 9 route sekaligus.
**Architecture:** Hapus total shell template (TopBar + NavRail + MobileNav + main scroll container). Ganti dengan satu `ConstellationStage`: canvas full-bleed fixed di belakang seluruh app yang me-render force-directed graph per-route (scene), plus overlay HTML mengambang (tanpa card box standar) untuk konten detail. Router tetap Next App Router; perpindahan route = kamera "fly-to" ke scene baru. Data tetap lewat pola SSR seed (`page.tsx` server fetch → `view.tsx` client + SWR fallbackData) Line truncated
**Tech Stack:** Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 (token oklch existing) · three r180 (+ `@types/three`) · d3-force (+ `@types/d3-force`) untuk simulasi layout deterministik · motion v12 (transisi antar-scene) · biome (0 error **dan** 0 warning) · bun:test untuk modul murni.
---
## Keputusan Terkunci (2026-08-24, user MythEclipse)
| Keputusan | Pilihan user |
|---|---|
| Cakupan | **Semua 9 route sekali jalan** (dashboard, voice, media, recordings, moderation, messages, analysis, channels, glossary) |
| Arah struktural | **Constellation Node Graph** — navigasi = terbang antar node |
| Tema | **Pertahankan toggle light/dark** (jangan rebuild sistem tema — harden saja) |
## Kondisi Saat Ini (fakta repo, 2026-08-24)
- Repo: `/home/code/GMW/services/frontend` (monorepo GMW, deploy Nix-first via GHA "Build & Deploy (Nix)" ke `imphnen.asepharyana.my.id`; nginx gmw-proxy :4009 → Next standalone :4017).
- Shell aktif: `src/components/shell/ambient-app.tsx` → `AppFrame` = NavRail + TopBar + `<main>` scroll + MobileNav + MiniPlayer, dipakai SEMUA route via `(dashboard)/layout.tsx`. AmbientCanvas (233 l) masih ada sebagai background di belakang chrome template.
- 9 route di `src/app/(dashboard)/`: dashboard (368 l), voice (311), media (293), moderation (424), messages (611), analysis (196), recordings (201), channels (23), glossary (15).
- `next.config.ts`: `output: "standalone"` + `trailingSlash: true` → **nav via `router.push`/Link bisa no-op; pakai `<a href>` polos** (pitfall terverifikasi).
- Token desain ada di `globals.css` `@theme`: `--color-canvas/-2`, `--color-ink/-soft/-faint`, `--color-signal(+glow)`, amber, vermilion, hairline; font Bricolage Grotesque/JetBrains Mono; `.light` class override + next-themes sudah jalan — JANGAN dibangun ulang.
- Sudah ada & dipertahankan: CommandPalette (⌘K), Chatbot, MiniPlayer, primitives (GlassPanel/GlassCard/Badge/…), shared states+skeletons, `lib/format.ts`, `lib/ai-status.ts`, WS typed events (`message_created/updated/deleted/analyzed/snapshot*`, `voice_state`, dll).
- Tooling lokal: `pnpm lint|build|format`, `pnpm exec biome check --write --unsafe .` buat import order; bun 1.3.14 tersedia untuk unit test modul murni.
- Smoke test SELALU di port non-prod (mis. 4024), cek `ss -ltnp | grep <port>` dulu; JANGAN pernah bind 4017.
## Struktur Target
```
src/components/shell/
constellation-frame.tsx # pengganti AppFrame: tanpa topbar/rail/bottom-bar
constellation-stage.tsx # canvas three.js full-bleed + hit-test + kamera
route-scenes.ts # map pathname → scene config (nodes/edges/focus)
floating-chrome.tsx # brand mark + status dot + route switcher minim + palette hint
(hapus setelah migrasi): topbar.tsx, nav-rail.tsx, mobile-nav.tsx, ambient-app.tsx*
src/lib/constellation/
graph.ts # tipe GraphNode/GraphEdge + builder dari data API (murni)
layout.ts # wrapper d3-force deterministik (seeded) + fallback radial (murni)
camera.ts # interpolasi kamera fly-to (murni, testable)
*.test.ts # bun:test utk modul murni di atas
```
*ambient-canvas/context dievaluasi: bisa di-refactor jadi bagian dari stage atau dihapus.
### Scene per route (bahasa visual satu kesatuan)
| Route | Scene |
|---|---|
| `/dashboard/` | Guild = bintang pusat; channel = node orbit; event WS = pulse edge; metric cluster floating (tanpa box) |
| `/channels/` | Semua channel sebagai konstelasi; klik channel = fly-to + panel detail mengambang |
| `/moderation/` | Pesan flagged = node merah (vermilion) mengelilingi hub AI-verdict; LiveModerationFeed jadi ribbon ticker |
| `/messages/` | Stream pesan = sabuk orbital; node baru masuk lewat animasi dari tepi |
| `/voice/` | Stage node di tengah; speaker aktif = node mengorbit dengan glow intensitas |
| `/media/` | Galeri spiral node (thumbnail via div bg-image); klik = MiniPlayer handoff |
| `/recordings/` | Variasi spiral media, timeline density sebagai cincin |
| `/analysis/` | Hub analitik; chart existing dirender sebagai panel translusen mengambang ter-anchor ke node |
| `/glossary/` | Term KB = cincin satelit; search memfilter node real-time |
### Checklist anti-default (wajib lolos sebelum commit akhir)
- [ ] Tidak ada top bar / nav rail / side panel / bottom prompt bar
- [ ] Tidak ada card grid standar sebagai struktur utama
- [ ] Layout beda SPASIAL dari versi lama (bukan ganti warna)
- [ ] Verifikasi visual sungguhan: browser_navigate + browser_vision → "bukan template dashboard"
---
## Tasks
### Phase 0 — Fondasi
#### Task 1: Dependencies + scaffold direktori
**Files:** `package.json`, create `src/lib/constellation/`
1. `cd /home/code/GMW/services/frontend && pnpm add three @types/three d3-force @types/d3-force && pnpm add -d @types/bun`
2. Buat 4 file kosong bertipe sesuai struktur target (ekspor placeholder bertipe agar tsc lolos).
3. Run: `pnpm exec tsc --noEmit` → expected PASS.
4. Commit: `chore(frontend): scaffold constellation lib + deps`.
> Catatan: jika `@types/bun` bentrok dengan tsconfig Next (duplikat globals), fallback: exclude `"**/*.test.ts"` dari `tsconfig.json` dan jalankan test hanya via `bun test` (tanpa typecheck). Putuskan saat eksekusi, dokumentasikan di commit body.
#### Task 2 (TDD): Modul murni graph + layout + camera
**Files:** `src/lib/constellation/{graph,layout,camera}.ts` + `*.test.ts`
1. **Tulis test dulu** (bun:test):
- `graph.test.ts`: builder `dashboardToGraph()` menghasilkan node guild pusat + N channel + edges benar dari fixture `/api/dashboard/stats|channels` shape (lihat AGENTS.md; nama channel di `message.metadata.channelName`).
- `layout.test.ts`: `computeLayout(nodes, edges, {seed:42})` DETERMINISTIK (dua pemanggilan = posisi identik), semua posisi finite, radius dalam bound; `radialLayout()` fallback untuk reduced-motion.
- `camera.test.ts`: `flyTo(from,to,t)` easing monotonic, t=0→from, t=1→to.
2. Run: `bun test src/lib/constellation` → expected FAIL (belum diimplementasi).
3. Implementasi minimal sampai PASS.
4. Run: `bun test src/lib/constellation && pnpm exec tsc --noEmit` → PASS.
5. Commit: `feat(frontend): constellation graph/layout/camera pure modules (tested)`.
### Phase 1 — Shell Baru
#### Task 3: `ConstellationStage` (canvas renderer)
**Files:** `src/components/shell/constellation-stage.tsx`
- `<canvas>` fixed inset-0 `-z-10`; three.js orthographic 2D-ish render node+edge (sprite/glow shader sederhana — jangan over-engineer; titik + garis + glow cukup).
- Interaksi: drag = pan, wheel/pinch = zoom, hover = highlight node + tooltip title, click node ber-`href` = navigasi via `<a>` semantics (window.location assign — hindari router.push no-op).
- DPR-aware resize; pause render loop saat `document.hidden`; `prefers-reduced-motion` → render statis tanpa animasi.
- Sampling warna dari CSS var (`getComputedStyle`) + `MutationObserver` pada `documentElement.classList` agar ikut toggle dark/light.
- Verify: `pnpm lint && pnpm build` PASS → commit `feat(frontend): constellation stage canvas renderer`.
#### Task 4: `route-scenes.ts` + `ConstellationFrame`
**Files:** `src/components/shell/{route-scenes,constellation-frame,floating-chrome}.tsx`
- `route-scenes.ts`: `usePathname()` → config scene (builder mana, focus node, overlay slots).
- `ConstellationFrame`: render `ConstellationStage` + `children` (overlay HTML absolute, BUKAN flex column bertingkat) + `floating-chrome` (brand mark kiri-atas dgn status dot WS, switcher route minimal kanan-bawah, hint ⌘K). MiniPlayer + Chatbot + CommandPalette tetap termounting.
- Switcher & semua nav internal pakai `<a href="/<route>/">` polos (trailingSlash!). Verifikasi klik di browser nanti, bukan cuma baca kode.
- Wire `(dashboard)/layout.tsx`: ganti `AppFrame` → `ConstellationFrame` (provider Ambient/WS tetap).
- Smoke: `ss -ltnp | grep 4024` kosong → `PORT=4024 pnpm start` → curl semua 9 route = 200 → kill server.
- Commit: `feat(frontend): replace classic shell with constellation frame`.
#### Task 5: Hapus chrome lama + bersih-bersih
**Files:** hapus `shell/topbar.tsx`, `shell/nav-rail.tsx`, `shell/mobile-nav.tsx`, update `shell/index.ts`; eval `ambient-*` (refactor jadi layer stage ATAU hapus).
- `grep -rn "TopBar\|NavRail\|MobileNav\|AppFrame" src/` → 0 referensi tersisa.
- `pnpm exec biome check --write --unsafe .` → import order rapi.
- Commit: `refactor(frontend): remove classic dashboard chrome`.
### Phase 2 — Migrasi 9 Route (batch, tiap batch ship hijau)
Urutan sengaja dari yang paling graph-native ke paling padat konten:
#### Task 6: `/dashboard/` + `/channels/`
- Dashboard view: guild-star + channel orbit + WS pulse; metric cluster floating (reuse MetricTile tapi tanpa card-box — ubah jadi teks besar + hairline).
- Channels: konstelasi channel, klik = fly-to + panel detail (data dari hook `use-channels`/server fetch existing — jangan karang endpoint).
- Lint+build+smoke 2 route → commit `feat(frontend): dashboard & channels constellation scenes`.
#### Task 7: `/glossary/` + `/analysis/`
- Glossary: cincin satelit term + search filter node (client-side filter pada node label).
- Analysis: hub node + panel chart translusen mengambang (reuse komponen charts/* existing, anchor posisi ke node).
- Commit: `feat(frontend): glossary & analysis scenes`.
#### Task 8: `/messages/` + `/moderation/`
- Messages: sabuk orbital stream (reuse sortMessages/logika WS `use-messages.ts` — jangan duplikasi); pesan baru animate-in dari tepi.
- Moderation: flagged nodes vermilion + hub verdict; LiveModerationFeed jadi ticker ribbon bawah (bukan panel).
- Commit: `feat(frontend): messages & moderation scenes`.
#### Task 9: `/voice/` + `/media/` + `/recordings/`
- Voice: speaker aktif orbit dgn glow (seed dari snapshot `activeSpeakers` server-authoritative — pertahankan perilaku useSpeakers).
- Media/Recordings: spiral galeri; thumbnail pakai div background-image (hindari `noImgElement`; kalau terpaksa `<img>` → `// biome-ignore lint/performance/noImgElement: <alasan>`); MiniPlayer handoff tidak boleh regress.
- Commit: `feat(frontend): voice, media & recordings scenes`.
### Phase 3 — Polish, A11y, Tema
#### Task 10: Motion & micro-interaction
- Fly-to antar scene pakai `motion` v12 (interpolasi kamera dari `lib/constellation/camera.ts`).
- Overlay: stagger masuk (pattern `animate-stagger` + `staggerDelay(i)` existing); tambah semua `animate-*` baru ke kill-list `prefers-reduced-motion` di `globals.css`.
- Commit: `feat(frontend): scene transitions + motion polish`.
#### Task 11: A11y & fallback semantik
- Setiap scene punya fallback semantik: `<nav aria-label="Routes">` visually-hidden berisi link semua route + `<ul>` node (label + href) yang ter-sync dgn graph state.
- Keyboard: Tab ke daftar route; panah memindahkan fokus antar node; Enter aktivasi; `focus-visible` ring pakai `--color-ring`.
- Kontras ink-on-canvas dicek (WCAG AA utk teks overlay).
- Hooks-after-early-return pitfall: compute flags dulu, guard di dalam useEffect.
- Commit: `feat(frontend): a11y semantic fallback for constellation`.
#### Task 12: Hardening tema light/dark
- Pastikan canvas sampling var `.light` benar saat toggle (MutationObserver dari Task 3); harden `.light`: `color-scheme: light`, scrollbar/selection, glow opacity turun.
- JANGAN menyentuh sistem next-themes.
- Commit: `fix(frontend): light theme parity for constellation stage`.
### Phase 4 — Gerbang Ship (non-negotiable)
#### Task 13: Verifikasi penuh + deploy
1. `pnpm lint` → **Found 0 errors. Found 0 warnings** (warning pun harus bersih).
2. `bun test src/lib/constellation` → all PASS; `pnpm exec tsc --noEmit` PASS; `pnpm build` sukses.
3. Smoke: port 4024 bersih → start → curl 9 route (dgn trailing slash) semua **200** → kill server.
4. `git status` audit: hanya file scope yang ter-commit (`git checkout --` file liar hasil auto-format).
5. Push `origin/main` → `gh run watch` "Build & Deploy (Nix)" → hijau.
6. Live: curl `https://imphnen.asepharyana.my.id/<route>/` × 9 → 200.
7. **Verifikasi visual sungguhan**: browser_navigate ke live URL → browser_vision: "Apakah ini masih template dashboard?" — jawaban HARUS bukan; screenshot dikirim ke user (`MEDIA:<path>`).
8. Checklist anti-default dicentang satu per satu di PR/commit message akhir.
---
## Files Likely to Change
```
package.json, pnpm-lock.yaml
next.config.ts # hanya jika perlu (tidak diharapkan)
src/app/(dashboard)/layout.tsx # wire frame baru
src/app/(dashboard)/{9 route}/page.tsx|view.tsx # scene migration
src/components/shell/** # frame baru + hapus lama
src/lib/constellation/** # baru (murni + test)
src/app/globals.css # tokens tambahan + reduced-motion kill list
biome.json # hanya jika rule intentional baru perlu "off"
```
## Risks / Tradeoffs
| Risiko | Mitigasi |
|---|---|
| Perf GPU di device lemah (full-page WebGL) | Node count kecil (<200/route), pause saat tab hidden, fallback radial statis utk reduced-motion; jika tetap berat → downgrade ke Canvas2D renderer dgn API sama |
| router.push no-op (standalone+trailingSlash) | Semua nav pakai `<a href>`; verifikasi klik nyata di browser |
| Konten padat (messages 611 l) tak muat di metafora graf | Prinsip: graf = struktur/navigasi; DETAIL tetap lewat overlay panel mengambang — konten tidak dikorbankan, hanya wadahnya yang berubah |
| Bun test + Next tsconfig konflik types | Exclude `*.test.ts` dari tsconfig (keputusan Task 1) |
| Regress fitur existing (theme toggle, palette, chatbot, mini-player, WS sync) | Dipertahankan apa adanya; smoke + klik verifikasi per phase; sortMessages & snapshot voice TIDAK disentuh logikanya |
## Open Questions
- Tidak ada blocker. Satu preferensi opsional: apakah brand mark perlu jam/uptime seperti TopBar lama — default: tidak (minimal). Keputusan bisa diambil saat review Task 4.
@@ -0,0 +1,39 @@
# GMW FE — Monokrom Hitam-Putih + Sidebar Ala Menu Game + Ringan di Mobile
Tanggal: 2026-08-24 · Basis: `eda5c75` (shell usable hasil revert)
## Tujuan
1. Tema **monokrom murni** (hitam-putih, tanpa warna) di dark & light.
2. Sidebar (desktop NavRail + mobile dock) beranimasi **ala menu game** — corner
brackets, sweep, stagger masuk, marker segitiga.
3. **Ringan di mobile**: matikan WebGL ambient di layar kecil, kurangi biaya
blur/backdrop, animasi transform/opacity saja.
## Non-goals
- Tidak menyentuh backend, endpoint, hooks/data-flow, struktur route.
- Tidak menambah dependensi baru (CSS murni untuk semua animasi).
## File yang disentuh
| File | Perubahan |
|---|---|
| `src/app/globals.css` | Token mono (dark+light): signal/amber/vermilion → skala putih-abu; `.glass` blur adaptif; kelas baru `.game-nav-item` (bracket ::before/::after, sweep, stagger via `--i`), `.game-frame` (panel sudut terpotong + garis tergambar), keyframes `sweep-x`, `draw-line`, `nav-in`; media query `<md`: blur 18→8px, hambat animasi berat |
| `src/components/shell/nav-rail.tsx` | Item pakai `.game-nav-item` + `style={{'--i': n}}`; marker aktif jadi segitiga ▸ putih; hapus box-shadow glow besar (ganti sweep) |
| `src/components/shell/mobile-nav.tsx` | Dock mono: tab aktif = bar atas putih + sweep sekali; target sentuh ≥44px; hapus glow blob |
| `src/components/shell/topbar.tsx` | Aksen mono + `.game-frame` pada container (cek markup dulu) |
| `src/components/ambient/ambient-canvas.tsx` | Early-return WebGL bila `(pointer: coarse)` / lebar <768 / `saveData` / core ≤4; fallback statik CSS tetap |
| `src/components/ambient/status/signal tone` (`SIGNAL_RGB`) | Semua tone jadi grayscale (putih; intensitas beda per tone) |
| `src/app/(dashboard)/dashboard/view.tsx` | Hero + kartu metrik pakai `.game-frame`/cut-corner sebagai showcase |
## Keputusan desain
- **Full monokrom termasuk danger**: flag/moderation tidak lagi merah —
ditandai badge putih-di-atlas-hitam inversi + pulse. Kalau user kangen merah,
tinggal isi ulang `--color-vermilion`.
- Semua animasi hanya `transform`/`opacity` (compositor-friendly), hormati
`prefers-reduced-motion` (sudah ada kill-switch global).
## Verifikasi (gerbang)
1. `tsc --noEmit` bersih; biome 0 error 0 warning.
2. `pnpm build` sukses; smoke lokal 4024 → 9 route 200.
3. Push → GHA "Build & Deploy (Nix)" hijau → live 9×200.
4. Visual check live: desktop (rail game-menu terlihat) + cek rule mobile
(media query & gate kode) — screenshot disimpan.
@@ -0,0 +1,56 @@
# Spec: Simpan pesan NSFW tanpa analisis AI (Request 1)
Date: 2026-08-30
## Goal
Saat ini GMW **skip capture** untuk pesan di channel age-restricted/NSFW
(`messageCapture.ts` baris 291 & 310 memanggil `isAgeRestrictedMessage` lalu
`return`). User ingin pesan NSFW tetap **disimpan** ke database (jadi terlihat
di dashboard), tetapi **tidak dianalisis AI** (tidak dipanggil LLM).
## Behavior target
1. Pesan NSFW/age-restricted di `messageCreate` → DISIMPAN (capture normal).
2. Pesan NSFW di `messageUpdate`/`messageDelete` → tetap diproses seperti pesan
biasa (edit/deleted state tercatat).
3. AI analysis TIDAK berjalan untuk pesan NSFW. Sudah ada jalur yang benar:
`queueMessageAnalysis` → `isAgeRestrictedMessage(message)` →
`buildAgeRestrictedSkipResult()` yang menulis `status=clean` + flag
`age_restricted` + `action=none` TANPA memanggil LLM. Jalur ini sudah ada dan
dipakai di `aiAnalyzer.queueMessageAnalysis` (baris 72-84) dan
`batchProcessor.skipAgeRestrictedMessages`. Jadi tinggal membuka capture.
4. Konten NSFW TIDAK masuk ke Qdrant public archive (semantic search publik).
`archiveMessageEmbedded` harus di-guard untuk pesan age-restricted.
## Files touched
- `services/discord-gateway/src/modules/message-capture/messageCapture.ts`
- Hapus guard `isAgeRestrictedMessage` di `messageCreate` (baris 291) dan
`messageUpdate` (baris 310). Jangan hapus guard `isExcludedThread`,
`isBotExcludedChannel`, `shouldCaptureForAnyTarget`.
- `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts`
- `archiveMessageEmbedded` terima flag/cek metadata age-restricted → skip
embed untuk NSFW.
- Call-site di `messageCapture.ts` `captureMessage` (baris 218) pass isNSFW
atau cek dulu.
## Schema/type changes
- TIDAK ada perubahan DB schema. Metadata pesan sudah membawa `channel.nsfw`
(`getMessageLocation`). Tidak perlu kolom baru — flag `age_restricted` sudah
ditulis ke `ai_moderation_flags` lewat skip-result.
## Verification
- `pnpm typecheck`, `pnpm lint` (biome), `pnpm build` di `services/discord-gateway`.
- Unit test: pastikan ada test untuk age-restricted skip (sudah ada
`tests/conversationContext.test.ts` ref nsfw; cek apakah ada test sesuai).
- Deploy via GHA push; verify pesan NSFW muncul di DB dan `ai_status` = clean
dengan flag age_restricted, dan TIDAK ada panggilan LLM (log llm-caller tidak
menampilkan id pesan NSFW).
## Status
- Request 1: DONE & DEPLOYED (commit 50967f64, CI green, gateway restart 20:17 WIB).
- Request 2 (video): dicatat, belum dikerjakan — lihat section di bawah.
## Request 2 (video) — NOT in this change
Fitur record video kamera/screenshare orang lain. @discordjs/voice hanya
mendukung **audio** receive. Video orang lain butuh WebRTC viewer baru
(SDP answer, decrypt H264/VP8, decode frame, mux MP4/WebM). Diluar scope
changeset ini; dicatat untuk desain lanjutan.
@@ -0,0 +1,82 @@
# Spec: Recordings — filter per user + export WAV (Audacity)
## Konteks / Gejala
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
rekaman voice (deck). User ingin:
1. **Filter per orang** (tampil rekaman satu user saja).
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
## Fakta saat ini (verified)
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
## Keputusan desain
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
distinct `user_id`/`username` pada items yang sedang tampil.
2. **Export WAV (Audacity)**: client-side via Web Audio API.
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
/ mixdown per orang.
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
## Perubahan
### Frontend
- **`src/lib/audio/wav.ts`** (baru):
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
`new AudioContext().decodeAudioData`.
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
(mono) dengan sample-rate max; untuk export gabungan.
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
- **`src/app/(dashboard)/recordings/view.tsx`**:
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
→ set ke SWR (key includes filter), reset pagination.
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
concat semua items ter-filter yang punya download_url.
- Status loading saat export (spinner/disable).
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
### Backend / gateway
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
## File yang disentuh (frontend only)
- `src/lib/audio/wav.ts` (baru)
- `src/app/(dashboard)/recordings/view.tsx`
- `src/hooks/use-recordings.ts`
## Verification
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (next build) — hijau.
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
EXPORT WAV (filtered) → satu .wav gabungan.
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
## Risiko / Trade-off
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
yang dipilih.
@@ -0,0 +1,81 @@
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
## Konteks
4 fitur lanjutan untuk halaman /recordings (dipilih user):
1. Tampilkan transkripsi + search by kata kunci
2. Filter lanjutan: by channel + rentang tanggal
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
4. Leaderboard bicara per user + ringkasan
## Fakta terverifikasi (2026-08-30)
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
upload_error, created_at, uploaded_at, **transcription** (schema
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
default **false** (config/index.ts:333) & tidak diset di BWS env →
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
auto-detect jika tidak diberikan).
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
`transcription`.
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
(channel sama + gap created_at), durasi leaderboard = estimasi dari
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
## Keputusan desain
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
file OGG lama kemungkinan besar sudah tidak dipakai).
2. **Backend** — perluas `getRecent`:
- select `transcription` (+ interface RecordingRow + FE type)
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
(created_at range, bigint ms)
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
3. **Frontend**:
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
- Toolbar: search box (q), Select channel, date range (start/end), speaker
(sudah ada), reset filter.
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
user + klik → filter deck by user itu.
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
## Perubahan file
### Gateway (Tahap 1)
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
### Backend (Tahap 2)
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
baru `recordings.summary`.
### Frontend (Tahap 3-5)
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
Summary type.
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
useRecordingsSummary.
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
## Verification
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
- Tahap 1c: setelah deploy + rekaman baru, cek
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
@@ -0,0 +1,71 @@
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
(2033) DOES decrypt any payload type generically (audio + video) using
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
is the opus check inside onUdpMessage.
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
Delegate opus (120) to the original handler. Delegate audio to original.
## Audio already works (screenShareAudio.ts). We add VIDEO.
## Science-of-the-changes below.
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
Files (new): `src/modules/voice-recording/videoReceiver.ts`
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
- Wrap `receiver.onUdpMessage`:
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
- if payload type is video AND we have a "watching" subscription for that user
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
fragments into AnnexB NALs (start-code prefixed), buffer until we have
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
user-per-burst `.h264` file.
- else delegate to original onUdpMessage.
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
signal a video burst started for that user (like screenShareAudio does).
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
- Guard: skip bot's own video (client.user.id).
Dependencies: NO new npm deps for Phase A (only crypto already in
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
buildInputs) used in Phase B for decode+mux.
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
Nix buildInputs for the music/GoLive players).
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
## Phase C — frontend playback + session grouping (follow-up)
- Backend oRPC list video files; FE video player, group by call session like audio.
## Verification
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
grows with NAL frames + keyframes; journal shows "video burst" logs.
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
## Open questions / risks
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
Phase B via ffmpeg RTP input.
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
already applies daveSession.decrypt for audio — we must call the SAME
parsePacket path so DAVE/encryption is handled identically.
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
voice state; may need the STREAM_CREATE network events to key reliably.
@@ -0,0 +1,66 @@
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
## Goal
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
yang sama.
## Requirement mapping (user's ask)
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
startup (ready handler) + keep DB record across graceful shutdown.
- "state nya persistent di db" → `voice_auto_reconnect` table.
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
(kick / moved / voice server restart) → full rejoin with backoff.
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
## Design decisions
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
- `guild_id` text PK
- `channel_id` text NOT NULL
- `channel_name` text
- `connected_at` bigint epoch-ms
- `updated_at` bigint epoch-ms
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
`upsert`. IDEMPOTENT (upsert per guild).
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
(list persisted → connect each, non-fatal on failure).
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
## Files touched
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
+ types.
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
disconnect/disconnectGuild pass `clearPersisted: true`.
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
in `ready`.
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
`meta/_journal.json` entry (apply manually per gmw-ops).
## Edge cases
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
→ log + delete persisted row (don't retry forever).
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
- Multiple guilds: per-guild monitor, per-guild persisted row.
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
but keeps row.
## Verification
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
- Apply migration `0020` manually; verify table exists.
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
confirm rejoin; manual disconnect → row cleared.
@@ -0,0 +1,166 @@
# Spec: Perbaiki alur voice → recording (miss & terpotong)
## Konteks & Gejala
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
Pipeline per user yang mulai bicara (speaking "start"):
```
receiver.speaking "start" → speakingHandler(userId)
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
```
## Root cause (dari pembacaan kode — justifikasi di bawah)
### A. MISS bagian awal bicara — subscribe tertunda (utama)
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
user baru/evict (cache max 200) kena setiap kali.
### B. Double-subscribe race
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
→ miss kata singkat ("ya", "siap").
### D. Rotasi segmen 5s di tengah bicara
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
(recording "hilang"). Tidak ada watchdog.
## Scope
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
## Perubahan
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
`recorder/speakingHandler.ts`:
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
sebelum `collectUserMetadata`.
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
cache untuk registrasi segmen saat finalize.
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
Detail konkret:
```
async handler(userId):
if (userId === client.user?.id) return;
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
setup pipeline (data/end/error, pipe, open segment)
collectUserMetadata(...).then(meta => {
if (meta.bot) { drain & close subscription (jangan simpan) }
else { registrasi ulang metadata utk segmen aktif }
})
```
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
### 2. Selesaikan race double-subscribe (bagian dari #1)
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
`shared/config/index.ts`:
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
Opsional via env override di production (tidak wajib komit env).
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
end). Ini menghilangkan pemecahan di tengah kalimat.
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
recording; decoder rotate tetap dijalankan.
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
Watchdog di #5 membatasi durasi menggantung.
### 5. Watchdog end-of-burst & teardown recovery
`recorder/speakingHandler.ts`:
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
belum "end".
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
Implementasi: subscriptionStream (audioStream) + track milik per-user di
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
menjadwalkan finalize.
### 6. Naikkan/ambil MIN segmen duration lebih rendah
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
("ya", "siap") tetap tersimpan. GUI biarkan.
## File yang disentuh
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
supaya return subscription utk cleanup/destroy bot)
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
- `src/shared/config/index.ts` (default AfterSilence 4000)
## Yang TIDAK disentuh
- `transmitter.ts` (arah browser→Discord, bukan recording)
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
- `muxer.ts` (konsolidasi akhir tetap jalan)
- Flake/deps/bundle
## Verification
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (tsc → dist/).
4. Unit test baru (vitest, kalau infra tes ada):
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
- double-start skip lewat guard sinkron
- "stop" → watchdog finalize segmen walau stream tidak "end"
- MIN_DURATION 300ms menyimpan kata pendek
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
## Risiko / Trade-off
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
timeout (transcodeToMp3 30s) tetap aman.
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
@@ -0,0 +1,177 @@
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
Date: 2026-08-31
Author: Hermes
Related: `.hermes/plans/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
`.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
## Problem / Ground truth (established from live logs 2026-08-31)
GMW must record OTHER members' screen-share + camera video in a voice channel it
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
`references/video-receive-and-unmute.md` §4.
Facts:
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
video never rides the @discordjs/voice guild socket.
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
(send). No STREAM_WATCH / receive.
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
no STREAM_WATCH.
- `discord.js-selfbot-v13`: video receive but no DAVE.
## Goal
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
playable container. Reuse every tested building block already in the repo.
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
Two implementation routes. Decide by Phase 2 prototype result.
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
for audio):
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
connection — they take arbitrary endpoint/token/session).
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
handshake (Davey), then on receipt of a video RTP packet call
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
it needs no selfbot voice connection).
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
in `videoReceiver.ts`, unit-tested).
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
via `as any`, as the existing `parsePacket` usage shows).
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
risk (native dep in Nix store), but a clean-room receive path decoupled from
djs/voice internals. Use only if A proves infeasible.
## Files touched (Strategy A shape)
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
endpoint) via `client.on('raw')` match on stream_key.
- builds a djs/voice-style connection to `<endpoint>` with the received token/
session; completes DAVE handshake.
- `onUdpMessage` wrapper: for video payload types, decrypt with
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
the entry point; delegate the actual receive to `streamWatchReceiver`.
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
guild-socket `hookVideoReceiver` can be removed or left inert.
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
## Phases (each independently verifiable)
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
stream-watch connection's identify/select-protocol flows. Verify the selfbot
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
spec + no unknowns blocking A.
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
lab.
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
biome + unit tests green; CI deploy ok.
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
## Risks / open questions
- Does Discord require the stream-watch connection to use the SAME session_id as
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
payload type live; add depacketizers for other codecs only if observed.
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
confirmed against the installed 0.19.2 build (P1).
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
## Verification (overall)
- Per-phase gates above.
- No regression: audio recording + message capture still work after changes.
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
lifting.
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
channelId)`; on network packets it calls `this.session.decrypt(userId,
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
index.d.ts.
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
stream connection via `client.on('raw')` matching `d.stream_key`, setting
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
`StreamConnectionReadonly`).
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
`BaseMediaConnection.handleProtocolAck` + `initDave`.
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
→ add depacketizers only if observed).
@@ -0,0 +1,93 @@
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
Status: PLANNED
Date: 2026-08-31
Author: Hermes
Related: `.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
## Symptom (from live logs, 2026-08-31 ~12:34)
A user was actively screen-sharing + on camera in the recorded voice channel.
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
```
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
[VOICE] connection? true, guild session channel
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
video-recorder: userId=..., "Connection not established within 15 seconds."
```
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
shows `newState.streaming === true`.
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
already in an established voice session, Discord does NOT emit a new
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
15s `VOICE_CONNECTION_TIMEOUT`.
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
WS+UDP socket feeds `PacketHandler.push`, authenticated with
`authentication.secret_key`).
## Fix — establish the selfbot connection eagerly, at voice-join time
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
appears. Establish it once, synchronously alongside the @discordjs/voice join in
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
return null; do NOT block audio).
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
destroy the cached selfbot connection (`disconnect()`).
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
- If cached & `status === CONNECTED` → use it.
- Else → fall back to a lazy `joinChannel` (still best-effort).
## The two-connection coexistence risk (must verify live)
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
their OWN low-level voice WS+UDP on the same session. The spec's original
open-question flagged this. Mitigations:
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
connection is up.
- If Discord kicks/breaks the audio connection, logs will show
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
richer status logging.
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
call `destroyGuildSelfbotVoice` on voice stop/destroy.
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
+ status gating).
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
2. Tests green.
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
4. LIVE (deploy): join a channel with the bot → journal shows
`Selfbot voice connected` (parent CONNECTED). When a member streams →
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
recording still flows (no djs/voice reconnect churn).
@@ -0,0 +1,97 @@
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
Status: PLANNED (not built)
Date: 2026-08-31
Author: Hermes
Related: `.hermes/plans/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
## TL;DR — what changed vs Phase A/B
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
## Problem / the crux
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
to the same channel whose ONLY job is to watch + record others' video.
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
### Option B: Migrate primary voice to selfbot lib
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
- Pros: one connection; video+audio unified.
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
## CAVEAT — ClientVoiceManager.connection is a singleton
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
committing to Option A.
## Implementation plan (Option A)
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
- Skip bot's own user id (unless we also want self-video; default skip).
### 2. Watch + record wiring
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
fallback: build a `VoiceConnection` directly from the existing voice auth).
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
### 3. Teardown
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
### 4. Frontend (Phase UI, later)
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
- Possibly `voiceController.ts` (voice state update subscription)
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
appears under recordings dir; playable via ffmpeg.
4. CI Build & Deploy (Nix) green.
## Open questions for spike (before full build)
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
@@ -0,0 +1,33 @@
# Video Recording Splitting — Like Voice Recording
## Goal
Camera + screen share (stream watch) recording should split into per-burst
segments just like voice recording does — each time a streamer pauses/stops
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
## Voice Recording Model (to replicate)
1. `receiver.speaking.start` → new OGG segment per burst
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
4. File stored as `<userId>/<startTime>.ogg` + `.json`
## Video Recording Splitting
1. DAVE video RTP → depacketize H264 → write to current segment .h264
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
mux to MP4 → insert DB record → upload → start new segment on next packet
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
## Files Modified
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
— Main change: silence-based splitting + DB registration + upload
## Constants
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
## Verification
- `pnpm typecheck` in `services/discord-gateway`
- `pnpm build` (dist/ is the deployed artifact)
- Push → CI deploy → live test with a streamer
@@ -0,0 +1,81 @@
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
Status: PLANNED (not yet built)
Date: 2026-09-02
Author: Hermes
Related: `.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
`gmw-ops/references/discord-voice-fork-video-receive.md`
## TL;DR — the decisive finding (verified live 2026-09-02)
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
a selfbot CANNOT auto-detect other members' camera/share because:
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
triggers) are therefore both **dead on a selfbot**.
- No manual watch command exists today, so even on-demand capture is impossible.
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
built and only lacks a real streamer to produce its first `.mp4`.
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
self-capture is a follow-up, not the default.
## Goal
Add a **manual watch command** so an operator can say "record <member>'s screen share"
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
genuinely buildable with the existing receive pipeline.
## Scope / files
Gateway (`services/discord-gateway`):
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
`video.handler.ts`), routed via `createHandlerRegistry`.
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
connections) + target memberId from the command payload, calls
`startStreamWatch(channel, memberId)` (already exported).
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
command calls `stopStreamWatch(guildId, userId)`.
- Reply: success/failure via the standard `CommandReply` publish.
Backend (`services/backend`):
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
bridge the FE already uses for voice commands.
Frontend (`services/frontend`):
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
backend procedure. Shows live status (watching / recording / segments uploaded).
(Each layer optional independently; gateway alone gives a Redis-testable path.)
## Verification
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
(mock the module).
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
4. `Build & Deploy (Nix)` CI green.
## Out of scope (documented dead ends on selfbot)
- Auto camera/share capture of OTHER members (impossible at detection layer).
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
- Bot-token migration (user declined).
+61
View File
@@ -0,0 +1,61 @@
# GMW — Fix mobile navbar "tidak bisa pindah halaman" (root cause)
## Context / symptom
- User (phone, mobile viewport <md): pressing the bottom mobile nav items does NOT
navigate ("Masih sama, walau sudah ditekan tidak pindah halaman").
- Previous fix (commits ae41f64e, 4d0bbed5) repositioned the chatbot FAB above the
nav and expanded nav to all 7 items — but the bug persisted.
## Root cause (verified live 2026-08-28)
Reproduced on the LIVE site (`imphnen.asepharyana.my.id`) with Playwright @375px:
1. **Next `<Link>` client-side navigation is dead app-wide.** Clicking a mobile nav
`<Link>` fires the click (event reaches document, `inLink=true`,
`defaultPrevented=false`) but **the URL never changes**. `window.next.router.push('/voice')`
also does nothing. The desktop NavRail navigates only because it uses a **plain
`<a href>`** (full browser navigation bypasses the broken router).
2. **A React hydration failure (#418 "server rendered text didn't match the client")**
is thrown on live pages — the only console error. Cause: relative-time text
(`formatRelativeTime(e.edited_at)` / `formatRelativeTime(m.created_at)` in the
SSR-seeded messages/edit-history feed uses `Date.now()`; server and client
render slightly different text → hydration mismatch → React re-renders, and the
Next client router ends up non-functional.
## Why desktop "worked" / mobile didn't
- `NavRail` = plain `<a href>` → **hard** navigation (works).
- `MobileNav` = Next `<Link>` → **client** navigation → dead router.
User's instruction "ikuti cara kerja sidebar" = make mobile nav behave like the
sidebar (plain anchors).
## Changes
### 1. `src/components/shell/mobile-nav.tsx`
Replace Next `<Link>` with a **plain `<a href>`** (mirrors NavRail). Keep:
`usePathname` for active-state styling + `scrollIntoView` for snap-to-active.
Drop the now-unused `Link` import (biome import-order — run biome check --write).
### 2. Hydration root cause — `formatRelativeTime` in SSR-seeded feeds
Add `suppressHydrationWarning` to the timestamp elements in the SSR-seeded
components that render live-relative text so server/client text drift no longer
throws #418:
- `src/components/EditHistory.tsx` (line ~111 `edited {formatRelativeTime(...)}`)
- `src/app/(dashboard)/messages/view.tsx` MessageRow channel/time (line ~580) +
semantic row (line ~364)
- `src/components/LiveModerationFeed.tsx` (line ~139)
- (recordings/view.tsx, TermGlossary, ChannelCultureGlossary, CategoryDrilldown,
chatbot — chatbot is client-after-mount; add where SSR-seeded.)
NOTE: `suppressHydrationWarning` is safe for these single-text spans; the values
are cosmetic and self-correct on the next interval/render.
## Verification (non-prod, NEVER 4017)
- `pnpm format` + `pnpm lint` (biome) + `pnpm build` clean.
- Smoke on :4024 (fresh `next dev`): mobile click on nav item actually navigates
(URL changes) AND no React #418 in console.
- After push: `gh run watch`, then live `imphnen.asepharyana.my.id` @375px: nav tap
navigates, console free of #418.
## Files
- services/frontend/src/components/shell/mobile-nav.tsx
- services/frontend/src/components/EditHistory.tsx
- services/frontend/src/app/(dashboard)/messages/view.tsx
- services/frontend/src/components/LiveModerationFeed.tsx
- (others only if #418 persists)