Compare commits
110
Commits
aa440eda69
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2e97ae11d | ||
|
|
6244e307a3 | ||
|
|
2d7c7f2c35 | ||
|
|
416c690ebc | ||
|
|
c590a8be27 | ||
|
|
9c83ec86cc | ||
|
|
17a4fbd73d | ||
|
|
c04c410fad | ||
|
|
5e5f4ae208 | ||
|
|
e2013988ff | ||
|
|
0164444dd7 | ||
|
|
9ae26b8ec9 | ||
|
|
7ebee7559d | ||
|
|
66c33a2657 | ||
|
|
25b220b7f9 | ||
|
|
1c4f28c5f2 | ||
|
|
392db8eba1 | ||
|
|
3c2c1c3b15 | ||
|
|
1b56212d1a | ||
|
|
b98101c576 | ||
|
|
84757bdcf4 | ||
|
|
6c9a91dad4 | ||
|
|
bcb563ea7f | ||
|
|
589fd38fd8 | ||
|
|
da02bfff9b | ||
|
|
a66db8d702 | ||
|
|
d65dc11c73 | ||
|
|
8b281c7feb | ||
|
|
5bbf75a65b | ||
|
|
5816e94a63 | ||
|
|
11f2ad5f23 | ||
|
|
6e188f81d6 | ||
|
|
7c376ea66a | ||
|
|
8ee32b8df8 | ||
|
|
c285a4c813 | ||
|
|
f156fc0c9e | ||
|
|
89f1097729 | ||
|
|
df24c756a0 | ||
|
|
add31d3561 | ||
|
|
6e7c4901c9 | ||
|
|
60faaa9304 | ||
|
|
5505983dbd | ||
|
|
3d57e9c102 | ||
|
|
d3cb5f6756 | ||
|
|
37787cc4f0 | ||
|
|
f849a87f2f | ||
|
|
deb5dedf2c | ||
|
|
3b221823e7 | ||
|
|
9f7ce7dbd5 | ||
|
|
bd292fdf3d | ||
|
|
7a7f433988 | ||
|
|
84c5c36672 | ||
|
|
c5898f7cf0 | ||
|
|
354e378e74 | ||
|
|
392bc35a0d | ||
|
|
196cb1d3af | ||
|
|
00fc852a32 | ||
|
|
d9f5592e6e | ||
|
|
f1aa08cdf6 | ||
|
|
f70a92880e | ||
|
|
88b13225cd | ||
|
|
67ab289caa | ||
|
|
ef9e243609 | ||
|
|
42a503c206 | ||
|
|
652974e23a | ||
|
|
407e003399 | ||
|
|
968a43b0f4 | ||
|
|
91c7a67d2f | ||
|
|
8615383829 | ||
|
|
10d7ecd405 | ||
|
|
ff554fcff2 | ||
|
|
f8b253ba5e | ||
|
|
c8473b0610 | ||
|
|
3deca91ffe | ||
|
|
edec2edf82 | ||
|
|
17013fe1e5 | ||
|
|
3acb03391a | ||
|
|
9109d3c898 | ||
|
|
9139e225f4 | ||
|
|
9ae230d047 | ||
|
|
a1a6d8b418 | ||
|
|
4f06c30c05 | ||
|
|
2203dd5771 | ||
|
|
a53d7b71da | ||
|
|
c18431bdbf | ||
|
|
4f4c43555f | ||
|
|
50371bd2d1 | ||
|
|
0792ff4dc0 | ||
|
|
eb89bb79ed | ||
|
|
7d6c741bb2 | ||
|
|
4cb4904517 | ||
|
|
4ee295bd29 | ||
|
|
65c9c2cd9e | ||
|
|
0a5254bf20 | ||
|
|
4a51f3055c | ||
|
|
185d81f0e0 | ||
|
|
ecbb538c9f | ||
|
|
4049ab4201 | ||
|
|
5d094829c4 | ||
|
|
abbd78f42b | ||
|
|
4f9d4a5c7d | ||
|
|
2c995b41d7 | ||
|
|
18dd6a56ba | ||
|
|
a690e5b63e | ||
|
|
62ffb676f9 | ||
|
|
4797aca20f | ||
|
|
42b8afd412 | ||
|
|
7575a701bd | ||
|
|
9f91155944 | ||
|
|
f20889868d |
@@ -67,7 +67,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [backend, discord-gateway, proxy]
|
||||
service: [backend, discord-gateway, proxy, frontend]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
@@ -82,6 +82,19 @@ jobs:
|
||||
extra-conf: |
|
||||
sandbox = false
|
||||
accept-flake-config = true
|
||||
# Attic binary cache as substituter on the runner: lets CI pull the
|
||||
# prebuilt attic client (and any cached deps/builds) over HTTPS,
|
||||
# no SSH round-trip needed. extra-substituters (NOT
|
||||
# extra-trusted-substituters) is required — Determinate Nix never
|
||||
# merges trusted-* substituters for nix-store CLI clients.
|
||||
extra-substituters = https://attic.asepharyana.my.id/gmw
|
||||
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88=
|
||||
# NOTE: nix-installer-action unconditionally injects
|
||||
# 'build-provenance-tags' into /etc/nix/nix.conf (a Determinate
|
||||
# Nix-only setting). With determinate:false the runner's upstream
|
||||
# nix warns 'unknown setting build-provenance-tags' on every
|
||||
# invocation — benign, cosmetic. Switching determinate:true would
|
||||
# silence it but changes the runner's nix flavor.
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
@@ -107,20 +120,125 @@ jobs:
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Push build result to Attic binary cache (attic.asepharyana.my.id) so
|
||||
# the VPS can substitute it instead of a single-stream `nix copy ssh://`.
|
||||
#
|
||||
# Fast path: push DIRECTLY from the runner to the public attic endpoint
|
||||
# (validated 2026-08-10: token auth over public HTTPS works without
|
||||
# Tailscale). This skips the ~794MB closure SSH copy to the VPS that
|
||||
# used to take 25+ minutes per new store path.
|
||||
#
|
||||
# The attic client is NOT in nixpkgs anymore and has no prebuilt
|
||||
# releases, so we pull the same prebuilt closure the VPS uses
|
||||
# (/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0, ~52MB).
|
||||
# The closure itself lives in the attic cache (pushed once from the
|
||||
# VPS), so the runner bootstraps it over HTTPS via the configured
|
||||
# extra-substituters — no SSH round-trip. If that fails we fall back
|
||||
# to `nix copy --from ssh://`, then the old VPS-hop flow (SSH copy to
|
||||
# VPS, then attic push from the VPS over Tailscale) so the deploy step
|
||||
# always has a working closure path.
|
||||
- name: Push to Attic cache
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$ATTIC_TOKEN" ]; then
|
||||
echo "ATTIC_TOKEN not set; skipping attic push"
|
||||
exit 0
|
||||
fi
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
|
||||
attic_push_vps_hop() {
|
||||
echo "Fallback: VPS-hop attic push"
|
||||
# Copy closure to VPS (fast if attic already has it via substitute)
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|
||||
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
# Push from VPS → Attic over Tailscale.
|
||||
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
|
||||
# writing the narinfo to gmw when chunks exist in the upstream
|
||||
# cache.nixos.org — leaving the path 404 on gmw so the VPS deploy's
|
||||
# nix-store --realise can't find it and falls back to ssh copy.
|
||||
# sudo: attic must read root's config (~/.config/attic), which has
|
||||
# the imrnes-ts server → Tailscale. Non-root users' configs only
|
||||
# have the public `pub` server → "Server imrnes-ts does not exist".
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|
||||
|| echo "attic push failed (non-fatal; ssh copy fallback below)"
|
||||
}
|
||||
|
||||
# ── Get an attic client on the runner ────────────────────────────
|
||||
# Order: PATH → pull the prebuilt closure from the attic cache
|
||||
# itself (extra-substituters configured in Install Nix step, HTTPS
|
||||
# only, no SSH) → pull over ssh from the VPS → VPS-hop.
|
||||
# The attic client closure is stored in the attic cache (pushed
|
||||
# once from the VPS), so the fast path never depends on SSH.
|
||||
ATTIC_BIN=""
|
||||
if command -v attic >/dev/null 2>&1; then
|
||||
ATTIC_BIN="$(command -v attic)"
|
||||
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
|
||||
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
elif nix copy --from "ssh://$VPS_USER@$VPS_HOST" "$ATTIC_DIR" 2>>/tmp/attic-bootstrap.err; then
|
||||
echo "✅ Pulled attic client from VPS over ssh"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
else
|
||||
echo "attic client unavailable on runner; using VPS-hop flow"
|
||||
echo "--- bootstrap errors (stderr) ---"
|
||||
tail -5 /tmp/attic-bootstrap.err 2>/dev/null || true
|
||||
attic_push_vps_hop
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Direct push: runner → attic public endpoint ──────────────────
|
||||
# --ignore-upstream-cache-filter forces the narinfo write even when
|
||||
# the path's chunks already exist in upstream cache.nixos.org (which
|
||||
# attic would otherwise skip, leaving the path 404 on the gmw cache).
|
||||
mkdir -p "$HOME/.config/attic"
|
||||
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||
default-server = "pub"
|
||||
|
||||
[servers.pub]
|
||||
endpoint = "https://attic.asepharyana.my.id"
|
||||
token = "$ATTIC_TOKEN"
|
||||
EOF
|
||||
# Retry the direct push — a transient 502 (e.g. atticd restart,
|
||||
# Traefik blip) must not abort the whole closure upload. attic push
|
||||
# is idempotent, so re-running only uploads what's still missing.
|
||||
push_ok=""
|
||||
for attempt in 1 2 3; do
|
||||
if "$ATTIC_BIN" push pub:gmw "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||
echo "✅ Pushed $STORE_PATH to attic directly from runner"
|
||||
push_ok=1
|
||||
break
|
||||
fi
|
||||
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ -z "$push_ok" ]; then
|
||||
echo "Direct attic push failed after 3 attempts; using VPS-hop flow"
|
||||
attic_push_vps_hop
|
||||
fi
|
||||
|
||||
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
|
||||
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
|
||||
- name: Deploy ${{ matrix.service }} to VPS
|
||||
run: |
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
|
||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
if [ -n "${{ secrets.ATTIC_TOKEN }}" ] && ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null; then
|
||||
echo "Substituted ${{ matrix.service }} from Attic cache"
|
||||
else
|
||||
echo "Attic substitute failed; falling back to ssh copy"
|
||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Updating profile ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
|
||||
|
||||
echo "=== Restarting service ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && sleep 3 && sudo systemctl is-active gmw-${{ matrix.service }}"
|
||||
echo "✅ gmw-${{ matrix.service }} deployed"
|
||||
echo "=== Restarting service ===\n"
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && for i in \$(seq 1 15); do state=\$(sudo systemctl is-active gmw-${{ matrix.service }} 2>/dev/null || echo inactive); [ \"\$state\" = \"active\" ] && break; sleep 2; done; echo \"final-state=\$state\"; [ \"\$state\" = \"active\" ]"
|
||||
echo "✅ gmw-${{ matrix.service }} deployed"
|
||||
|
||||
cleanup:
|
||||
# Bersihkan sampah Nix di VPS SETELAH semua deploy selesai: hapus generasi
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ worktrees/
|
||||
.worktrees/
|
||||
services/frontend/frontend/dist/
|
||||
target/
|
||||
|
||||
nix/
|
||||
# Gitea CI runner logs
|
||||
.gitea/workflows/*.log
|
||||
|
||||
|
||||
@@ -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 animation. **3D:** raw `three` (no react-three-fiber — leaner) in exactly TWO signature scenes, lazy-loaded client-only with graceful fallback. Deployed unchanged via existing flake + `gmw-proxy` nginx (`:4009` → Next `:4017`).
|
||||
|
||||
**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`, `input-otp`, all 50 `components/ui/*`.
|
||||
|
||||
---
|
||||
|
||||
## 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.)
|
||||
+9
-3
@@ -35,12 +35,15 @@
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off",
|
||||
"useIterableCallbackReturn": "off",
|
||||
"noArrayIndexKey": "warn"
|
||||
"noArrayIndexKey": "off",
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useSemanticElements": "off",
|
||||
"useButtonType": "off",
|
||||
"noAutofocus": "off"
|
||||
"noAutofocus": "off",
|
||||
"useMediaCaption": "off",
|
||||
"noStaticElementInteractions": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "warn"
|
||||
@@ -50,7 +53,10 @@
|
||||
},
|
||||
"correctness": {
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"noUnusedFunctionParameters": "warn"
|
||||
"noUnusedFunctionParameters": "warn",
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedPrivateClassMembers": "warn"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||
|
||||
# pnpm uses node-gyp for native addons — provide build tools
|
||||
export npm_config_build_from_source=true
|
||||
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
||||
|
||||
@@ -59,6 +59,36 @@
|
||||
pnpm rebuild 2>&1 || true
|
||||
'';
|
||||
|
||||
# Shrink the shipped node_modules to production deps only. The full
|
||||
# install's .pnpm virtual store carries dev-only packages (biome,
|
||||
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
|
||||
# needed at runtime, so we delete every .pnpm dir that is not part of
|
||||
# the resolved production graph (`pnpm list --prod`).
|
||||
#
|
||||
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
||||
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
||||
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
||||
# are only reachable through it), silently breaking voice.
|
||||
# Instead we keep the full install's symlink layout and only prune
|
||||
# orphaned package dirs + broken symlinks.
|
||||
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
||||
pruneProd = ''
|
||||
echo "=== Pruning devDependencies (production-only node_modules) ==="
|
||||
pnpm list --prod --depth 999 --parseable 2>/dev/null \
|
||||
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
|
||||
( cd node_modules/.pnpm \
|
||||
&& for d in */; do \
|
||||
d="''${d%/}"; \
|
||||
[ "$d" = "node_modules" ] && continue; \
|
||||
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
||||
done ) || true
|
||||
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
||||
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
||||
# which would otherwise fail the fixupPhase.
|
||||
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
|
||||
du -sh node_modules
|
||||
'';
|
||||
|
||||
# ---- Backend ----
|
||||
backend = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-backend";
|
||||
@@ -97,7 +127,7 @@
|
||||
console.log('Fixed ' + count + ' files');
|
||||
"
|
||||
echo "=== Build complete ==="
|
||||
'';
|
||||
'' + pruneProd;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/gmw-backend
|
||||
@@ -132,7 +162,7 @@ WRAPPER
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
pkgs.openssl.dev
|
||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
||||
pkgs.git # for any FetchContent-based deps during native builds
|
||||
pkgs.cacert
|
||||
];
|
||||
|
||||
@@ -145,32 +175,32 @@ WRAPPER
|
||||
# do NOT let stdenv run its own cmake configure phase on the source.
|
||||
dontUseCmakeConfigure = true;
|
||||
|
||||
# The gateway bundles native node_modules (.node addons plus .o/.a
|
||||
# object files left in prebuilt dirs). stdenv's fixupPhase walks
|
||||
# $out/node_modules and runs patchELF + shrinkELF over every ELF it
|
||||
# finds, choking on the non-ET_DYN files (.o/.a) and the prebuilt
|
||||
# .node addons — emitting hundreds of harmless "patchelf: wrong ELF
|
||||
# type" lines per build. The real binary is node (external, already
|
||||
# RPATH-fixed in its own derivation) and the .node addons are
|
||||
# self-contained prebuilts loaded via dlopen, so Nix's fixup pass is
|
||||
# neither needed nor wanted here. Skip it entirely.
|
||||
dontFixup = true;
|
||||
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Building native voice deps ==="
|
||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||
# from the wrong cwd — build each native dep explicitly with its own
|
||||
# install script. Each failure is tolerated (|| true); the packages
|
||||
# that matter (opus, datachannel, node-av) are verified at runtime.
|
||||
for pkg in \
|
||||
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
|
||||
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
|
||||
node_modules/.pnpm/zeromq@*/node_modules/zeromq
|
||||
do
|
||||
if [ -d "$pkg" ]; then
|
||||
echo "--- native build: $pkg ---"
|
||||
(cd "$pkg" && npm run install 2>&1 || true)
|
||||
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
|
||||
# expected first argument to be an array) — the install fallback
|
||||
# populates devDeps incl. cmake-js; build directly via cmake-js.
|
||||
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
|
||||
echo "--- datachannel cmake-js compile ---"
|
||||
# Nix splits OpenSSL headers/libs across outputs — merge them
|
||||
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
|
||||
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "=== Compiling TypeScript ==="
|
||||
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
|
||||
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
|
||||
# instead of compiling C++ from source. With build_from_source unset
|
||||
# (above), `pnpm rebuild` runs the package's own install script which
|
||||
# fetches the matching prebuilt; it only falls back to a source build
|
||||
# if the download fails. This keeps voice working without a per-build
|
||||
# native compile.
|
||||
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
|
||||
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||
echo "=== Compiling TypeScript ===="
|
||||
npx tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||
node -e "
|
||||
@@ -198,7 +228,7 @@ WRAPPER
|
||||
console.log('Fixed ' + count + ' files');
|
||||
"
|
||||
echo "=== Build complete ==="
|
||||
'';
|
||||
'' + pruneProd;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/gmw-discord-gateway
|
||||
@@ -223,7 +253,7 @@ WRAPPER
|
||||
};
|
||||
};
|
||||
|
||||
# ---- Frontend (Next.js static export) ----
|
||||
# ---- Frontend (Next.js SSR standalone) ----
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-frontend";
|
||||
version = "1.0.0";
|
||||
@@ -233,29 +263,47 @@ WRAPPER
|
||||
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
||||
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Building Next.js static export ==="
|
||||
# Build args are provided as env vars
|
||||
echo "=== Building Next.js SSR (standalone) ==="
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
export GMW_BACKEND_URL=http://127.0.0.1:4001
|
||||
npx next build 2>&1
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/share/gmw-frontend
|
||||
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
|
||||
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
|
||||
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
|
||||
echo "=== Packaging standalone server ==="
|
||||
mkdir -p $out/lib/gmw-frontend/standalone
|
||||
# The standalone server bundles its own minimal node_modules but
|
||||
# needs the build assets + public copied INSIDE its tree.
|
||||
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
|
||||
mkdir -p $out/lib/gmw-frontend/standalone/.next
|
||||
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
|
||||
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
|
||||
|
||||
# Copy node_modules for standalone mode if it exists
|
||||
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
|
||||
# Remove dangling symlinks left by pnpm's hoisted .pnpm layout
|
||||
# (e.g. node_modules/.pnpm/node_modules/...). The standalone server
|
||||
# never resolves those at runtime — it bundles its own node_modules
|
||||
# — and they trip stdenv's noBrokenSymlinks check.
|
||||
find $out/lib/gmw-frontend/standalone -type l \
|
||||
! -exec test -e {} \; -delete 2>/dev/null || true
|
||||
|
||||
mkdir -p $out/bin
|
||||
cat > $out/bin/gmw-frontend << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
cd $out/lib/gmw-frontend/standalone
|
||||
export PORT=''${GMW_FRONTEND_PORT:-4017}
|
||||
export HOSTNAME=127.0.0.1
|
||||
exec ${nodejs}/bin/node server.js
|
||||
WRAPPER
|
||||
chmod +x $out/bin/gmw-frontend
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "GMW Frontend — Next.js static dashboard";
|
||||
description = "GMW Frontend — Next.js SSR dashboard";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
# ---- Proxy (nginx serving frontend) ----
|
||||
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
|
||||
proxy = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-proxy";
|
||||
version = "1.0.0";
|
||||
@@ -270,11 +318,10 @@ WRAPPER
|
||||
mkdir -p $out/bin $out/etc $out/share
|
||||
|
||||
# Substitute placeholders in nginx template
|
||||
sed \
|
||||
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
|
||||
${./infra/nix/nginx.conf.template} \
|
||||
> $out/etc/nginx.conf
|
||||
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||
-e "s|@NEXT_PORT@|4017|g" \
|
||||
${./infra/nix/nginx.conf.template} \
|
||||
> $out/etc/nginx.conf
|
||||
|
||||
cat > $out/bin/gmw-proxy << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
@@ -284,7 +331,7 @@ WRAPPER
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "GMW Proxy — nginx serving frontend";
|
||||
description = "GMW Proxy — nginx -> Next.js + backend";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,28 +11,44 @@ http {
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Next.js standalone SSR server (backend-fetching on every render).
|
||||
# Not for hand-editing: @NEXT_PORT@ is substituted at build time.
|
||||
upstream gmw_next {
|
||||
server 127.0.0.1:@NEXT_PORT@;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream gmw_backend {
|
||||
server 127.0.0.1:4001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 4009;
|
||||
server_name _;
|
||||
|
||||
# Use relative redirects (Location: /dashboard/) instead of absolute
|
||||
# URLs that leak the internal listen port (4009) through Traefik.
|
||||
# URLs that leak the internal listen port (4009) through the reverse proxy.
|
||||
absolute_redirect off;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# ── Backend REST ───────────────────────────────────────────────
|
||||
location ^~ /api {
|
||||
proxy_pass http://127.0.0.1:4001$uri$is_args$args;
|
||||
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # keepalive to backend
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
|
||||
location ^~ /ws {
|
||||
proxy_pass http://127.0.0.1:4001$uri$is_args$args;
|
||||
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
@@ -45,16 +61,30 @@ http {
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
root @FRONTEND_ROOT@;
|
||||
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||
location ^~ /_next/static/ {
|
||||
proxy_pass http://gmw_next$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# ── Everything else → Next.js server (SSR) ──
|
||||
location / {
|
||||
root @FRONTEND_ROOT@;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
proxy_pass http://gmw_next$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Next-Prefetch $http_x_next_prefetch;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: add ai_analysis_duration_ms to messages
|
||||
-- Tracks how long the AI moderation LLM call took, per message (ms).
|
||||
-- Idempotent: safe to re-run.
|
||||
--
|
||||
-- Run against the production GMW database, e.g.:
|
||||
-- PGPASSWORD=*** psql -h 100.121.180.82 -p 6432 -U asephs -d dcbot \
|
||||
-- -f scripts/add-ai-analysis-duration.sql
|
||||
|
||||
ALTER TABLE "messages"
|
||||
ADD COLUMN IF NOT EXISTS "ai_analysis_duration_ms" BIGINT;
|
||||
@@ -2,8 +2,8 @@ import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
|
||||
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
|
||||
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
|
||||
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
|
||||
|
||||
const logger = createChildLogger("media.routes");
|
||||
|
||||
@@ -55,14 +55,14 @@ export function createMediaRouter(): Router {
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /api/media/volume
|
||||
// POST /api/media/loop
|
||||
router.post(
|
||||
"/media/volume",
|
||||
validateBody(mediaVolumeSchema),
|
||||
"/media/loop",
|
||||
validateBody(mediaLoopSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { volume } = req.body as { volume: number };
|
||||
logger.debug({ volume }, "Media volume requested");
|
||||
const state = await setVolume(volume);
|
||||
const { loop } = req.body as { loop: boolean };
|
||||
logger.debug({ loop }, "Media loop requested");
|
||||
const state = await setLoop(loop);
|
||||
res.json(state);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5,9 +5,9 @@ export const mediaQueueSchema = z.object({
|
||||
mode: z.enum(["music", "screen"]).default("music"),
|
||||
});
|
||||
|
||||
export const mediaVolumeSchema = z.object({
|
||||
volume: z.number().min(0).max(1).default(1.0),
|
||||
export const mediaLoopSchema = z.object({
|
||||
loop: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
|
||||
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>;
|
||||
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import {
|
||||
COMMAND_MEDIA_LOOP,
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
MEDIA_STATUS_KEY,
|
||||
} from "../../shared/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
@@ -31,6 +31,7 @@ export interface MediaState {
|
||||
/** null/absent when idle; "music" | "screen" while a track is active. */
|
||||
activeMode?: "music" | "screen" | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -44,7 +45,8 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_STATE: MediaState = {
|
||||
playing: false,
|
||||
activeMode: null,
|
||||
musicVolume: 1.0,
|
||||
musicVolume: 0.3,
|
||||
loop: false,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
@@ -65,7 +67,8 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
|
||||
return {
|
||||
playing,
|
||||
activeMode,
|
||||
musicVolume: Number(raw.musicVolume ?? 1.0),
|
||||
musicVolume: Number(raw.musicVolume ?? 0.3),
|
||||
loop: Boolean(raw.loop ?? false),
|
||||
current: (raw.current as MediaItem | null) ?? null,
|
||||
queue: (raw.queue as MediaItem[]) ?? [],
|
||||
};
|
||||
@@ -151,18 +154,18 @@ export async function stop(): Promise<MediaState> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume via Redis command to discord-gateway.
|
||||
* Toggle loop mode (replay current track on natural end) via Redis command.
|
||||
*/
|
||||
export async function setVolume(volume: number): Promise<MediaState> {
|
||||
logger.info({ volume }, "setVolume called");
|
||||
export async function setLoop(loop: boolean): Promise<MediaState> {
|
||||
logger.info({ loop }, "setLoop called");
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
{ volume },
|
||||
COMMAND_MEDIA_LOOP,
|
||||
{ loop },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"setVolume",
|
||||
"setLoop",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
||||
|
||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
@@ -150,12 +149,11 @@ export class MessagesRepository {
|
||||
|
||||
// Exclude spam threads (NULL-safe)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
@@ -316,12 +314,13 @@ export class MessagesRepository {
|
||||
like(pgAttachmentsTable.type, "image/%"),
|
||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||
...(EXCLUDED_THREAD_IDS.length > 0
|
||||
? [
|
||||
or(
|
||||
? (() => {
|
||||
const excludeThreads = or(
|
||||
isNull(pgAttachmentsTable.thread_id),
|
||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
]
|
||||
);
|
||||
return excludeThreads ? [excludeThreads] : [];
|
||||
})()
|
||||
: []),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Authoritative live-voice store.
|
||||
*
|
||||
* Single source of truth for who is present / speaking in voice. The backend
|
||||
* WebSocket server is the one relay every frontend client connects to, so it
|
||||
* is the correct place to aggregate the gateway's `voice_active_user` deltas
|
||||
* into a shared snapshot. A late-joining browser must be able to see the same
|
||||
* state as everyone else — this store makes that possible (seeded into the WS
|
||||
* initial states and served via GET /api/voice/status).
|
||||
*/
|
||||
|
||||
export interface LiveSpeaker {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
/** Epoch ms of the most recent activity (start OR end of speech). */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
const speakers = new Map<string, LiveSpeaker>();
|
||||
|
||||
const MAX_SPEAKERS = 200;
|
||||
|
||||
/**
|
||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||
* active; `speaking: false` marks them inactive while keeping them for the
|
||||
* activity timeline.
|
||||
*/
|
||||
/**
|
||||
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
|
||||
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
|
||||
* keeping them for the activity timeline.
|
||||
*/
|
||||
export function recordSpeaker(data: {
|
||||
userId: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
}): void {
|
||||
const { userId, speaking } = data;
|
||||
const existing = speakers.get(userId);
|
||||
const speaker: LiveSpeaker = {
|
||||
userId,
|
||||
username: data.username ?? existing?.username ?? "Unknown",
|
||||
avatar: data.avatar ?? existing?.avatar ?? null,
|
||||
speaking,
|
||||
lastActiveAt: Date.now(),
|
||||
};
|
||||
|
||||
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
||||
// Drop the least-recently-active non-speaking speaker to stay bounded.
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Infinity;
|
||||
for (const [id, s] of speakers) {
|
||||
if (!s.speaking && s.lastActiveAt < oldestTs) {
|
||||
oldestTs = s.lastActiveAt;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
if (oldestId) speakers.delete(oldestId);
|
||||
else return;
|
||||
}
|
||||
|
||||
speakers.set(userId, speaker);
|
||||
}
|
||||
|
||||
/** All known speakers, most recently active first. */
|
||||
export function getActiveSpeakers(): LiveSpeaker[] {
|
||||
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/** Only speakers currently flagged as speaking. */
|
||||
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
||||
return [...speakers.values()]
|
||||
.filter((s) => s.speaking)
|
||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/** Drop all tracked speakers (used on backend restart). */
|
||||
export function resetLiveSpeakers(): void {
|
||||
speakers.clear();
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
VOICE_STATUS_KEY,
|
||||
} from "../../shared/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
|
||||
|
||||
const logger = createChildLogger("voice.service");
|
||||
|
||||
@@ -45,6 +46,12 @@ export interface VoiceStatus {
|
||||
activeChannelId: string | null;
|
||||
activeChannelName: string | null;
|
||||
connections: GuildVoiceEntry[];
|
||||
/**
|
||||
* Authoritative shared voice snapshot — who is present / speaking right
|
||||
* now, aggregated server-side from the gateway's `voice_active_user`
|
||||
* deltas. All browsers converge on this same list.
|
||||
*/
|
||||
activeSpeakers: LiveSpeaker[];
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||
@@ -53,8 +60,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
connections: [],
|
||||
activeSpeakers: [],
|
||||
};
|
||||
|
||||
/** Attach the live speaker snapshot to any voice status payload. */
|
||||
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
|
||||
status: T,
|
||||
): T & { activeSpeakers: LiveSpeaker[] } {
|
||||
return { ...status, activeSpeakers: getActiveSpeakers() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
|
||||
* Attempts a Redis command first; on failure, falls back to the provided function.
|
||||
@@ -68,8 +83,10 @@ async function withFallback<T>(
|
||||
}
|
||||
|
||||
function readVoiceStatusFallback(): Promise<VoiceStatus> {
|
||||
return readRedisStatus(VOICE_STATUS_KEY).then(
|
||||
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
|
||||
withActiveSpeakers(
|
||||
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +156,9 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
logger.debug("getVoiceStatus called");
|
||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
||||
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS;
|
||||
return withActiveSpeakers(
|
||||
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||
}),
|
||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", {
|
||||
mode: "number",
|
||||
}),
|
||||
ai_error: pgText("ai_error"),
|
||||
},
|
||||
(table) => ({
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface MessageRecord {
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_analysis_duration_ms?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue";
|
||||
export const COMMAND_MEDIA_SKIP = "media:skip";
|
||||
export const COMMAND_MEDIA_STOP = "media:stop";
|
||||
export const COMMAND_MEDIA_VOLUME = "media:volume";
|
||||
export const COMMAND_MEDIA_LOOP = "media:loop";
|
||||
export const COMMAND_MODERATION_ACTION = "moderation:action";
|
||||
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
||||
|
||||
|
||||
@@ -108,5 +108,5 @@ export async function retryWithBackoff<T>(
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
throw lastError ?? new Error("Request failed after all retries");
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface MappedMessage {
|
||||
ai_confidence: number | null;
|
||||
ai_recommended_action: string | null;
|
||||
ai_analyzed_at: number | null;
|
||||
ai_analysis_duration_ms: number | null;
|
||||
ai_error: string | null;
|
||||
is_reply: boolean | null;
|
||||
is_forward: boolean | null;
|
||||
@@ -58,6 +59,8 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||
ai_analysis_duration_ms:
|
||||
(row.ai_analysis_duration_ms as number | null) ?? null,
|
||||
ai_error: (row.ai_error as string | null) ?? null,
|
||||
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
||||
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Redis from "ioredis";
|
||||
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import {
|
||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||
DISCORD_VOICE_ACTIVE_USER,
|
||||
DISCORD_VOICE_PCM,
|
||||
} from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
@@ -62,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate live-voice state authoritatively BEFORE broadcasting.
|
||||
// Every browser hears the same `voice_active_user` deltas, so the backend
|
||||
// can maintain the single shared snapshot for late-joining clients.
|
||||
if (channel === DISCORD_VOICE_ACTIVE_USER) {
|
||||
const speaker = data as {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
speaking?: boolean;
|
||||
};
|
||||
if (speaker?.userId) {
|
||||
recordSpeaker({
|
||||
userId: speaker.userId,
|
||||
username: speaker.username,
|
||||
avatar: speaker.avatar,
|
||||
speaking: Boolean(speaker.speaking),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||
broadcastEvent(eventType, data);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send initial media_state");
|
||||
}
|
||||
|
||||
// Send initial live-voice snapshot (shared authoritative state — a browser
|
||||
// joining mid-call sees the same speakers as everyone else, not an empty DB).
|
||||
try {
|
||||
const { getActiveSpeakers } = await import(
|
||||
"../modules/voice/live-speaker.js"
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "voice_state",
|
||||
state: { activeSpeakers: getActiveSpeakers() },
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send initial voice_state");
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWebSocketServer(): void {
|
||||
|
||||
@@ -1,172 +1,143 @@
|
||||
# Discord Gateway — Architecture
|
||||
|
||||
Pure event-driven microservice (no HTTP server). Captures Discord
|
||||
messages/voice/attachments/reactions/threads/presence, runs LLM-based AI
|
||||
moderation, and publishes everything to Redis pub/sub for the backend to
|
||||
consume. The backend serves the HTTP/WS API to the frontend.
|
||||
|
||||
> NOTE: this doc is the source of truth for the module layout. The older
|
||||
> `MODULE_STRUCTURE.md` was stale (referenced `winston`, `mock-crc.ts`,
|
||||
> `indonesianTextNormalizer.ts`, and `aiAnalysisWorker.ts`/`llmModerationClient.ts`
|
||||
> which were renamed/merged). If they disagree, this file wins.
|
||||
|
||||
## Top-level layout
|
||||
|
||||
```
|
||||
services/discord-gateway/
|
||||
├── src/
|
||||
│ ├── index.ts # Entry point → initializeDiscordGateway()
|
||||
│ ├── app/
|
||||
│ │ ├── bootstrap.ts # Discord Gateway initialization (no HTTP server)
|
||||
│ │ └── shutdown.ts # Graceful shutdown handler
|
||||
│ │ ├── bootstrap.ts # Wires client, DB, Redis, workers, schedulers
|
||||
│ │ ├── shutdown.ts # Graceful shutdown (SIGINT/SIGTERM + transient errors)
|
||||
│ │ └── retention.ts # Expired-record cleanup scheduler
|
||||
│ ├── shared/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.ts # Environment configuration (Zod validated)
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── schema.ts # Drizzle ORM schema
|
||||
│ │ │ ├── drizzle.ts # Database connection
|
||||
│ │ │ ├── migrate.ts # Migration runner
|
||||
│ │ │ └── voiceRecordingRepo.ts
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── errors.ts # Custom error classes
|
||||
│ │ ├── logger/
|
||||
│ │ │ ├── logger.ts # Winston logger wrapper
|
||||
│ │ │ └── serialization.ts # Log value serialization
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── retry.ts # Retry with backoff utility
|
||||
│ │ └── discord/
|
||||
│ │ └── clientOptions.ts # Discord.js client configuration
|
||||
│ ├── modules/
|
||||
│ │ ├── message-capture/ # Modular MVC: Message capture & storage
|
||||
│ │ │ ├── messageCapture.ts # Controller: Discord event listeners
|
||||
│ │ │ ├── messageStore.ts # Repository: Database operations
|
||||
│ │ │ ├── messageMetadata.ts # Service: Message metadata extraction
|
||||
│ │ │ ├── types.ts # Domain types
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── ai-moderation/ # Modular MVC: AI analysis & moderation
|
||||
│ │ │ ├── aiAnalyzer.ts # Controller: Analysis orchestration
|
||||
│ │ │ ├── llmModerationClient.ts # Service: LLM API client
|
||||
│ │ │ ├── aiAnalysisWorker.ts # Service: Worker pool management
|
||||
│ │ │ ├── indonesianTextNormalizer.ts # Service: Text normalization
|
||||
│ │ │ ├── moderationPrompt.ts # Service: Prompt generation
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── voice-recording/ # Modular MVC: Voice recording & streaming
|
||||
│ │ │ ├── voiceController.ts # Controller: Voice connection management
|
||||
│ │ │ ├── recorder.ts # Service: Recording orchestration
|
||||
│ │ │ ├── recorder/
|
||||
│ │ │ │ ├── audioStream.ts # Service: Audio stream subscription
|
||||
│ │ │ │ ├── decoder.ts # Service: Opus decoding
|
||||
│ │ │ │ ├── segment.ts # Service: OGG segment rotation
|
||||
│ │ │ │ ├── metadata.ts # Service: Segment metadata
|
||||
│ │ │ │ ├── sessionRecording.ts # Service: Session management
|
||||
│ │ │ │ └── uploader.ts # Service: Segment upload
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── attachment-upload/ # Modular MVC: Attachment handling
|
||||
│ │ │ ├── attachmentUploader.ts # Service: Upload orchestration
|
||||
│ │ │ ├── imageResizer.ts # Service: Image resizing
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ └── event-broadcaster/ # Event-driven: Redis pub/sub
|
||||
│ │ ├── eventBroadcaster.ts # Service: Event publishing
|
||||
│ │ ├── eventTypes.ts # Domain: Event type definitions
|
||||
│ │ └── index.ts # Module exports
|
||||
│ ├── mock-crc.ts # CRC polyfill for discord.js
|
||||
│ └── index.ts # Service entry point
|
||||
├── package.json # Service dependencies
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
|
||||
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
|
||||
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
|
||||
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
|
||||
│ │ ├── logger/ # pino wrapper + createChildLogger()
|
||||
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
|
||||
│ │ ├── utils/ # retry, pagination
|
||||
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
|
||||
│ │ ├── uploader.ts # Shared attachment upload helper
|
||||
│ │ ├── redis-channels.ts # Redis channel-name constants
|
||||
│ │ └── moderation-types.ts # Shared AI analysis domain types
|
||||
│ └── modules/
|
||||
│ ├── message-capture/ # Discord event listeners + DB store
|
||||
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
|
||||
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
|
||||
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
|
||||
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
|
||||
│ ├── attachment-upload/ # Download + (sharp) resize + upload
|
||||
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
|
||||
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||
│ ├── channel-topic/ guild-member-events/
|
||||
│ └── gateway-metrics/ # Prometheus /metrics endpoint (port 4016)
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
## AI moderation pipeline (`ai-moderation/`)
|
||||
|
||||
### Modular MVC Structure
|
||||
Each module follows Controller-Service-Repository pattern:
|
||||
- **Controller**: Discord event listeners (messageCapture, aiAnalyzer, voiceController)
|
||||
- **Service**: Business logic (messageStore, llmModerationClient, recorder)
|
||||
- **Repository**: Data access (messageStore, voiceRecordingRepo)
|
||||
LLM-only judge — no regex/heuristic classification. One orchestrator call
|
||||
handles a whole batch (text + media split internally, parallel paths).
|
||||
|
||||
### Event-Driven Design
|
||||
- **Redis Pub/Sub**: All events published to Redis channels
|
||||
- **Event Channels**:
|
||||
- `discord:message:created` — New message captured
|
||||
- `discord:message:updated` — Message edited
|
||||
- `discord:message:deleted` — Message deleted
|
||||
- `discord:message:analyzed` — AI analysis complete
|
||||
- `discord:attachment:created` — Attachment detected
|
||||
- `discord:attachment:uploaded` — Attachment uploaded to storage
|
||||
- `discord:voice:started` — Voice recording started
|
||||
- `discord:voice:stopped` — Voice recording stopped
|
||||
- `discord:voice:uploaded` — Voice segment uploaded
|
||||
- `discord:analysis:queue_status` — Analysis queue status update
|
||||
- `aiAnalyzer.ts` — public API: `queueMessageAnalysis`, `getAnalysisQueueStatus`,
|
||||
`startPendingAIAnalysisWorker` (recovery worker + cache-prune).
|
||||
- `batchScheduler.ts` — per-conversation debounce → `processBatch`.
|
||||
- `batchProcessor.ts` — batch lock/circuit-breaker, fans failed targets to
|
||||
individual fallback.
|
||||
- `individualFallbackProcessor.ts` — one-message-at-a-time retry path, own CB.
|
||||
- `conversationState.ts` / `circuitBreaker.ts` — per-conversation state,
|
||||
Piscina `workerPool`, `getConversationKey`.
|
||||
- `ai-analysis-worker.ts` — Piscina entry point (`batch` / `individual` jobs).
|
||||
Runs `runModerationAnalysis` off the main thread.
|
||||
- `moderationOrchestrator.ts` — exact-hash cache → batched semantic (Qdrant)
|
||||
cache → LLM. Text and media paths run in parallel.
|
||||
- `textBatchProcessor.ts` / `mediaBatchProcessor.ts` — actual LLM calls
|
||||
(one call per sub-batch, not per message).
|
||||
- `llmClient.ts` — central OpenAI-compatible chat client (streaming, retries,
|
||||
thinking-disable injection). `visionAnalyzer.ts` / `mediaAnalysisClient.ts`
|
||||
share the same router/base URL (different model alias for vision).
|
||||
- `embeddingClient.ts` + `qdrantClient.ts` — semantic cache (one embed call +
|
||||
one batched Qdrant search for all uncached targets).
|
||||
- `textCacheStore.ts` / `channelCultureStore.ts` / `userProfileStore.ts` /
|
||||
`userReputationStore.ts` — caches & learned per-channel/user state.
|
||||
|
||||
### Shared Infrastructure
|
||||
- **Config**: Zod-validated environment variables
|
||||
- **Logger**: Winston logger with context support
|
||||
- **Database**: Drizzle ORM with PostgreSQL
|
||||
- **Errors**: Custom error classes with codes and status codes
|
||||
- **Utils**: Retry logic with exponential backoff
|
||||
### Concurrency model
|
||||
|
||||
### No HTTP Server
|
||||
- Discord Gateway service is **event-driven only**
|
||||
- No Express, WebSocket, or HTTP routes
|
||||
- All communication via Redis pub/sub
|
||||
- Backend service consumes events and serves HTTP API
|
||||
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
||||
`llmClient.withLlmConcurrency`.
|
||||
- Piscina pool (`PISCINA_MAX_THREADS`, default 4) runs the heavy LLM work off
|
||||
the event loop; **each worker thread initializes its own pg Pool** (min 0,
|
||||
grows to `POSTGRES_POOL_MAX`). See "Memory & connections" below.
|
||||
|
||||
## Initialization Flow
|
||||
## Memory & DB connections
|
||||
|
||||
1. Load environment config (Zod validation)
|
||||
2. Initialize database connection
|
||||
3. Run pending migrations
|
||||
4. Create Discord client with optimized cache settings
|
||||
5. Initialize Redis event broadcaster
|
||||
6. Register Discord event listeners (messageCapture, aiAnalyzer)
|
||||
7. Login to Discord
|
||||
8. Listen for graceful shutdown signals (SIGINT, SIGTERM)
|
||||
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
||||
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
||||
|
||||
## Graceful Shutdown
|
||||
`POSTGRES_POOL_MIN=0` (default). The gateway = main process + up to 4 Piscina
|
||||
worker threads, each with its own pg Pool. With min:0 the pools stay empty
|
||||
until a query runs and drop idle clients afterward, instead of holding
|
||||
`(1 main + 4 workers) × 2 = 10` permanently-open idle connections against
|
||||
PgBouncer. The pool still grows on demand up to `POSTGRES_POOL_MAX`.
|
||||
|
||||
On shutdown signal:
|
||||
1. Close database connection
|
||||
2. Disconnect from voice channels
|
||||
3. Close Redis connection
|
||||
4. Destroy Discord client
|
||||
5. Exit process
|
||||
## Event channels (Redis pub/sub)
|
||||
|
||||
## Dependencies
|
||||
`discord:message:{created,updated,deleted,analyzed}`,
|
||||
`discord:attachment:{created,uploaded}`,
|
||||
`discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}`,
|
||||
`discord:analysis:queue_status`,
|
||||
`discord:reaction:{added,removed}`,
|
||||
`discord:thread:{created,deleted,updated}`,
|
||||
`discord:channel_topic:updated`,
|
||||
`discord:presence:updated`,
|
||||
`discord:guild_member:{added,removed}`.
|
||||
See `src/shared/redis-channels.ts` for the canonical names.
|
||||
|
||||
**Core Discord**:
|
||||
- discord.js-selfbot-v13
|
||||
- @discordjs/voice
|
||||
- @discordjs/opus
|
||||
## Initialization flow
|
||||
|
||||
**Audio Processing**:
|
||||
- prism-media (Opus encoding/decoding)
|
||||
- opusscript (Opus fallback)
|
||||
- sharp (Image resizing)
|
||||
1. Validate env (Zod). Refuse to start if `AI_ANALYSIS_ENABLED` but no key.
|
||||
2. `AUTO_MIGRATE_ON_STARTUP` → run pending Drizzle migrations.
|
||||
3. `initializeDatabase()` (pg Pool, min 0).
|
||||
4. Create discord.js-selfbot-v13 client; register listeners on `ready`.
|
||||
5. Start `gmw-discord-gateway` metrics server (port `METRICS_PORT`, default 4016).
|
||||
6. `client.login(token)`.
|
||||
|
||||
**Data & Config**:
|
||||
- drizzle-orm (ORM)
|
||||
- pg (PostgreSQL driver)
|
||||
- zod (Config validation)
|
||||
- ioredis (Redis client)
|
||||
## Graceful shutdown
|
||||
|
||||
**Logging & Utilities**:
|
||||
- winston (Structured logging)
|
||||
- p-retry (Retry logic)
|
||||
- p-limit (Concurrency limiting)
|
||||
- piscina (Worker pool)
|
||||
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
|
||||
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
|
||||
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
|
||||
close command handler → close DB → destroy client → exit.
|
||||
|
||||
## Event Flow Example
|
||||
## Observability
|
||||
|
||||
### Message Capture Flow
|
||||
1. Discord emits `messageCreate` event
|
||||
2. `messageCapture.ts` listener receives event
|
||||
3. Extract metadata (user, channel, content, timestamp)
|
||||
4. `messageStore.ts` inserts into database
|
||||
5. `eventBroadcaster.messageCreated()` publishes to Redis
|
||||
6. Backend service subscribes to `discord:message:created` channel
|
||||
7. Backend processes and stores in its own database
|
||||
Prometheus scrapes `127.0.0.1:4016/metrics` (`bete_*` prefix). Collectors run
|
||||
per-scrape and expose: process memory/uptime, and (when AI analysis is on) live
|
||||
pipeline gauges — `ai_analysis_queued_conversations`,
|
||||
`ai_analysis_active_batch_requests`, `ai_analysis_active_individual_requests`,
|
||||
`ai_analysis_individual_in_flight`, `ai_analysis_individual_circuit_breaker_active`,
|
||||
`ai_analysis_worker_threads`, `ai_analysis_worker_threads_active`.
|
||||
|
||||
### Voice Recording Flow
|
||||
1. `voiceController.connect()` joins voice channel
|
||||
2. `recorder.ts` subscribes to user audio streams
|
||||
3. For each speaking user:
|
||||
- Create audio stream subscription
|
||||
- Decode Opus packets to PCM
|
||||
- Rotate OGG segments (5s default)
|
||||
- Collect user metadata
|
||||
4. On silence (3s):
|
||||
- Finalize segment
|
||||
- Create metadata JSON
|
||||
- Upload segment to storage
|
||||
- Publish `discord:voice:uploaded` event
|
||||
5. Backend service receives event and indexes recording
|
||||
## Key invariants (do not break)
|
||||
|
||||
## No Breaking Changes
|
||||
|
||||
- Original `src/` remains untouched for now
|
||||
- Discord Gateway is a **new service** in `services/discord-gateway/`
|
||||
- Can run alongside existing monolith during transition
|
||||
- Backend service will consume Redis events
|
||||
- Frontend continues to use Backend HTTP API
|
||||
- **LLM is the only judge.** Failed LLM → `status:"error"` + recovery retry.
|
||||
Never reintroduce regex/heuristic content classification.
|
||||
- **Discord tokens are sanitized** (`discordTokens.ts`: `<:emoji:id>` →
|
||||
`[emoji:name]`, `<@id>` → `@user`, etc.) before content reaches the LLM, so
|
||||
numeric snowflake IDs never trigger false positives.
|
||||
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
||||
not N sequential round-trips. `ensureQdrantCollection` is memoized.
|
||||
- **Streaming is mandatory** against the 9router base URL (non-stream waits for
|
||||
the full body and times out). `llmClient` aggregates SSE chunks.
|
||||
|
||||
@@ -1,408 +1,80 @@
|
||||
# Discord Gateway Service - Module Structure
|
||||
# Discord Gateway Service — Module Structure
|
||||
|
||||
## Complete Directory Tree
|
||||
> Kept as a compact module map. For the authoritative layout, design
|
||||
> decisions, and invariants, see `ARCHITECTURE.md`. This file was rewritten
|
||||
> on 2026-08-16 to fix stale references (`winston` → pino,
|
||||
> `mock-crc.ts`/`indonesianTextNormalizer.ts` removed,
|
||||
> `aiAnalysisWorker.ts` → `ai-analysis-worker.ts`,
|
||||
> `llmModerationClient.ts` → `llmClient.ts`).
|
||||
|
||||
## Top-level
|
||||
|
||||
```
|
||||
services/discord-gateway/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── bootstrap.ts
|
||||
│ │ │ └── Initializes Discord client, database, Redis broadcaster
|
||||
│ │ │ Registers event listeners, handles graceful shutdown
|
||||
│ │ └── shutdown.ts
|
||||
│ │ └── Graceful shutdown handler for SIGINT/SIGTERM/exceptions
|
||||
│ │
|
||||
│ ├── shared/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.ts
|
||||
│ │ │ └── Zod-validated environment configuration
|
||||
│ │ │ - Discord token, database URL, Redis URL
|
||||
│ │ │ - AI LLM settings, recording parameters
|
||||
│ │ │ - Attachment upload settings, retention policies
|
||||
│ │ │
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── schema.ts
|
||||
│ │ │ │ └── Drizzle ORM schema definitions
|
||||
│ │ │ ├── drizzle.ts
|
||||
│ │ │ │ └── PostgreSQL connection and initialization
|
||||
│ │ │ ├── migrate.ts
|
||||
│ │ │ │ └── Database migration runner
|
||||
│ │ │ ├── migrateCli.ts
|
||||
│ │ │ │ └── CLI for programmatic migrations
|
||||
│ │ │ ├── voiceRecordingRepo.ts
|
||||
│ │ │ │ └── Voice recording repository
|
||||
│ │ │ └── migrations/
|
||||
│ │ │ └── Database migration files
|
||||
│ │ │
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── errors.ts
|
||||
│ │ │ └── Custom error classes
|
||||
│ │ │ - AppError (base)
|
||||
│ │ │ - ConfigError
|
||||
│ │ │ - AudioError
|
||||
│ │ │ - VoiceConnectionError
|
||||
│ │ │ - ValidationError
|
||||
│ │ │
|
||||
│ │ ├── logger/
|
||||
│ │ │ ├── logger.ts
|
||||
│ │ │ │ └── Winston logger wrapper with context support
|
||||
│ │ │ └── serialization.ts
|
||||
│ │ │ └── Log value serialization utilities
|
||||
│ │ │
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── retry.ts
|
||||
│ │ │ └── Retry with exponential backoff utility
|
||||
│ │ │
|
||||
│ │ └── discord/
|
||||
│ │ └── clientOptions.ts
|
||||
│ │ └── Discord.js client configuration
|
||||
│ │
|
||||
│ ├── modules/
|
||||
│ │ │
|
||||
│ │ ├── message-capture/
|
||||
│ │ │ ├── messageCapture.ts
|
||||
│ │ │ │ └── CONTROLLER: Discord event listeners
|
||||
│ │ │ │ - messageCreate, messageUpdate, messageDelete
|
||||
│ │ │ │ - Validates capture target, publishes events
|
||||
│ │ │ │
|
||||
│ │ │ ├── messageStore.ts
|
||||
│ │ │ │ └── REPOSITORY: Database CRUD operations
|
||||
│ │ │ │ - upsertMessageForCapture
|
||||
│ │ │ │ - updateMessageAsEdited
|
||||
│ │ │ │ - updateMessageAsDeleted
|
||||
│ │ │ │ - insertAttachment
|
||||
│ │ │ │ - getMessageById
|
||||
│ │ │ │
|
||||
│ │ │ ├── messageMetadata.ts
|
||||
│ │ │ │ └── SERVICE: Message metadata extraction
|
||||
│ │ │ │ - getMessageMetadata
|
||||
│ │ │ │ - getMessageLocation
|
||||
│ │ │ │ - getDisplayContent
|
||||
│ │ │ │
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ │ └── Domain types
|
||||
│ │ │ │ - MessageRecord
|
||||
│ │ │ │ - AttachmentRecord
|
||||
│ │ │ │ - VoiceSegmentRecord
|
||||
│ │ │ │ - AIStatus, AISeverity, AIRecommendedAction
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── ai-moderation/
|
||||
│ │ │ ├── aiAnalyzer.ts
|
||||
│ │ │ │ └── CONTROLLER: Analysis orchestration
|
||||
│ │ │ │ - startPendingAIAnalysisWorker
|
||||
│ │ │ │ - queueMessageAnalysis
|
||||
│ │ │ │ - Manages analysis queue and worker pool
|
||||
│ │ │ │
|
||||
│ │ │ ├── llmModerationClient.ts
|
||||
│ │ │ │ └── SERVICE: LLM API integration
|
||||
│ │ │ │ - Calls LLM for text/image moderation
|
||||
│ │ │ │ - Parses responses, handles errors
|
||||
│ │ │ │ - Retry logic with backoff
|
||||
│ │ │ │
|
||||
│ │ │ ├── aiAnalysisWorker.ts
|
||||
│ │ │ │ └── SERVICE: Worker pool management
|
||||
│ │ │ │ - Piscina worker pool for parallel analysis
|
||||
│ │ │ │ - Conversation context batching
|
||||
│ │ │ │
|
||||
│ │ │ ├── indonesianTextNormalizer.ts
|
||||
│ │ │ │ └── SERVICE: Text preprocessing
|
||||
│ │ │ │ - Normalize Indonesian text
|
||||
│ │ │ │ - Handle diacritics, abbreviations
|
||||
│ │ │ │
|
||||
│ │ │ ├── moderationPrompt.ts
|
||||
│ │ │ │ └── SERVICE: Prompt generation
|
||||
│ │ │ │ - Generate LLM prompts for moderation
|
||||
│ │ │ │ - Include context and policy
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── voice-recording/
|
||||
│ │ │ ├── voiceController.ts
|
||||
│ │ │ │ └── CONTROLLER: Voice connection management
|
||||
│ │ │ │ - connect(guildId, channelId)
|
||||
│ │ │ │ - disconnect()
|
||||
│ │ │ │ - listGuilds(), listVoiceChannels()
|
||||
│ │ │ │ - getStatus()
|
||||
│ │ │ │
|
||||
│ │ │ ├── recorder.ts
|
||||
│ │ │ │ └── SERVICE: Recording orchestration
|
||||
│ │ │ │ - startRecording(client, channel)
|
||||
│ │ │ │ - stopRecording(guildId)
|
||||
│ │ │ │ - Manages active recording sessions
|
||||
│ │ │ │
|
||||
│ │ │ ├── recorder/
|
||||
│ │ │ │ ├── audioStream.ts
|
||||
│ │ │ │ │ └── SERVICE: Audio stream subscription
|
||||
│ │ │ │ │ - subscribeToAudioStream
|
||||
│ │ │ │ │ - Opus packet handling
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── decoder.ts
|
||||
│ │ │ │ │ └── SERVICE: Opus decoding
|
||||
│ │ │ │ │ - OpusDecoder class
|
||||
│ │ │ │ │ - Decode Opus to PCM
|
||||
│ │ │ │ │ - Rotation and cooldown logic
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── segment.ts
|
||||
│ │ │ │ │ └── SERVICE: OGG segment rotation
|
||||
│ │ │ │ │ - SegmentManager class
|
||||
│ │ │ │ │ - Rotate segments (5s default)
|
||||
│ │ │ │ │ - Write OGG files
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── metadata.ts
|
||||
│ │ │ │ │ └── SERVICE: Segment metadata
|
||||
│ │ │ │ │ - collectUserMetadata
|
||||
│ │ │ │ │ - createSegmentMetadata
|
||||
│ │ │ │ │ - User info, roles, timestamps
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── sessionRecording.ts
|
||||
│ │ │ │ │ └── SERVICE: Session management
|
||||
│ │ │ │ │ - createRecordingSession
|
||||
│ │ │ │ │ - finalizeRecordingSession
|
||||
│ │ │ │ │ - Track active sessions
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── uploader.ts
|
||||
│ │ │ │ └── SERVICE: Segment upload
|
||||
│ │ │ │ - uploadRecordingSegment
|
||||
│ │ │ │ - Upload to external storage
|
||||
│ │ │ │ - Retry logic
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── attachment-upload/
|
||||
│ │ │ ├── attachmentUploader.ts
|
||||
│ │ │ │ └── SERVICE: Upload orchestration
|
||||
│ │ │ │ - processAttachmentUpload
|
||||
│ │ │ │ - Download from Discord
|
||||
│ │ │ │ - Upload to external storage
|
||||
│ │ │ │ - Retry with backoff
|
||||
│ │ │ │
|
||||
│ │ │ ├── imageResizer.ts
|
||||
│ │ │ │ └── SERVICE: Image processing
|
||||
│ │ │ │ - resizeImage
|
||||
│ │ │ │ - Resize to max dimension
|
||||
│ │ │ │ - Preserve aspect ratio
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ └── event-broadcaster/
|
||||
│ │ ├── eventBroadcaster.ts
|
||||
│ │ │ └── SERVICE: Redis pub/sub publisher
|
||||
│ │ │ - EventBroadcaster class
|
||||
│ │ │ - RedisEventPublisher class
|
||||
│ │ │ - Publish to Redis channels
|
||||
│ │ │ - Methods:
|
||||
│ │ │ - messageCreated()
|
||||
│ │ │ - messageUpdated()
|
||||
│ │ │ - messageDeleted()
|
||||
│ │ │ - messageAnalyzed()
|
||||
│ │ │ - attachmentCreated()
|
||||
│ │ │ - attachmentUploaded()
|
||||
│ │ │ - voiceRecordingStarted()
|
||||
│ │ │ - voiceRecordingStopped()
|
||||
│ │ │ - voiceRecordingUploaded()
|
||||
│ │ │ - analysisQueueStatus()
|
||||
│ │ │
|
||||
│ │ ├── eventTypes.ts
|
||||
│ │ │ └── Domain types
|
||||
│ │ │ - DiscordGatewayEvent interface
|
||||
│ │ │ - EventChannels constants
|
||||
│ │ │ - Event channel names
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ └── Module exports
|
||||
│
|
||||
│ ├── mock-crc.ts
|
||||
│ │ └── CRC polyfill for discord.js compatibility
|
||||
│ │
|
||||
│ └── index.ts
|
||||
│ └── Service entry point
|
||||
│ - Initialize Discord Gateway
|
||||
│ - Handle startup errors
|
||||
│
|
||||
├── ARCHITECTURE.md
|
||||
│ └── Detailed architecture documentation
|
||||
│
|
||||
├── README.md
|
||||
│ └── Complete service documentation
|
||||
│
|
||||
├── MODULE_STRUCTURE.md
|
||||
│ └── This file - module structure reference
|
||||
│
|
||||
└── package.json
|
||||
└── Service dependencies and scripts
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── app/ # bootstrap, shutdown, retention
|
||||
│ ├── shared/ # config, database, logger, errors, utils, discord, uploader
|
||||
│ └── modules/
|
||||
│ ├── message-capture/ # Discord listeners + DB store + metadata
|
||||
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
|
||||
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
|
||||
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
|
||||
│ ├── attachment-upload/ # Download + sharp resize + upload
|
||||
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||
│ ├── command-handler/ # Backend→gateway Redis commands
|
||||
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||
│ ├── channel-topic/ guild-member-events/
|
||||
│ └── gateway-metrics/ # Prometheus /metrics (port 4016)
|
||||
├── tests/ # Vitest suites (129 tests)
|
||||
├── drizzle/ # Drizzle migration SQL + journal
|
||||
├── ARCHITECTURE.md README.md package.json tsconfig.json vitest.config.ts
|
||||
```
|
||||
|
||||
## Module Responsibilities
|
||||
## Module responsibilities (summary)
|
||||
|
||||
### message-capture
|
||||
**Purpose**: Capture Discord messages (create, update, delete)
|
||||
**Pattern**: Controller-Service-Repository
|
||||
- **Controller** (messageCapture.ts): Listens to Discord events
|
||||
- **Service** (messageMetadata.ts): Extracts metadata
|
||||
- **Repository** (messageStore.ts): Database operations
|
||||
- **Events Published**:
|
||||
- `discord:message:created`
|
||||
- `discord:message:updated`
|
||||
- `discord:message:deleted`
|
||||
Captures `messageCreate`/`messageUpdate`/`messageDelete`, extracts metadata,
|
||||
stores to Postgres, publishes to Redis. Controller–Service–Repository split:
|
||||
`messageCapture.ts` (listener) → `messageStore.ts` (DB) + `messageMetadata.ts`
|
||||
(service).
|
||||
|
||||
### ai-moderation
|
||||
**Purpose**: Analyze messages with LLM for moderation
|
||||
**Pattern**: Controller-Service-Service-Service
|
||||
- **Controller** (aiAnalyzer.ts): Orchestrates analysis workflow
|
||||
- **Service** (llmModerationClient.ts): LLM API integration
|
||||
- **Service** (aiAnalysisWorker.ts): Worker pool management
|
||||
- **Service** (indonesianTextNormalizer.ts): Text preprocessing
|
||||
- **Service** (moderationPrompt.ts): Prompt generation
|
||||
- **Events Published**:
|
||||
- `discord:message:analyzed`
|
||||
- `discord:analysis:queue_status`
|
||||
LLM-only moderation. Entry: `aiAnalyzer.ts` (`queueMessageAnalysis`,
|
||||
`startPendingAIAnalysisWorker`, `getAnalysisQueueStatus`). Scheduling:
|
||||
`batchScheduler.ts` → `batchProcessor.ts` (batch lock + circuit breaker) →
|
||||
`individualFallbackProcessor.ts` (per-message retry). Heavy work runs in the
|
||||
Piscina pool via `ai-analysis-worker.ts` (jobs `batch` / `individual`).
|
||||
Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched
|
||||
semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
|
||||
(one LLM call per sub-batch), `llmClient.ts` (central streaming client),
|
||||
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
|
||||
`channelCultureStore.ts` / `userProfileStore.ts` / `userReputationStore.ts`.
|
||||
|
||||
### voice-recording
|
||||
**Purpose**: Record voice channel audio
|
||||
**Pattern**: Controller-Service-SubServices
|
||||
- **Controller** (voiceController.ts): Voice connection management
|
||||
- **Service** (recorder.ts): Recording orchestration
|
||||
- **Sub-services** (recorder/*): Audio processing pipeline
|
||||
- audioStream.ts: Opus packet subscription
|
||||
- decoder.ts: Opus to PCM decoding
|
||||
- segment.ts: OGG file rotation
|
||||
- metadata.ts: User metadata collection
|
||||
- sessionRecording.ts: Session lifecycle
|
||||
- uploader.ts: Segment upload
|
||||
- **Events Published**:
|
||||
- `discord:voice:started`
|
||||
- `discord:voice:stopped`
|
||||
- `discord:voice:uploaded`
|
||||
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
|
||||
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
|
||||
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
|
||||
|
||||
### attachment-upload
|
||||
**Purpose**: Upload message attachments to external storage
|
||||
**Pattern**: Service-Service
|
||||
- **Service** (attachmentUploader.ts): Upload orchestration
|
||||
- **Service** (imageResizer.ts): Image processing
|
||||
- **Events Published**:
|
||||
- `discord:attachment:created`
|
||||
- `discord:attachment:uploaded`
|
||||
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
|
||||
(sharp resize). Emits `discord:attachment:*`.
|
||||
|
||||
### event-broadcaster
|
||||
**Purpose**: Publish events to Redis pub/sub
|
||||
**Pattern**: Service-Domain
|
||||
- **Service** (eventBroadcaster.ts): Redis publisher
|
||||
- **Domain** (eventTypes.ts): Event type definitions
|
||||
- **Channels**:
|
||||
- discord:message:* (message events)
|
||||
- discord:attachment:* (attachment events)
|
||||
- discord:voice:* (voice events)
|
||||
- discord:analysis:* (analysis events)
|
||||
`RedisEventPublisher` (ioredis publish) + `EventBroadcaster` (typed methods).
|
||||
Channel names in `src/shared/redis-channels.ts`.
|
||||
|
||||
## Shared Infrastructure
|
||||
### gateway-metrics
|
||||
`metrics.ts` Prometheus HTTP server on `METRICS_PORT` (4016). Collectors run
|
||||
per scrape; live pipeline gauges registered in `bootstrap.ts`.
|
||||
|
||||
### config
|
||||
- Zod-validated environment variables
|
||||
- Type-safe configuration access
|
||||
- Sensible defaults
|
||||
## Shared infrastructure
|
||||
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
|
||||
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
|
||||
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
|
||||
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
|
||||
|
||||
### database
|
||||
- Drizzle ORM schema
|
||||
- PostgreSQL connection
|
||||
- Migration management
|
||||
- Voice recording repository
|
||||
|
||||
### logger
|
||||
- Winston logger wrapper
|
||||
- Context-aware logging
|
||||
- Log serialization utilities
|
||||
|
||||
### errors
|
||||
- Custom error classes
|
||||
- Error codes and HTTP status codes
|
||||
- Proper error hierarchy
|
||||
|
||||
### utils
|
||||
- Retry with exponential backoff
|
||||
- Configurable retry parameters
|
||||
|
||||
### discord
|
||||
- Discord.js client configuration
|
||||
- Cache optimization
|
||||
- Partial handling
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Discord Events
|
||||
↓
|
||||
message-capture (Controller)
|
||||
↓
|
||||
messageStore (Repository) → PostgreSQL
|
||||
↓
|
||||
eventBroadcaster (Service)
|
||||
↓
|
||||
Redis Pub/Sub
|
||||
↓
|
||||
Backend Service (Subscriber)
|
||||
↓
|
||||
HTTP API / WebSocket
|
||||
↓
|
||||
Frontend Application
|
||||
```
|
||||
|
||||
## No HTTP Server
|
||||
|
||||
- ✅ No Express
|
||||
- ✅ No WebSocket server
|
||||
- ✅ No HTTP routes
|
||||
- ✅ No middleware
|
||||
- ✅ Pure event-driven service
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
1. Close PostgreSQL connection
|
||||
2. Disconnect from voice channels
|
||||
3. Close Redis connection
|
||||
4. Destroy Discord client
|
||||
5. Exit process
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Discord**:
|
||||
- discord.js-selfbot-v13
|
||||
- @discordjs/voice
|
||||
- @discordjs/opus
|
||||
|
||||
**Audio**:
|
||||
- prism-media
|
||||
- opusscript
|
||||
- sharp
|
||||
|
||||
**Data**:
|
||||
- drizzle-orm
|
||||
- pg
|
||||
- zod
|
||||
- ioredis
|
||||
|
||||
**Logging**:
|
||||
- winston
|
||||
- p-retry
|
||||
- p-limit
|
||||
- piscina
|
||||
|
||||
## Summary
|
||||
|
||||
The Discord Gateway service is a **pure event-driven microservice** that:
|
||||
- Captures Discord messages, voice, and attachments
|
||||
- Performs AI moderation analysis
|
||||
- Publishes events to Redis pub/sub
|
||||
- Has no HTTP server or WebSocket
|
||||
- Follows Modular MVC pattern
|
||||
- Maintains clean module boundaries
|
||||
- Provides type-safe configuration
|
||||
- Includes structured logging
|
||||
- Handles graceful shutdown
|
||||
|
||||
The service is designed to run alongside the Backend service, which consumes Redis events and serves the HTTP API to the Frontend.
|
||||
## Notes
|
||||
- No HTTP server (other than the metrics endpoint). Pure event-driven.
|
||||
- `MODULE_STRUCTURE.md` is intentionally a sketch; `ARCHITECTURE.md` is the
|
||||
detailed reference. When they diverge, `ARCHITECTURE.md` wins.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS "term_glossary_cache" (
|
||||
"term" text PRIMARY KEY NOT NULL,
|
||||
"definition" text NOT NULL,
|
||||
"source_url" text DEFAULT '' NOT NULL,
|
||||
"resolved_at" bigint NOT NULL,
|
||||
"hit_count" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "idx_term_glossary_cache_resolved_at" ON "term_glossary_cache" USING btree ("resolved_at");
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1785551832190,
|
||||
"tag": "0013_rename_mascot_chat_to_chatbot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1785621600000,
|
||||
"tag": "0014_add_term_glossary_cache",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,11 +7,8 @@
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
"@lng2004/node-datachannel",
|
||||
"esbuild",
|
||||
"node-av",
|
||||
"sharp",
|
||||
"zeromq"
|
||||
"sharp"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
@@ -24,7 +21,6 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dank074/discord-video-stream": "6.0.0",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@snazzah/davey": "^0.1.11",
|
||||
@@ -32,7 +28,6 @@
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"imghash": "^1.1.4",
|
||||
"ioredis": "^5.11.0",
|
||||
"libsodium-wrappers": "^0.8.4",
|
||||
"lru-cache": "^11.5.1",
|
||||
|
||||
Generated
+36
-896
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ allowBuilds:
|
||||
"@lng2004/node-datachannel": true
|
||||
esbuild: true
|
||||
node-av: true
|
||||
sharp: true
|
||||
zeromq: true
|
||||
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
|
||||
# package.json is ignored). Native voice deps need their postinstall build.
|
||||
|
||||
@@ -3,7 +3,11 @@ import { inArray, lt } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { ConfigError, DatabaseError } from "@/shared/errors/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
|
||||
import {
|
||||
getAnalysisQueueStatus,
|
||||
startPendingAIAnalysisWorker,
|
||||
} from "../modules/ai-moderation/aiAnalyzer.js";
|
||||
import { workerPool } from "../modules/ai-moderation/circuitBreaker.js";
|
||||
import { registerChannelTopicCapture } from "../modules/channel-topic/index.js";
|
||||
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
||||
import {
|
||||
@@ -11,6 +15,8 @@ import {
|
||||
RedisEventPublisher,
|
||||
} from "../modules/event-broadcaster/index.js";
|
||||
import {
|
||||
registerCollector,
|
||||
setGauge,
|
||||
startMetricsServer,
|
||||
stopMetricsServer,
|
||||
} from "../modules/gateway-metrics/index.js";
|
||||
@@ -222,7 +228,10 @@ export async function initializeDiscordGateway() {
|
||||
await initializeDatabase();
|
||||
logger.info("PostgreSQL database initialized");
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to initialize database");
|
||||
logger.error(
|
||||
{ err, errorMsg: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to initialize database",
|
||||
);
|
||||
throw new DatabaseError(
|
||||
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
@@ -267,7 +276,10 @@ export async function initializeDiscordGateway() {
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error({ error: err }, "Client error");
|
||||
logger.error(
|
||||
{ err, errorMsg: err instanceof Error ? err.message : String(err) },
|
||||
"Client error",
|
||||
);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
@@ -279,15 +291,97 @@ export async function initializeDiscordGateway() {
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.error({ error: err }, "Uncaught exception");
|
||||
const code =
|
||||
typeof (err as NodeJS.ErrnoException).code === "string"
|
||||
? (err as NodeJS.ErrnoException).code
|
||||
: "";
|
||||
// Transient stream-teardown errors (voice stop/disconnect races, child
|
||||
// process stdin closed while we still write) are NOT fatal — crashing the
|
||||
// gateway on EPIPE takes the whole bot offline mid-music. Log + continue.
|
||||
if (
|
||||
code === "EPIPE" ||
|
||||
code === "ERR_STREAM_DESTROYED" ||
|
||||
code === "ERR_STREAM_WRITE_AFTER_END" ||
|
||||
code === "ECONNRESET"
|
||||
) {
|
||||
logger.warn(
|
||||
{ error: err },
|
||||
"Uncaught transient stream error — continuing",
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
{
|
||||
err,
|
||||
errorMsg: err instanceof Error ? err.message : String(err),
|
||||
stack: err?.stack,
|
||||
},
|
||||
"Uncaught exception",
|
||||
);
|
||||
gracefulShutdown("uncaughtException");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
logger.error({ reason, promise }, "Unhandled rejection");
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const err =
|
||||
reason instanceof Error ? reason : new Error(String(reason ?? "unknown"));
|
||||
const code = (err as NodeJS.ErrnoException).code ?? "";
|
||||
// Same transient-teardown policy as uncaughtException: a rejection that
|
||||
// fires while a stream is being torn down (EPIPE after ffmpeg stdin
|
||||
// closes, write-after-destroy, socket reset) must NOT take the whole
|
||||
// gateway offline. Log detail + continue. Everything else still shuts
|
||||
// down so real bugs surface.
|
||||
if (
|
||||
code === "EPIPE" ||
|
||||
code === "ERR_STREAM_DESTROYED" ||
|
||||
code === "ERR_STREAM_WRITE_AFTER_END" ||
|
||||
code === "ECONNRESET"
|
||||
) {
|
||||
logger.warn(
|
||||
{ error: err },
|
||||
"Unhandled rejection transient stream error — continuing",
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger.error({ error: err, reason: String(reason) }, "Unhandled rejection");
|
||||
gracefulShutdown("unhandledRejection");
|
||||
});
|
||||
|
||||
// ── Metrics: register live pipeline collectors before starting server ──
|
||||
// These refresh on every scrape so Prometheus sees real AI-analysis
|
||||
// queue depth, concurrency, and DB pool state instead of an empty stub.
|
||||
registerCollector(() => {
|
||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||
try {
|
||||
const status = getAnalysisQueueStatus();
|
||||
setGauge("ai_analysis_queued_conversations", status.queuedConversations);
|
||||
setGauge("ai_analysis_active_batch_requests", status.activeRequests);
|
||||
setGauge(
|
||||
"ai_analysis_active_individual_requests",
|
||||
status.activeIndividualRequests,
|
||||
);
|
||||
setGauge(
|
||||
"ai_analysis_individual_in_flight",
|
||||
status.individualInFlightCount,
|
||||
);
|
||||
setGauge(
|
||||
"ai_analysis_individual_circuit_breaker_active",
|
||||
status.individualCircuitBreakerActive ? 1 : 0,
|
||||
);
|
||||
if (typeof status.lastError === "string") {
|
||||
setGauge("ai_analysis_last_error_present", status.lastError ? 1 : 0);
|
||||
}
|
||||
const pool = workerPool as unknown as {
|
||||
_poolState?: { size: number; active: number };
|
||||
};
|
||||
if (pool._poolState) {
|
||||
setGauge("ai_analysis_worker_threads", pool._poolState.size);
|
||||
setGauge("ai_analysis_worker_threads_active", pool._poolState.active);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ error: String(err) }, "AI metrics collector failed");
|
||||
}
|
||||
});
|
||||
|
||||
// Start metrics server
|
||||
startMetricsServer();
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ import { config } from "../../shared/config/config.js";
|
||||
import { initializeDatabase } from "../../shared/database/drizzle.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { buildConversationContext } from "./conversationContext.js";
|
||||
import {
|
||||
buildConversationContext,
|
||||
buildLocationContext,
|
||||
} from "./conversationContext.js";
|
||||
import { buildConversationContextBlock } from "./moderationBuilders.js";
|
||||
import { runModerationAnalysis } from "./moderationOrchestrator.js";
|
||||
|
||||
const logger = createChildLogger("ai-analysis-worker");
|
||||
@@ -274,29 +278,56 @@ async function processBatch(job: {
|
||||
contextBefore,
|
||||
targets: messages,
|
||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
||||
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
||||
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
||||
});
|
||||
const contextBlock = buildConversationContextBlock({
|
||||
location: buildLocationContext(messages),
|
||||
descriptor: contextLines.descriptor,
|
||||
lines: contextLines.lines,
|
||||
});
|
||||
const contextText = contextLines.join("\n");
|
||||
|
||||
const targetIds = messages.map((m) => m.id);
|
||||
const allTargetIds = messages.map((m) => m.id);
|
||||
const contextIds = contextBefore.map((m) => m.id);
|
||||
const attachments = await messageStore.getAttachmentsForMessages([
|
||||
...targetIds,
|
||||
...allTargetIds,
|
||||
...contextIds,
|
||||
]);
|
||||
|
||||
// Attachment-upload race guard: a message whose attachment is still being
|
||||
// uploaded (upload_status='pending') must not be analyzed yet. Its
|
||||
// uploaded_url is not ready, and falling back to the Discord CDN link often
|
||||
// 404s (expired/purged) — which used to silently produce a text-only
|
||||
// verdict ("lampiran yang gagal terbaca"). Leave those targets pending; the
|
||||
// next worker cycle picks them up after the upload lands.
|
||||
const pendingUploadTargetIds = new Set(
|
||||
(attachments ?? [])
|
||||
.filter((a) => a.upload_status === "pending")
|
||||
.map((a) => a.message_id),
|
||||
);
|
||||
const readyMessages =
|
||||
pendingUploadTargetIds.size === 0
|
||||
? messages
|
||||
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
||||
if (readyMessages.length === 0) {
|
||||
return { ok: true, conversationKey, rows: [] };
|
||||
}
|
||||
|
||||
// The orchestrator handles text/media split + caching + parallel paths
|
||||
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
||||
// when media is present), not N per-message calls.
|
||||
const analysisStart = Date.now();
|
||||
const moderationResult = await runModerationAnalysis({
|
||||
targets: messages,
|
||||
contextText,
|
||||
targets: readyMessages,
|
||||
contextBlock,
|
||||
attachments,
|
||||
});
|
||||
const analysisDurationMs = Date.now() - analysisStart;
|
||||
|
||||
const results = moderationResult.results.map((r) =>
|
||||
normalizeResult(
|
||||
r as unknown as AnalysisResult,
|
||||
messages.find((m) => m.id === r.messageId),
|
||||
readyMessages.find((m) => m.id === r.messageId),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -313,6 +344,7 @@ async function processBatch(job: {
|
||||
confidence: result.confidence,
|
||||
recommendedAction: result.recommendedAction,
|
||||
analyzedAt: Date.now(),
|
||||
analysisDurationMs,
|
||||
error: result.status === "error" ? result.analysis : null,
|
||||
},
|
||||
}));
|
||||
@@ -324,9 +356,10 @@ async function processBatch(job: {
|
||||
|
||||
logger.info(
|
||||
{
|
||||
total: messages.length,
|
||||
total: readyMessages.length,
|
||||
saved: allRows.length,
|
||||
conversationKey,
|
||||
skippedPendingUpload: messages.length - readyMessages.length,
|
||||
},
|
||||
"LLM batch analysis complete",
|
||||
);
|
||||
@@ -359,8 +392,14 @@ async function processIndividual(job: {
|
||||
contextBefore,
|
||||
targets: [message],
|
||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
||||
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
|
||||
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
|
||||
});
|
||||
const contextBlock = buildConversationContextBlock({
|
||||
location: buildLocationContext([message]),
|
||||
descriptor: contextLines.descriptor,
|
||||
lines: contextLines.lines,
|
||||
});
|
||||
const contextText = contextLines.join("\n");
|
||||
|
||||
const contextIds = contextBefore.map((m) => m.id);
|
||||
const attachments = await messageStore.getAttachmentsForMessages([
|
||||
@@ -368,10 +407,21 @@ async function processIndividual(job: {
|
||||
...contextIds,
|
||||
]);
|
||||
|
||||
// Same attachment-upload race guard as the batch path: while the upload is
|
||||
// still in-flight the uploaded_url is not ready and the Discord CDN fallback
|
||||
// often 404s — analyzing now would silently produce a text-only verdict.
|
||||
// Return no results so the message stays pending for the next cycle.
|
||||
const uploadStillPending = (attachments ?? []).some(
|
||||
(a) => a.message_id === message.id && a.upload_status === "pending",
|
||||
);
|
||||
if (uploadStillPending) {
|
||||
return { ok: true, results: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const moderationResult = await runModerationAnalysis({
|
||||
targets: [message],
|
||||
contextText,
|
||||
contextBlock,
|
||||
attachments,
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,40 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Parse the flag list from a structured result or the stored column. */
|
||||
export function parseModerationFlags(
|
||||
message: MessageRecord,
|
||||
analysisResult?: AnalysisResult,
|
||||
): string[] {
|
||||
const flags = analysisResult?.flags ?? null;
|
||||
if (flags && flags.length > 0) return flags;
|
||||
const stored = message.ai_moderation_flags;
|
||||
if (!stored) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((f): f is string => typeof f === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ONLY violation is the member's server nickname — the message
|
||||
* content itself is clean. Such messages must NOT be auto-deleted; the
|
||||
* correct enforcement is resetting the nickname to the default username.
|
||||
* Any other flag (sara, harassment, vulgar_language, ...) keeps the normal
|
||||
* delete path.
|
||||
*/
|
||||
export function isNicknameOnlyViolation(
|
||||
message: MessageRecord,
|
||||
analysisResult?: AnalysisResult,
|
||||
): boolean {
|
||||
const flags = parseModerationFlags(message, analysisResult);
|
||||
return flags.length > 0 && flags.every((f) => f === "offensive_username");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a message qualifies for auto-deletion.
|
||||
* Uses the structured `analysisResult` fields when provided, falling back
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { isEligibleForAutoDelete } from "./autoDeleteEligibility.js";
|
||||
import {
|
||||
isEligibleForAutoDelete,
|
||||
isNicknameOnlyViolation,
|
||||
} from "./autoDeleteEligibility.js";
|
||||
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||
|
||||
@@ -15,6 +19,83 @@ export interface AutoDeleteResult {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Cooldown per guild:user — a nick violation fires per message, but the
|
||||
// Discord PATCH is idempotent; hammering it on every message by the same
|
||||
// member is wasteful and risks rate limits.
|
||||
const recentNicknameResets = new LRUCache<string, number>({
|
||||
max: 200,
|
||||
ttl: config.AUTO_NICKNAME_RESET_COOLDOWN_MS ?? 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
export function isNicknameResetInCooldown(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
return recentNicknameResets.has(`${guildId}:${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a member's server nickname to the default (global username) —
|
||||
* Discord's `setNickname(null)` removes the custom nick so the member is
|
||||
* shown under their default username. Non-blocking; failures are logged
|
||||
* but never throw into the moderation pipeline.
|
||||
*/
|
||||
export async function resetOffensiveNickname(
|
||||
client: Client | undefined,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
messageId: string,
|
||||
): Promise<boolean> {
|
||||
const cooldownKey = `${guildId}:${userId}`;
|
||||
try {
|
||||
if (!client?.user?.id) {
|
||||
logger.warn(
|
||||
{ messageId, guildId, userId },
|
||||
"Nick reset skipped: client missing",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (userId === client.user.id) {
|
||||
logger.debug({ userId }, "Nick reset skipped: operator's own account");
|
||||
return false;
|
||||
}
|
||||
if (recentNicknameResets.has(cooldownKey)) {
|
||||
logger.debug({ guildId, userId }, "Nick reset skipped: cooldown active");
|
||||
return false;
|
||||
}
|
||||
if (config.AUTO_NICKNAME_RESET_ENABLED === false) return false;
|
||||
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
logger.warn(
|
||||
{ messageId, guildId },
|
||||
"Nick reset skipped: guild not found",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const member = await guild.members.fetch(userId);
|
||||
// setNickname(null) = remove nickname → Discord shows global username
|
||||
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
||||
recentNicknameResets.set(cooldownKey, Date.now());
|
||||
logger.info(
|
||||
{ messageId, guildId, userId },
|
||||
"Offensive nickname reset to default username",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
messageId,
|
||||
guildId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Nick reset failed",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Error Handling Utilities ────────────────────────────────────────
|
||||
|
||||
function getErrorCode(error: unknown): number | string | undefined {
|
||||
@@ -107,6 +188,57 @@ export async function attemptAutoDeleteFlaggedMessage(
|
||||
return { deleted: false, skipped: true, reason: "disabled" };
|
||||
}
|
||||
|
||||
// ── Nickname-only violation: reset nick, DO NOT delete ─────────────
|
||||
// When the only flag is offensive_username (message content is clean),
|
||||
// the problem is the server nickname, not the message. Enforcement is
|
||||
// removing the nickname back to the default username — the message stays.
|
||||
if (isNicknameOnlyViolation(message)) {
|
||||
if (
|
||||
!config.AUTO_DELETE_FLAGGED_DRY_RUN &&
|
||||
config.AUTO_NICKNAME_RESET_ENABLED !== false
|
||||
) {
|
||||
const inCooldown = isNicknameResetInCooldown(
|
||||
message.guild_id,
|
||||
message.user_id,
|
||||
);
|
||||
if (!inCooldown) {
|
||||
const resetOk = await resetOffensiveNickname(
|
||||
client,
|
||||
message.guild_id,
|
||||
message.user_id,
|
||||
message.id,
|
||||
);
|
||||
try {
|
||||
await messageStore.createModerationAction({
|
||||
message_id: message.id,
|
||||
user_id: message.user_id,
|
||||
guild_id: message.guild_id,
|
||||
action_type: "reset_nickname",
|
||||
reason:
|
||||
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
||||
executed_by: "auto-delete-manager",
|
||||
status: resetOk ? "executed" : "failed",
|
||||
error: resetOk ? null : "nickname_reset_failed",
|
||||
executed_at: resetOk ? Date.now() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
messageId: message.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
"Failed to persist nickname reset action log",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
{ messageId: message.id, userId: message.user_id },
|
||||
"Nickname-only violation: message kept, nickname reset attempted",
|
||||
);
|
||||
return { deleted: false, skipped: true, reason: "nickname_only_violation" };
|
||||
}
|
||||
|
||||
// ── Status gate ──────────────────────────────────────────────────
|
||||
|
||||
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../message-capture/messageMetadata.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||
import { escapeXml, resolveDisplayName } from "./moderationBuilders.js";
|
||||
|
||||
const logger = createChildLogger("conversationContext");
|
||||
|
||||
@@ -13,6 +14,26 @@ export interface ConversationContextInput {
|
||||
contextBefore: MessageRecord[];
|
||||
targets: MessageRecord[];
|
||||
maxTokens: number;
|
||||
/**
|
||||
* Hard age cap for context messages (ms). Messages older than this
|
||||
* relative to the target are stale conversation noise and dropped.
|
||||
*/
|
||||
maxAgeMs?: number;
|
||||
/**
|
||||
* Silence threshold (ms). A gap between consecutive context messages
|
||||
* larger than this means the conversation restarted — older messages
|
||||
* belong to a previous conversation and are dropped.
|
||||
*/
|
||||
gapMs?: number;
|
||||
}
|
||||
|
||||
export interface ConversationContextResult {
|
||||
/** Formatted context lines (oldest → newest, recency-gated). */
|
||||
lines: string[];
|
||||
/** One-line flow descriptor: status, span, dropped counts. */
|
||||
descriptor: string;
|
||||
/** Number of context messages dropped by the recency gates. */
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
let _encoder: ReturnType<typeof encodingForModel> | null = null;
|
||||
@@ -103,26 +124,143 @@ export function formatMessageForPrompt(
|
||||
msg: MessageRecord,
|
||||
label: "context" | "target",
|
||||
): string {
|
||||
const content = sanitizeDiscordTokens(
|
||||
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
||||
const content = truncateContextLine(
|
||||
sanitizeDiscordTokens(
|
||||
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
||||
),
|
||||
);
|
||||
const timestamp = formatTimestamp(msg.created_at);
|
||||
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
||||
const refInfo = formatReferenceInfo(msg);
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}${refInfo}`;
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
|
||||
}
|
||||
|
||||
/** Max content chars per context line — a single huge paste (log dump,
|
||||
* copypasta) must not eat the whole conversation budget. */
|
||||
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
|
||||
|
||||
/** Marker appended when a context line's content was cut. Distinct from the
|
||||
* target-content marker so the model knows which side was truncated. */
|
||||
export const CONTEXT_TRUNC_MARKER = "…[konteks dipotong: terlalu panjang]";
|
||||
|
||||
/** Cap one context message's content to CONTEXT_LINE_CONTENT_MAX_CHARS. */
|
||||
export function truncateContextLine(content: string): string {
|
||||
if (content.length <= CONTEXT_LINE_CONTENT_MAX_CHARS) return content;
|
||||
return `${content.slice(0, CONTEXT_LINE_CONTENT_MAX_CHARS).trimEnd()}${CONTEXT_TRUNC_MARKER}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a structured `<location_context .../>` element for the batch —
|
||||
* channel/thread name and age-restriction flags from captured message
|
||||
* metadata. The LLM uses it to judge messages in the right channel context
|
||||
* (e.g. a thread about a specific topic, or an age-restricted channel).
|
||||
* Returns "" when no channel metadata was captured.
|
||||
*/
|
||||
export function buildLocationContext(targets: MessageRecord[]): string {
|
||||
const target = targets[0];
|
||||
if (!target?.metadata) return "";
|
||||
try {
|
||||
const meta = JSON.parse(target.metadata) as {
|
||||
channel?: {
|
||||
channelName?: string | null;
|
||||
threadName?: string | null;
|
||||
topic?: string | null;
|
||||
nsfw?: boolean;
|
||||
ageRestricted?: boolean;
|
||||
nsfwLevel?: string | null;
|
||||
} | null;
|
||||
};
|
||||
const ch = meta?.channel;
|
||||
if (!ch) return "";
|
||||
const attrs: string[] = [`channel_id="${escapeXml(target.channel_id)}"`];
|
||||
if (ch.channelName)
|
||||
attrs.push(`channel_name="${escapeXml(ch.channelName)}"`);
|
||||
if (target.thread_id || ch.threadName) {
|
||||
if (target.thread_id)
|
||||
attrs.push(`thread_id="${escapeXml(target.thread_id)}"`);
|
||||
if (ch.threadName)
|
||||
attrs.push(`thread_name="${escapeXml(ch.threadName)}"`);
|
||||
}
|
||||
if (typeof ch.topic === "string" && ch.topic.trim().length > 0) {
|
||||
const topic =
|
||||
ch.topic.length > 200
|
||||
? `${ch.topic.slice(0, 200).trimEnd()}…`
|
||||
: ch.topic;
|
||||
attrs.push(`topic="${escapeXml(topic)}"`);
|
||||
}
|
||||
if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`);
|
||||
if (typeof ch.ageRestricted === "boolean") {
|
||||
attrs.push(`age_restricted="${ch.ageRestricted}"`);
|
||||
}
|
||||
return `<location_context ${attrs.join(" ")}/>`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds conversation historical context without including targets.
|
||||
* Calculates how much token budget targets use, and fills the rest with context.
|
||||
*
|
||||
* Two recency gates decide whether a conversation is STILL the same one
|
||||
* ("obrolan berlanjut") or already restarted:
|
||||
* - `gapMs`: a silence longer than this between two context messages cuts
|
||||
* the block there — earlier messages belong to a previous conversation.
|
||||
* - `maxAgeMs`: anything older than this relative to the target is noise.
|
||||
*
|
||||
* On a cold start (no recent context), the nearest messages are kept as a
|
||||
* sparse anchor and the descriptor says `cold_start` instead of `ongoing`,
|
||||
* so the LLM does not mistake scattered old messages for an active chat.
|
||||
*/
|
||||
export function buildConversationContext(
|
||||
input: ConversationContextInput,
|
||||
): string[] {
|
||||
): ConversationContextResult {
|
||||
const { contextBefore, targets, maxTokens } = input;
|
||||
const maxAgeMs = input.maxAgeMs ?? 45 * 60 * 1000;
|
||||
const gapMs = input.gapMs ?? 12 * 60 * 1000;
|
||||
|
||||
// Calculate tokens used by targets (parallel)
|
||||
const targetTime = targets.reduce(
|
||||
(min, t) => Math.min(min, t.created_at),
|
||||
targets[0]?.created_at ?? Date.now(),
|
||||
);
|
||||
|
||||
// ── Recency gating (walk newest → oldest) ───────────────────────────────
|
||||
const gated: MessageRecord[] = [];
|
||||
let latestSelected: MessageRecord | null = null;
|
||||
let gapBeforeMs: number | null = null;
|
||||
let dropped = 0;
|
||||
|
||||
for (let i = contextBefore.length - 1; i >= 0; i--) {
|
||||
const msg = contextBefore[i];
|
||||
// Age gate
|
||||
if (targetTime - msg.created_at > maxAgeMs) {
|
||||
dropped += i + 1; // everything older also exceeds the age cap
|
||||
break;
|
||||
}
|
||||
// Gap gate — silence between this message and the newer one already selected
|
||||
if (latestSelected && latestSelected.created_at - msg.created_at > gapMs) {
|
||||
gapBeforeMs = latestSelected.created_at - msg.created_at;
|
||||
dropped += i + 1;
|
||||
break;
|
||||
}
|
||||
gated.push(msg);
|
||||
latestSelected = msg;
|
||||
}
|
||||
|
||||
const gatedNewestFirst = gated.reverse();
|
||||
let status: "ongoing" | "cold_start" | "sparse";
|
||||
if (gatedNewestFirst.length === 0) {
|
||||
// Cold start — keep a small anchor of the nearest messages so the LLM
|
||||
// still senses the channel, but mark it clearly.
|
||||
status = "cold_start";
|
||||
gatedNewestFirst.push(...contextBefore.slice(-2)); // ± 2 nearest to target
|
||||
} else if (gapBeforeMs === null) {
|
||||
status = "ongoing";
|
||||
} else {
|
||||
status = "sparse";
|
||||
}
|
||||
|
||||
// ── Format + token budget (most recent first, like before) ─────────────
|
||||
const targetLines = targets.map((msg) =>
|
||||
formatMessageForPrompt(msg, "target"),
|
||||
);
|
||||
@@ -131,7 +269,7 @@ export function buildConversationContext(
|
||||
0,
|
||||
);
|
||||
|
||||
const contextLines = contextBefore.map((msg) =>
|
||||
const contextLines = gatedNewestFirst.map((msg) =>
|
||||
formatMessageForPrompt(msg, "context"),
|
||||
);
|
||||
const selectedContextLines: string[] = [];
|
||||
@@ -148,14 +286,26 @@ export function buildConversationContext(
|
||||
}
|
||||
}
|
||||
|
||||
const descriptorParts = [
|
||||
`[conversation_flow] status=${status}`,
|
||||
`context_msgs=${selectedContextLines.length}`,
|
||||
`dropped=${dropped}`,
|
||||
];
|
||||
if (gapBeforeMs !== null) {
|
||||
descriptorParts.push(`gap_before_min=${Math.round(gapBeforeMs / 60000)}`);
|
||||
}
|
||||
const descriptor = descriptorParts.join(" ");
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
targetCount: targets.length,
|
||||
contextCount: selectedContextLines.length,
|
||||
status,
|
||||
dropped,
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
},
|
||||
"Conversation context built",
|
||||
);
|
||||
return selectedContextLines;
|
||||
return { lines: selectedContextLines, descriptor, dropped };
|
||||
}
|
||||
|
||||
@@ -56,7 +56,16 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
||||
*/
|
||||
type LLMResponseChunk = {
|
||||
choices?: Array<{
|
||||
delta?: { content?: string | null };
|
||||
delta?: {
|
||||
content?: string | null;
|
||||
reasoning_content?: string | null;
|
||||
reasoning?: string | null;
|
||||
reasoning_details?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
index?: number;
|
||||
}> | null;
|
||||
};
|
||||
message?: { content?: string | null };
|
||||
finish_reason?: string | null;
|
||||
text?: string;
|
||||
@@ -67,6 +76,39 @@ type LLMResponseChunk = {
|
||||
finish_reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the textual payload from a single streaming chunk. Prefers
|
||||
* `delta.content`; falls back to reasoning fields so reasoning-only models
|
||||
* still produce usable aggregated text. Providers differ in the field name:
|
||||
* - DeepSeek-style / Cloudflare gemma → `delta.reasoning_content`
|
||||
* - mimo (via 9router) streams reasoning in `delta.reasoning` +
|
||||
* `delta.reasoning_details[].text` (content:"") — without these fallbacks
|
||||
* vision aggregation came back empty ("Vision API null response").
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function extractChunkText(
|
||||
chunk: LLMResponseChunk | null | undefined,
|
||||
): string {
|
||||
if (!chunk) return "";
|
||||
const choice = chunk.choices?.[0];
|
||||
const reasoningDetails = choice?.delta?.reasoning_details
|
||||
?.map((d) => d.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
return (
|
||||
choice?.delta?.content ||
|
||||
choice?.delta?.reasoning_content ||
|
||||
choice?.delta?.reasoning ||
|
||||
reasoningDetails ||
|
||||
choice?.message?.content ||
|
||||
choice?.text ||
|
||||
chunk?.message?.content ||
|
||||
chunk?.response ||
|
||||
chunk?.content ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy singleton — created on first use so that config is always resolved.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,6 +147,11 @@ export interface LlmCallOpts {
|
||||
top_p?: number;
|
||||
/** Force JSON output via response_format: { type: "json_object" }. */
|
||||
jsonResponse?: { type: "json_object" };
|
||||
/**
|
||||
* Disable LLM chain-of-thought (reasoning/thinking) for faster analysis.
|
||||
* Defaults to config.AI_LLM_DISABLE_THINKING when omitted.
|
||||
*/
|
||||
disableThinking?: boolean;
|
||||
/** Extra retries beyond DEFAULT_RETRIES (default 2). */
|
||||
retries?: number;
|
||||
/** Whether to use streaming (if true, will consume stream and return aggregated result) */
|
||||
@@ -113,6 +160,61 @@ export interface LlmCallOpts {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request params for an LLM chat completion. Pulled out of `llmChat`
|
||||
* so the thinking-disable injection can be unit-tested without network access.
|
||||
*
|
||||
* Optional params (temperature/top_p/max_tokens) are only attached when
|
||||
* explicitly provided, to maximise compatibility with various providers/local
|
||||
* APIs. When `disableThinking` is set, we inject the common provider params
|
||||
* used to switch OFF chain-of-thought reasoning. OpenAI-compatible routers
|
||||
* ignore the variants their backend does not understand, so sending the
|
||||
* OpenAI (`reasoning_effort`), OpenRouter (`reasoning.enabled`) and
|
||||
* vLLM/Qwen/litellm (`chat_template_kwargs.enable_thinking`) forms together
|
||||
* covers the popular reasoning backends behind a proxy.
|
||||
*/
|
||||
export function buildLlmParams(
|
||||
opts: LlmCallOpts,
|
||||
disableThinking: boolean,
|
||||
): OpenAI.Chat.Completions.ChatCompletionCreateParams {
|
||||
const {
|
||||
messages,
|
||||
model = config.AI_LLM_MODEL,
|
||||
max_tokens,
|
||||
temperature,
|
||||
top_p,
|
||||
jsonResponse,
|
||||
stream,
|
||||
} = opts;
|
||||
|
||||
const params = {
|
||||
model,
|
||||
messages,
|
||||
...(stream !== undefined ? { stream } : {}),
|
||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParams;
|
||||
|
||||
if (temperature !== undefined) params.temperature = temperature;
|
||||
if (top_p !== undefined) params.top_p = top_p;
|
||||
if (max_tokens !== undefined) params.max_tokens = max_tokens;
|
||||
if (jsonResponse) params.response_format = jsonResponse;
|
||||
|
||||
if (disableThinking) {
|
||||
Object.assign(params, {
|
||||
// OpenAI o-series
|
||||
reasoning_effort: "none",
|
||||
// OpenRouter
|
||||
reasoning: { enabled: false },
|
||||
// vLLM / Qwen / litellm
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
// Anthropic / Claude-format (9router exposes thinkingFormat
|
||||
// "claude-adaptive" / "claude-budget" on its reasoning models)
|
||||
thinking: { type: "disabled" },
|
||||
} as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the LLM with sensible defaults: concurrency cap, retry, model, tokens.
|
||||
*
|
||||
@@ -125,33 +227,12 @@ export async function llmChat(
|
||||
const client = getClient();
|
||||
if (!client) return null;
|
||||
|
||||
const {
|
||||
messages,
|
||||
model = config.AI_LLM_MODEL,
|
||||
max_tokens,
|
||||
temperature,
|
||||
top_p,
|
||||
jsonResponse,
|
||||
retries = DEFAULT_RETRIES,
|
||||
stream,
|
||||
signal,
|
||||
} = opts;
|
||||
const { retries = DEFAULT_RETRIES, signal } = opts;
|
||||
const disableThinking =
|
||||
opts.disableThinking ?? config.AI_LLM_DISABLE_THINKING;
|
||||
|
||||
const params = {
|
||||
model,
|
||||
messages,
|
||||
...(stream !== undefined ? { stream } : {}),
|
||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParams;
|
||||
|
||||
// Attach optional parameters only if explicitly provided to maintain
|
||||
// maximum compatibility with various LLM providers and local APIs.
|
||||
if (temperature !== undefined) params.temperature = temperature;
|
||||
if (top_p !== undefined) params.top_p = top_p;
|
||||
if (max_tokens !== undefined) params.max_tokens = max_tokens;
|
||||
|
||||
if (jsonResponse) {
|
||||
params.response_format = jsonResponse;
|
||||
}
|
||||
const params = buildLlmParams(opts, disableThinking);
|
||||
const model = params.model;
|
||||
|
||||
return retryWithBackoff(
|
||||
async () => {
|
||||
@@ -167,15 +248,7 @@ export async function llmChat(
|
||||
let finishReason = "stop";
|
||||
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const textChunk =
|
||||
choice?.delta?.content ||
|
||||
choice?.message?.content ||
|
||||
choice?.text ||
|
||||
chunk?.message?.content ||
|
||||
chunk?.response ||
|
||||
chunk?.content ||
|
||||
"";
|
||||
content += textChunk;
|
||||
content += extractChunkText(chunk);
|
||||
const fr = choice?.finish_reason || chunk?.finish_reason;
|
||||
if (fr) finishReason = fr;
|
||||
}
|
||||
@@ -249,6 +322,12 @@ export async function llmChat(
|
||||
* Convenience for vision (image/sticker/emoji) analysis.
|
||||
* Returns the raw completion content (trimmed) or null.
|
||||
*
|
||||
* Vision routes through the SAME router/base URL as text moderation
|
||||
* (AI_LLM_BASE_URL) — the dedicated NVIDIA multimodal endpoint was removed.
|
||||
* It uses AI_LLM_VISION_MODEL (a different model alias from the text combo)
|
||||
* so image analysis stays on a vision-capable model. Thinking-disable from
|
||||
* config.AI_LLM_DISABLE_THINKING applies automatically via buildLlmParams.
|
||||
*
|
||||
* NOTE: retries are disabled here on purpose — visionAnalyzer.ts already
|
||||
* wraps this call in its own 3-attempt loop with exponential backoff.
|
||||
* A second retry layer would multiply worst-case API calls (3×3=9/image).
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
export {
|
||||
acquireMediaAnalysisLock,
|
||||
computeImagePhash,
|
||||
deleteCachedMediaAnalysis,
|
||||
getCachedMediaAnalysis,
|
||||
setCachedMediaAnalysis,
|
||||
|
||||
@@ -16,8 +16,10 @@ import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import type { RetryState } from "./llmCaller.js";
|
||||
import { callModerationLLM } from "./llmCaller.js";
|
||||
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
||||
import { buildUserProfilesBlock } from "./moderationBuilders.js";
|
||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||
import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js";
|
||||
import { getUserProfile } from "./userProfileStore.js";
|
||||
|
||||
const log = createChildLogger("mediaBatchProcessor");
|
||||
|
||||
@@ -26,7 +28,7 @@ const log = createChildLogger("mediaBatchProcessor");
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function runMediaBatch(
|
||||
targets: MessageRecord[],
|
||||
contextText: string,
|
||||
contextBlock: string,
|
||||
attachments: AttachmentRecord[] | undefined,
|
||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||
if (!targets.length) return { results: [], raw: null };
|
||||
@@ -58,14 +60,41 @@ export async function runMediaBatch(
|
||||
const channelCulture = channelCultureObj?.culture_summary;
|
||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||
const systemText = buildSystemPromptModular({
|
||||
contextText,
|
||||
mode: "mixed",
|
||||
correctedExamples,
|
||||
channelCulture,
|
||||
});
|
||||
|
||||
// Gather user profiles ONCE for the whole batch and emit a deduplicated
|
||||
// <user_profiles> map (with last-generated timestamp); per-message blocks
|
||||
// (from prepareMediaMessage) reference it via <user_profile_ref>.
|
||||
const profileByUser = new Map<
|
||||
string,
|
||||
{
|
||||
text: string;
|
||||
asOf?: number | null;
|
||||
}
|
||||
>();
|
||||
for (const t of targets) {
|
||||
if (profileByUser.has(t.user_id)) continue;
|
||||
const profile = await getUserProfile(t.user_id);
|
||||
profileByUser.set(t.user_id, {
|
||||
text: profile?.profile_summary ?? "",
|
||||
asOf: profile?.last_analyzed_at ?? null,
|
||||
});
|
||||
}
|
||||
const userProfilesBlock = buildUserProfilesBlock(profileByUser);
|
||||
|
||||
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
||||
const userContent = `<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
|
||||
// Data/instruction separation: the system prompt is stable per mode — all
|
||||
// per-batch context (profiles, conversation) lives in the USER payload,
|
||||
// ordered oldest-first so targets come last.
|
||||
const userBlocks = [
|
||||
userProfilesBlock?.trimEnd() ?? "",
|
||||
contextBlock?.trimEnd() ?? "",
|
||||
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||
].filter((b) => b.trim().length > 0);
|
||||
const userContent = userBlocks.join("\n\n");
|
||||
|
||||
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||
const batchTimeout = Math.min(
|
||||
|
||||
@@ -7,28 +7,22 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import {
|
||||
acquireMediaAnalysisLock,
|
||||
computeImagePhash,
|
||||
deleteCachedMediaAnalysis,
|
||||
getCachedMediaAnalysis,
|
||||
getCachedMediaByPhash,
|
||||
makeCustomEmojiCacheKey,
|
||||
makeImageCacheKey,
|
||||
makeStickerCacheKey,
|
||||
upsertCachedMediaAnalysis,
|
||||
upsertCachedMediaByPhash,
|
||||
} from "./textCacheStore.js";
|
||||
|
||||
export {
|
||||
acquireMediaAnalysisLock,
|
||||
computeImagePhash,
|
||||
deleteCachedMediaAnalysis,
|
||||
getCachedMediaAnalysis,
|
||||
getCachedMediaByPhash,
|
||||
makeCustomEmojiCacheKey,
|
||||
makeImageCacheKey,
|
||||
makeStickerCacheKey,
|
||||
upsertCachedMediaAnalysis,
|
||||
upsertCachedMediaByPhash,
|
||||
};
|
||||
|
||||
/** Convenience alias for upsertCachedMediaAnalysis. */
|
||||
|
||||
@@ -296,101 +296,137 @@ export async function downloadAndExtractFrame(
|
||||
imageMap: Map<string, MessageImagePart[]>,
|
||||
): Promise<void> {
|
||||
const log = createChildLogger("mediaAnalysis");
|
||||
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
|
||||
if (!urlToUse) return;
|
||||
// Prefer the upload proxy (uploaded_url); the Discord CDN link can expire
|
||||
// or be purged (404), and a non-OK response used to silently drop the image
|
||||
// from vision analysis (no log, empty image map → text-only verdict). Try
|
||||
// each candidate URL in order and surface failures.
|
||||
const urlCandidates = [
|
||||
att.uploaded_url,
|
||||
att.discord_url && att.discord_url !== att.uploaded_url
|
||||
? att.discord_url
|
||||
: null,
|
||||
].filter((u): u is string => Boolean(u));
|
||||
if (urlCandidates.length === 0) return;
|
||||
|
||||
const { controller, clear } = createAbortControllerWithTimeout(15000);
|
||||
try {
|
||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
||||
if (!res.ok || !res.body) return;
|
||||
|
||||
let totalBytes = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = res.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
totalBytes += value.length;
|
||||
if (totalBytes > 10 * 1024 * 1024) {
|
||||
reader.cancel();
|
||||
return;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
const imageBytes = Buffer.concat(chunks);
|
||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||
|
||||
if (!sniffedMime && att.type.startsWith("video/")) {
|
||||
await extractVideoFrames(
|
||||
att,
|
||||
imageBytes,
|
||||
targetId,
|
||||
maxDimension,
|
||||
imageMap,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: try attachment type metadata, then filename extension
|
||||
let resolvedMime = sniffedMime;
|
||||
if (!resolvedMime) {
|
||||
if (att.type.startsWith("image/")) {
|
||||
resolvedMime = att.type;
|
||||
let imageBytes: Buffer | null = null;
|
||||
let lastStatus = 0;
|
||||
let lastError: string | null = null;
|
||||
for (const urlToUse of urlCandidates) {
|
||||
const { controller, clear } = createAbortControllerWithTimeout(15000);
|
||||
try {
|
||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
||||
if (!res.ok || !res.body) {
|
||||
lastStatus = res.status;
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename, type: att.type },
|
||||
"Image MIME sniff failed — using attachment metadata type as fallback",
|
||||
{
|
||||
attachmentId: att.id,
|
||||
urlHost: new URL(urlToUse).host,
|
||||
status: res.status,
|
||||
},
|
||||
"Attachment fetch non-OK — trying next URL",
|
||||
);
|
||||
} else {
|
||||
// Last resort: check file extension
|
||||
const ext = att.filename?.toLowerCase().split(".").pop();
|
||||
if (ext && ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
};
|
||||
resolvedMime = mimeMap[ext];
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename, ext },
|
||||
"Image MIME sniff failed — using file extension fallback",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = res.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
totalBytes += value.length;
|
||||
if (totalBytes > 10 * 1024 * 1024) {
|
||||
reader.cancel();
|
||||
return;
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all fallbacks fail, still try with generic image/jpeg
|
||||
if (!resolvedMime) {
|
||||
resolvedMime = "image/jpeg";
|
||||
imageBytes = Buffer.concat(chunks);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename },
|
||||
"All MIME detection failed — forcing image/jpeg as last resort",
|
||||
{
|
||||
attachmentId: att.id,
|
||||
urlHost: new URL(urlToUse).host,
|
||||
error: lastError,
|
||||
},
|
||||
"Attachment download failed — trying next URL",
|
||||
);
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(imageBytes, maxDimension);
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
addImageToMap(imageMap, targetId, {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!imageBytes) {
|
||||
log.warn(
|
||||
{
|
||||
attachmentId: att.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
filename: att.filename,
|
||||
lastStatus,
|
||||
lastError,
|
||||
},
|
||||
"Download failed",
|
||||
"All attachment URLs failed — skipping media analysis",
|
||||
);
|
||||
} finally {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const sniffedMime = sniffImageMimeType(imageBytes);
|
||||
|
||||
if (!sniffedMime && att.type.startsWith("video/")) {
|
||||
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: try attachment type metadata, then filename extension
|
||||
let resolvedMime = sniffedMime;
|
||||
if (!resolvedMime) {
|
||||
if (att.type.startsWith("image/")) {
|
||||
resolvedMime = att.type;
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename, type: att.type },
|
||||
"Image MIME sniff failed — using attachment metadata type as fallback",
|
||||
);
|
||||
} else {
|
||||
// Last resort: check file extension
|
||||
const ext = att.filename?.toLowerCase().split(".").pop();
|
||||
if (ext && ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
};
|
||||
resolvedMime = mimeMap[ext];
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename, ext },
|
||||
"Image MIME sniff failed — using file extension fallback",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all fallbacks fail, still try with generic image/jpeg
|
||||
if (!resolvedMime) {
|
||||
resolvedMime = "image/jpeg";
|
||||
log.warn(
|
||||
{ attachmentId: att.id, filename: att.filename },
|
||||
"All MIME detection failed — forcing image/jpeg as last resort",
|
||||
);
|
||||
}
|
||||
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(imageBytes, maxDimension);
|
||||
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
|
||||
addImageToMap(imageMap, targetId, {
|
||||
type: "image_url",
|
||||
image_url: { url: dataUrl },
|
||||
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -414,7 +450,7 @@ export async function downloadMediaCandidate(
|
||||
if (candidate.customEmojiId || candidate.stickerName) {
|
||||
const vck = candidate.customEmojiId
|
||||
? makeCustomEmojiCacheKey(candidate.customEmojiId)
|
||||
: makeStickerCacheKey(candidate.stickerName!);
|
||||
: makeStickerCacheKey(candidate.stickerName ?? "");
|
||||
const cached = await getCachedMediaAnalysis(vck);
|
||||
if (cached) {
|
||||
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||
@@ -494,8 +530,9 @@ export async function fetchUrlInline(
|
||||
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${targetId}]`,
|
||||
});
|
||||
} else if (result.type === "text" && result.textContent) {
|
||||
const titleAttr = result.title ? ` title="${escapeXml(result.title)}"` : "";
|
||||
webTexts.push(
|
||||
`<web_content url="${escapeXml(url)}">${escapeXml(result.textContent.slice(0, 2000))}</web_content>`,
|
||||
`<web_content url="${escapeXml(url)}"${titleAttr}>${escapeXml(result.textContent.slice(0, 2000))}</web_content>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { renderDiscordMentions } from "../message-capture/messageMetadata.js";
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
import type { MessageRecord } from "../message-capture/types.js";
|
||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||
import { sanitizeAiContent } from "./prompts/output.js";
|
||||
|
||||
/** Simple XML-escaping for content text. */
|
||||
export function escapeXml(s: string): string {
|
||||
@@ -19,6 +20,216 @@ export function escapeXml(s: string): string {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation context block — structured data for the USER message.
|
||||
//
|
||||
// All per-batch context lives in the USER message (not the SYSTEM prompt) so
|
||||
// the system prompt is stable per mode (cacheable on routers/providers) and
|
||||
// the role boundary is clean: instructions in SYSTEM, data in USER.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Outer char cap for the assembled `<conversation_context>` inner text. */
|
||||
export const CONVERSATION_CONTEXT_MAX_CHARS = 40_000;
|
||||
|
||||
/**
|
||||
* Wraps per-batch context data into structured XML blocks for the USER
|
||||
* message:
|
||||
*
|
||||
* <location_context channel_id="..." channel_name="..." nsfw="..."/>
|
||||
* <conversation_context>
|
||||
* [conversation_flow] status=ongoing context_msgs=12 dropped=0
|
||||
* [context] id=... time=... user=...: isi pesan
|
||||
* ...
|
||||
* </conversation_context>
|
||||
*
|
||||
* Empty blocks are omitted entirely (never emit a hollow `<conversation_context>`
|
||||
* with no content). The inner text is AI/user-derived and passed through
|
||||
* `sanitizeAiContent` (CDATA + XML-escape) to block prompt injection.
|
||||
*/
|
||||
export function buildConversationContextBlock(input: {
|
||||
/** Pre-built `<location_context .../>` string (or ""). */
|
||||
location?: string;
|
||||
/** `[conversation_flow]` descriptor line from buildConversationContext. */
|
||||
descriptor?: string;
|
||||
/** `[context]` lines, oldest → newest. */
|
||||
lines: string[];
|
||||
}): string {
|
||||
const blocks: string[] = [];
|
||||
const location = input.location?.trim();
|
||||
if (location) blocks.push(location);
|
||||
|
||||
const inner = [input.descriptor ?? "", ...input.lines]
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n");
|
||||
if (inner) {
|
||||
blocks.push(
|
||||
`<conversation_context>\n${sanitizeAiContent(inner, CONVERSATION_CONTEXT_MAX_CHARS)}\n</conversation_context>`,
|
||||
);
|
||||
}
|
||||
return blocks.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-message content bounds — protects the LLM token budget from a single
|
||||
// huge paste (stack traces, log dumps, copypasta). Truncation is explicit so
|
||||
// the model never mistakes the cut for a real message boundary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Max characters of a message's content sent to the LLM `<content>` payload. */
|
||||
export const AI_CONTENT_MAX_CHARS = 4000;
|
||||
|
||||
/** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */
|
||||
export const AI_CONTENT_TRUNC_MARKER = "\n…[pesan dipotong: terlalu panjang]";
|
||||
|
||||
/** Truncate a message's content for the LLM `<content>` payload. */
|
||||
export function truncateForAi(content: string): string {
|
||||
if (content.length <= AI_CONTENT_MAX_CHARS) return content;
|
||||
return `${content.slice(0, AI_CONTENT_MAX_CHARS)}${AI_CONTENT_TRUNC_MARKER}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User profile deduplication — a batch can contain many messages from the
|
||||
// same user. Instead of repeating the (up to 3000-char) profile summary on
|
||||
// every message, emit a single <user_profiles> map per batch and reference
|
||||
// entries per message with <user_profile_ref user_id="..."/>.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UserProfileEntry {
|
||||
/** Profile summary text (from user_profiles.profile_summary). */
|
||||
text: string;
|
||||
/** Epoch ms when the profile was last generated — staleness signal for
|
||||
* the LLM (a profile from months ago may not reflect current behavior). */
|
||||
asOf?: number | null;
|
||||
}
|
||||
|
||||
/** Build a deduplicated `<user_profiles>` map block, keyed by Discord user id. */
|
||||
export function buildUserProfilesBlock(
|
||||
profiles: ReadonlyMap<string, UserProfileEntry>,
|
||||
): string {
|
||||
const entries = Array.from(profiles.entries()).filter(
|
||||
([, entry]) => entry.text.trim().length > 0,
|
||||
);
|
||||
if (entries.length === 0) return "";
|
||||
const lines = entries.map(([userId, entry]) => {
|
||||
const asOfAttr =
|
||||
typeof entry.asOf === "number" && entry.asOf > 0
|
||||
? ` as_of="${new Date(entry.asOf).toISOString()}"`
|
||||
: "";
|
||||
return ` <user_profile user_id="${escapeXml(userId)}"${asOfAttr}>${sanitizeAiContent(entry.text)}</user_profile>`;
|
||||
});
|
||||
return `<user_profiles>\n${lines.join("\n")}\n</user_profiles>`;
|
||||
}
|
||||
|
||||
/** Per-message reference tag pointing at an entry in the `<user_profiles>` map. */
|
||||
export function buildUserProfileRef(userId: string): string {
|
||||
return `<user_profile_ref user_id="${escapeXml(userId)}"/>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User reputation — richer than a bare trust score.
|
||||
//
|
||||
// The trust model tracks total_infractions, a clean-message streak and the
|
||||
// last infraction timestamp. Feeding all of it to the LLM lets it tell a
|
||||
// first-timer (same score, 1 infraction) from a repeat offender (score 50,
|
||||
// 3 infractions, last one yesterday) — the same score means very different
|
||||
// things in those two contexts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReputationAttrsSource {
|
||||
trust_score: number;
|
||||
total_infractions: number;
|
||||
clean_message_streak: number;
|
||||
last_infraction_at: number | null;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const REPEAT_OFFENSE_WINDOW_MS = 7 * DAY_MS;
|
||||
|
||||
/**
|
||||
* Formats reputation fields into XML attributes for `<user_reputation .../>`.
|
||||
* Derived signals: last_offense_days_ago (0 = today) and repeat_offender
|
||||
* (infraction within the last 7 days) are computed here so both the text and
|
||||
* media paths emit the exact same shape.
|
||||
*/
|
||||
export function formatReputationAttrs(
|
||||
rep: ReputationAttrsSource,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const attrs = [
|
||||
`trust_score="${rep.trust_score}"`,
|
||||
`total_infractions="${rep.total_infractions}"`,
|
||||
`clean_streak="${rep.clean_message_streak}"`,
|
||||
];
|
||||
if (
|
||||
typeof rep.last_infraction_at === "number" &&
|
||||
rep.last_infraction_at > 0
|
||||
) {
|
||||
const daysAgo = Math.max(
|
||||
0,
|
||||
Math.floor((now - rep.last_infraction_at) / DAY_MS),
|
||||
);
|
||||
attrs.push(`last_offense_days_ago="${daysAgo}"`);
|
||||
const isRepeat =
|
||||
rep.total_infractions > 0 &&
|
||||
now - rep.last_infraction_at <= REPEAT_OFFENSE_WINDOW_MS;
|
||||
if (isRepeat) attrs.push(`repeat_offender="true"`);
|
||||
}
|
||||
return attrs.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an optional `<user_history>` block (last flagged messages) from
|
||||
* getUserRecentInfractions rows. Only emitted when there is real history —
|
||||
* lets the LLM see the PATTERN (e.g. the same scam link posted repeatedly)
|
||||
* without treating old flags as proof for the current message.
|
||||
*/
|
||||
export function buildUserHistoryXml(
|
||||
history: Array<{
|
||||
content: string;
|
||||
severity: string | null;
|
||||
created_at: number;
|
||||
}>,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const filtered = history.filter((h) => h.content?.trim());
|
||||
if (filtered.length === 0) return "";
|
||||
const lines = filtered.map((h) => {
|
||||
const daysAgo = Math.max(0, Math.floor((now - h.created_at) / DAY_MS));
|
||||
const severityAttr = h.severity
|
||||
? ` severity="${escapeXml(h.severity)}"`
|
||||
: "";
|
||||
const snippet =
|
||||
h.content.length > 100
|
||||
? `${h.content.slice(0, 100).trimEnd()}…`
|
||||
: h.content;
|
||||
return ` <infraction${severityAttr} time_ago_days="${daysAgo}">${escapeXml(snippet)}</infraction>`;
|
||||
});
|
||||
return `<user_history>\n${lines.join("\n")}\n</user_history>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the message author was a bot (captured in metadata.author.bot).
|
||||
* Bot posts (logging bots, webhook-style automation) deserve different
|
||||
* scrutiny than user posts — expose the flag instead of hiding it.
|
||||
*/
|
||||
export function resolveIsBot(msg: MessageRecord): boolean {
|
||||
if (!msg.metadata) return false;
|
||||
try {
|
||||
const meta = JSON.parse(msg.metadata) as {
|
||||
author?: { bot?: boolean } | null;
|
||||
};
|
||||
return Boolean(meta?.author?.bot);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the shown content is an EDIT of the original post (evasion signal). */
|
||||
export function resolveIsEdited(msg: MessageRecord): boolean {
|
||||
return Boolean(msg.edited_content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real text content for AI analysis, stripping fallback text
|
||||
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
||||
@@ -36,6 +247,27 @@ export function getAnalysisContent(message: MessageRecord): string {
|
||||
).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server nickname (member.displayName) when captured, else the author
|
||||
* username. Discord shows the server nickname to other members, so the LLM
|
||||
* should see the same name the channel sees — and a nickname can carry
|
||||
* moderation signal itself (offensive nick + clean message → low warn).
|
||||
*/
|
||||
export function resolveDisplayName(msg: MessageRecord): string {
|
||||
if (msg.metadata) {
|
||||
try {
|
||||
const meta = JSON.parse(msg.metadata) as {
|
||||
member?: { displayName?: string | null } | null;
|
||||
};
|
||||
const dn = meta?.member?.displayName;
|
||||
if (dn && dn.trim().length > 0) return dn;
|
||||
} catch {
|
||||
// malformed metadata — fall back to username
|
||||
}
|
||||
}
|
||||
return msg.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a <reference> XML element for reply/forward/crosspost context.
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,13 @@ const log = createChildLogger("moderationOrchestrator");
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface ModerationInput {
|
||||
targets: MessageRecord[];
|
||||
contextText: string;
|
||||
/**
|
||||
* Pre-built XML context block for the USER message (from
|
||||
* `buildConversationContextBlock`): `<location_context .../>` +
|
||||
* `<conversation_context>...</conversation_context>`. Kept out of the
|
||||
* system prompt so it stays stable/cacheable per mode.
|
||||
*/
|
||||
contextBlock: string;
|
||||
attachments?: AttachmentRecord[];
|
||||
}
|
||||
|
||||
@@ -62,7 +68,7 @@ export interface ModerationOutput {
|
||||
export async function runModerationAnalysis(
|
||||
input: ModerationInput,
|
||||
): Promise<ModerationOutput> {
|
||||
const { targets, contextText, attachments } = input;
|
||||
const { targets, contextBlock, attachments } = input;
|
||||
|
||||
initSearxngCache(config.REDIS_URL);
|
||||
if (!targets.length) throw new Error("No targets provided for analysis");
|
||||
@@ -188,7 +194,7 @@ export async function runModerationAnalysis(
|
||||
if (embeddings && embeddings.length === texts.length) {
|
||||
// index-aligned with semanticCandidates
|
||||
for (let i = 0; i < semanticCandidates.length; i++) {
|
||||
const { target, cacheKey } = semanticCandidates[i];
|
||||
const { cacheKey } = semanticCandidates[i];
|
||||
embeddingsByKey.set(cacheKey, embeddings[i]);
|
||||
}
|
||||
|
||||
@@ -320,10 +326,10 @@ export async function runModerationAnalysis(
|
||||
// Run both paths in parallel
|
||||
const [textBatchResult, mediaBatchResult] = await Promise.all([
|
||||
textOnlyTargets.length > 0
|
||||
? runTextOnlyBatch(textOnlyTargets, contextText)
|
||||
? runTextOnlyBatch(textOnlyTargets, contextBlock)
|
||||
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
||||
mediaTargets.length > 0
|
||||
? runMediaBatch(mediaTargets, contextText, attachments)
|
||||
? runMediaBatch(mediaTargets, contextBlock, attachments)
|
||||
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -335,6 +335,26 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
||||
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["gw sih dukung palestina"],"analysis":"Segala diskusi Israel/Palestina/Yahudi dilarang total — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
|
||||
modes: ["text", "media", "mixed"],
|
||||
},
|
||||
|
||||
// ── Physics / Technology Discussions (false positive prevention) ──
|
||||
{
|
||||
id: "32",
|
||||
title: "Diskusi fisika/kinetik dalam konteks teknis (AMAN, bukan ancaman)",
|
||||
input:
|
||||
"[target] id=32323 user=physics_student: Cukup cuman tubuh manusia vs gravitasi. Konsep energy conservation di sini penting buat analisis statis.",
|
||||
output:
|
||||
'{"results":[{"message_id":"32323","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Diskusi fisika teknis tentang kinetik dan gravitasi dalam konteks analisis statis – tidak ada ancaman atau konten melanggar. Penggunaan istilah fisika untuk perhitungan teknis adalah hal wajar."}]}',
|
||||
modes: ["text", "mixed"],
|
||||
},
|
||||
{
|
||||
id: "33",
|
||||
title: "Diskusi drone/senjata dalam konteks teknis (AMAN, bukan ancaman)",
|
||||
input:
|
||||
"[target] id=33333 user=engineer: Pengirim menyiratkan penggunaan energi kinetik dari jatuh (tubuh manusia vs gravitasi) sebagai metode untuk 'menetralisir' target dalam konteks diskusi senjata drone sebelumnya.",
|
||||
output:
|
||||
'{"results":[{"message_id":"33333","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.9,"recommended_action":"none","evidence":[],"analysis":"Diskusi teknis tentang drone dan aplikasi fisika dalam konteks engineering – tidak ada ajuan aksi atau ancaman nyata. Penggunaan istilah senjata dalam konteks diskusi teori adalah hal wajar."}]}',
|
||||
modes: ["text", "mixed"],
|
||||
},
|
||||
];
|
||||
|
||||
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
|
||||
|
||||
@@ -31,11 +31,16 @@ Struktur wajib:
|
||||
]
|
||||
}
|
||||
|
||||
Instruksi per field:
|
||||
- "message_id": WAJIB sama persis dengan id di input. Setiap <message> di <messages_to_analyze> menghasilkan SATU hasil. Jangan gabungkan beberapa pesan, jangan lewati, jangan karang id.
|
||||
- "evidence": kutipan PERSIS frasa yang melanggar (maks 1 baris). Pelanggaran di gambar/sticker → kutip deskripsi Media analysis. Pelanggaran lewat balasan/referensi → sebut konteks pesan yang dibalas. Boleh tambah label sumber, mis. [media analysis] / [web_search] / [reply]. Kosong jika clean.
|
||||
|
||||
## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
|
||||
Data konteks tersedia: <user_profile> (ringkasan kepribadian pengguna) dan <channel_culture> (topik/vibe channel).
|
||||
Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id="..."/> yang menunjuk ke entri di peta <user_profiles>.
|
||||
Gunakan untuk personalisasi analysis, tapi:
|
||||
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
||||
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
||||
- <user_history> (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN KEKONSISTEN (spam link yang SAMA, provokasi yang MENGULANG KONTEN NYATA YANG SAMA). JANGAN pernah gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat. Setiap pesan BARU dinilai TERSAMBUNG (standalone). Jika tidak ada pola pengulangan yang jelas → CLEAN. Contoh: Jika sebelumnya ada pesan dengan link scam.example.com yang di-flag, dan pesan baru juga ada link scam.example.com → FLAG. Tapi jika pesan baru tentang "energi kinetik dari jatuh" tanpa link yang sama → CLEAN walaupun ada history lain.
|
||||
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
|
||||
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
|
||||
|
||||
@@ -54,6 +59,7 @@ Contoh buruk: "Pesan berisi teks dan gambar tanpa pelanggaran." (mengabaikan buk
|
||||
- **conflict_instigation:** "Pengirim <ajakan memicu konflik>. <konteks>. Diberi peringatan karena berpotensi memicu drama."
|
||||
- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang <alasan ofensif>. Isi pesan hanya <isi>. Diberi warning ringan." — (pesan memperkuat): "<username SARA> + isi pesan memperkuat tone kebencian. Pelanggaran berat."
|
||||
- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan <makna asli>. <dampak>. <kesimpulan>."
|
||||
- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. <isi pesan>. Diberi peringatan karena spam berulang." — nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran.
|
||||
- **sexual_deviation:** "Pengirim <konten penyimpangan>. <konteks>. Melanggar kebijakan server."
|
||||
- **SARA/penistaan agama:** "Pengirim <jenis penistaan spesifik: parodi ayat, mengaku Tuhan, mockery ritual, istilah agama sebagai joke, provokasi antar-agama>. <bukti>. Melanggar kebijakan SARA." — JANGAN gunakan kata "bercanda" untuk SARA.
|
||||
|
||||
@@ -66,7 +72,7 @@ CRITICAL:
|
||||
- Jika pesan adalah BALASAN (reply) ke pesan lain, jelaskan konteks balasannya: apa yang sedang dibicarakan, siapa yang dibalas (tanpa nama, cukup peran/isi pesan yang dibalas), dan bagaimana tanggapan pengirim terhadapnya.
|
||||
- Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
|
||||
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
|
||||
- GUNAKAN <user_profile> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna.
|
||||
- GUNAKAN <user_profile_ref>/<user_profiles> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna.
|
||||
- Jika perilaku pesan menyimpang dari profil yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan.
|
||||
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const SYSTEM_RULES = `Kamu adalah asisten moderasi konten untuk server Di
|
||||
## Normalisasi & Pertahanan Lintas Bahasa (WAJIB)
|
||||
1. Campuran bahasa (Inggris/Indonesia/daerah) WAJIB dinormalisasi mental ke Bahasa Indonesia sebelum menilai intent. Jangan longgar hanya karena sintaksis campur (Polyglot Obfuscation).
|
||||
2. Lakukan Named Entity Recognition agresif — nama orang/karakter (mis. "ren" setelah kata archaic "diagem") tetap dikenali sebagai nama.
|
||||
3. <term_glossary> (bila ada) = definisi kata/slang/jargon yang tidak umum. Baca dulu arti kata yang tidak kamu kenal dari sana — jangan menebak dari bunyi/kemiripan. Kata yang tampak mencurigakan namun ternyata bermakna netral di glossary = AMAN; kata asing yang ternyata vulgar/terlarang di glossary = FLAG.
|
||||
|
||||
## Aturan Umum (AMAN — jangan flag)
|
||||
- Slang: anjay, wkwk, gws, gaskeun, santuy, njir, baka, woy/woi, hadeh, astaga = AMAN.
|
||||
@@ -29,15 +30,19 @@ export const SYSTEM_RULES = `Kamu adalah asisten moderasi konten untuk server Di
|
||||
- Ekspresi religius (Astaghfirullah, Alhamdulillah, Subhanallah, Allahuakbar, MasyaAllah, Bismillah, InsyaAllah, Laa ilaha illallah + varian all-caps) = DOA NORMAL, bukan vulgar. AMAN.
|
||||
- Discord custom emoji (<:hadeh:123>) = ekspresi, bukan pelanggaran teks.
|
||||
- Makian pada entitas eksternal (game, dev, perusahaan, benda mati: "game ini ampas") = AMAN. Harassment/hate_speech HANYA untuk anggota/kelompok server secara personal.
|
||||
- **Diskusi fisika, teknik, atau engineering dalam konteks teknis** (kinetik, gravitasi, energi, drone, senjata, drone warfare, physics simulations, CAD, CNC, 3D printing, robotics, aerospace, aerodynamika) = AMAN. Penggunaan istilah teknis untuk perhitungan atau analisis bukan ancaman. JANGAN flag hanya karena istilah "senjata" atau "drone" dalam konteks diskusi teori teknis. Flag HANYA jika ada ajuan aksi eksplisit atau ancaman nyata terarah.
|
||||
- **Riwayat pengguna dengan pelanggaran sebelumnya** tidak boleh memengaruhi penilaian pesan bersih yang TERPISAH dan tidak mengandung pelanggaran aktual. Setiap pesan dinilai berdasarkan ISINYA SENDIRI.
|
||||
|
||||
## Zero Tolerance — Vulgaritas Anatomi/Seksual
|
||||
Kata alat kelamin/anatomi seksual (kontol, memek, titten, tit, dick) atau istilah seksual eksplisit WAJIB di-flag sebagai vulgar_language/sexual_content — TANPA pengecualian bercanda, slang, atau "santai".
|
||||
|
||||
## Nilai Server — Diskriminasi
|
||||
- Seksisme ("dasar perempuan", "logika cewek") → hate_speech (umum) / harassment (terarah).
|
||||
- Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment.
|
||||
- Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah.
|
||||
- Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar.
|
||||
-Ketika sesuatu yang melanggar terjadi di channel, flag jika relevan. Setiap pesan dinilai BERDASARKAN ISINYA SENDIRI, bukan sekadar histori pengguna.
|
||||
-Seksisme ("dasar perempuan", "logika cewek") → hate_speech (umum) / harassment (terarah).
|
||||
-Ageisme ("dasar bocil", "tau aja lo tua") → hate_speech / harassment.
|
||||
-Diskriminasi fisik ("gendut", "iteman", "cungkring") → harassment jika terarah.
|
||||
-Serangan personal, penghinaan, merendahkan = tidak ditoleransi. Perbedaan pendapat wajar.
|
||||
+**PESAN DINILAI SECARA STANDALONE:** Setiap pesan baru dinilai BERDASARKAN ISINYA SENDIRI. <user_history> (jika ada) HANYA untuk mendeteksi POLA PENGULANGAN dengan JAMAK (spam link yang SAMA, provokasi berulang yang MENGANDALKAN KONTEN YANG SAMA). JANGAN gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat pelanggaran sebelumnya. Jika pesan tidak mengandung unsur yang BERPANDUAN PADA riwayat → tetap CLEAN.
|
||||
|
||||
## LARANGAN BERAT (ZERO TOLERANCE)
|
||||
- **LGBT:** Segala promosi, diskusi, pengakuan orientasi, coming out, atau curhat personal tentang LGBT WAJIB di-flag "sexual_deviation". Tidak ada pengecualian.
|
||||
@@ -73,6 +78,7 @@ RENDAH: harassment, vulgar_language terarah, offensive_username (Scunthorpe: "Sa
|
||||
|
||||
## Web Sebagai Bukti Utama
|
||||
- <web_searches> ADALAH BUKTI UTAMA. Jika ada, WAJIB pakai hasilnya (hentai/scam/narkoba → flag; aman → clean). JANGAN abaikan. Jika tidak ada → gunakan pengetahuan internal.
|
||||
- <term_glossary> = REFERENSI ARTI KATA, bukan bukti pelanggaran. Dipakai untuk memahami istilah yang tidak dikenal sebelum memutuskan.
|
||||
- Prioritas bukti: <web_searches> > <web_content> > <media_analysis> > pengetahuan internal. <web_content> (URL fetch): gunakan isi, jangan flag hanya dari domain name.
|
||||
|
||||
## Pohon Keputusan
|
||||
|
||||
@@ -39,7 +39,6 @@ Gambar/sticker/embed/preview link sudah DIDESKRIPSIKAN vision model sebelum batc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
contextText: string;
|
||||
/** Prompt mode — determines which sections are included. */
|
||||
mode: PromptMode;
|
||||
/** @deprecated Use `mode` instead. */
|
||||
@@ -59,7 +58,6 @@ export interface BuildSystemPromptOptions {
|
||||
|
||||
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
const {
|
||||
contextText,
|
||||
mode,
|
||||
includeMediaInstructions,
|
||||
correction,
|
||||
@@ -105,15 +103,40 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`## Konteks Pengguna\nSetiap pesan mungkin memiliki tag <user_reputation>. Tag ini hanya indikator **referensi**, bukan bukti pelanggaran. Nilai trust_score yang rendah bukan alasan untuk memflag pesan yang bersih. Nilai trust_score yang tinggi bukan alasan untuk mengabaikan pelanggaran nyata. **Setiap pesan harus dinilai berdasarkan isinya sendiri.**`,
|
||||
`## Blok Data di Pesan USER\n` +
|
||||
`Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` +
|
||||
`- <location_context .../> = metadata channel/thread (channel_id, channel_name, thread_name, topic, nsfw, age_restricted). topic = deskripsi resmi channel — pakai untuk menilai kesesuaian pesan dengan tujuan channel.\n` +
|
||||
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
|
||||
`- <user_profiles> = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
|
||||
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
|
||||
`- <term_glossary> = kamus istilah: definisi kata/slang/jargon yang jarang dikenal (hasil pencarian Wikipedia via SearXNG). Gunakan untuk memahami arti kata yang tidak kamu kenal — JANGAN menebak atau mengarang arti.\n` +
|
||||
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`,
|
||||
);
|
||||
|
||||
parts.push(
|
||||
`## Konteks Pengguna (Referensi, Bukan Bukti)\n` +
|
||||
`Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` +
|
||||
`- <user_reputation trust_score="..." total_infractions="..." clean_streak="..." last_offense_days_ago="..." repeat_offender="..."> = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata. repeat_offender="true" = ada pelanggaran dalam 7 hari terakhir.\n` +
|
||||
`- <user_history> (di dalam <user_reputation>) = kutipan pesan-pesan pengguna yang PERNAH di-flag. Gunakan untuk mengenali POLA berulang (spam link sama, provokasi), tapi JANGAN memflag pesan bersih hanya karena riwayat.\n` +
|
||||
`- <user_profiles> (di pesan USER) = peta ringkasan kepribadian per user_id. <user_profile_ref user_id="..."/> dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` +
|
||||
`- Profil berguna untuk mengenali penyimpangan perilaku mencolok (mis. pengguna teknis tiba-tiba provokatif), tapi JANGAN memflag atau meloloskan hanya karena profil.\n` +
|
||||
`**Setiap pesan dinilai berdasarkan isinya sendiri.**`,
|
||||
);
|
||||
|
||||
parts.push(
|
||||
`## Framing: Konteks vs Target\n` +
|
||||
`- Baris dalam <conversation_context> berformat "[context] id=... time=<ISO> user=<nama>: isi", diurutkan paling lama → paling baru. Baris pertama biasanya "[conversation_flow] status=... context_msgs=... dropped=..." — metadata sistem tentang status percakapan (ongoing/sparse/cold_start), BUKAN pesan yang dinilai.\n` +
|
||||
`- <messages_to_analyze> berisi pesan-pesan TARGET yang WAJIB dinilai. Hasilkan SATU hasil per message_id — jangan menggabungkan beberapa pesan, jangan melewati, jangan mengarang id.\n` +
|
||||
`- Setiap target dinilai berdasarkan isinya sendiri; konteks percakapan memengaruhi interpretasi, bukan menggantikan isi pesan.\n` +
|
||||
`- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.\n` +
|
||||
`- Atribut time= pada <message> target = kapan pesan dikirim (ISO). Pakai untuk menilai kerelevanan waktu (mis. pesan lama di-bump, spam beruntun dalam menit yang sama).\n` +
|
||||
`- repetitions="N" pada <message> = teks pendek yang sama muncul N kali dalam batch — pertimbangkan sebagai sinyal spam, tapi nilai tetap dari isi pesan.\n` +
|
||||
`- bot="true" = pengirim adalah bot (otomatisasi), bukan pengguna manusia — jangan perlakukan sebagai pelanggaran personal, tapi kontennya tetap dinilai.\n` +
|
||||
`- edited="true" = konten yang ditampilkan adalah hasil edit setelah posting (sinyal potensi evasi), nilai konten saat ini apa adanya.`,
|
||||
);
|
||||
|
||||
parts.push(OUTPUT_INSTRUCTIONS);
|
||||
|
||||
// XML-delimited context — prevents prompt injection
|
||||
const delimitedContext = `<conversation_context>\n${sanitizeAiContent(contextText, 8000)}\n</conversation_context>`;
|
||||
parts.push(delimitedContext);
|
||||
|
||||
let base = parts.join("\n\n");
|
||||
|
||||
if (correction) {
|
||||
|
||||
@@ -17,6 +17,18 @@ import { config } from "../../shared/config/config.js";
|
||||
|
||||
const log = createChildLogger("qdrant");
|
||||
|
||||
// ensureQdrantCollection performs a network round-trip (GET, possibly
|
||||
// DELETE+PUT). Running it on every upsert adds 1-3 HTTP calls per
|
||||
// moderation verdict, which under Qdrant load pushes the upsert past the
|
||||
// request timeout and aborts it ("This operation was aborted"). Memoise the
|
||||
// result so the collection is only verified once per process lifetime.
|
||||
let ensureCollectionPromise: Promise<boolean> | null = null;
|
||||
|
||||
/** Reset the memoised ensure result (used by tests / config reload). */
|
||||
export function resetQdrantCollectionCache(): void {
|
||||
ensureCollectionPromise = null;
|
||||
}
|
||||
|
||||
export interface QdrantVerdictPayload {
|
||||
text: string;
|
||||
flags: string; // JSON string of the full moderation result
|
||||
@@ -97,46 +109,53 @@ export function qdrantPointId(cacheKey: string): number {
|
||||
export async function ensureQdrantCollection(
|
||||
vectorSize: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 404 = collection doesn't exist yet → create it.
|
||||
let existing: {
|
||||
result?: { config?: { params?: { vectors?: { size?: number } } } };
|
||||
} | null = null;
|
||||
if (ensureCollectionPromise) return ensureCollectionPromise;
|
||||
ensureCollectionPromise = (async () => {
|
||||
try {
|
||||
existing = (await request("GET", `/collections/${collectionName()}`)) as {
|
||||
// 404 = collection doesn't exist yet → create it.
|
||||
let existing: {
|
||||
result?: { config?: { params?: { vectors?: { size?: number } } } };
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes("-> 404")) {
|
||||
throw error;
|
||||
} | null = null;
|
||||
try {
|
||||
existing = (await request(
|
||||
"GET",
|
||||
`/collections/${collectionName()}`,
|
||||
)) as {
|
||||
result?: { config?: { params?: { vectors?: { size?: number } } } };
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.includes("-> 404")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const size = existing?.result?.config?.params?.vectors?.size;
|
||||
if (size === vectorSize) return true;
|
||||
const size = existing?.result?.config?.params?.vectors?.size;
|
||||
if (size === vectorSize) return true;
|
||||
|
||||
if (size !== undefined && size !== vectorSize) {
|
||||
log.warn(
|
||||
{ collection: collectionName(), oldSize: size, newSize: vectorSize },
|
||||
"Qdrant collection vector size changed — recreating collection",
|
||||
if (size !== undefined && size !== vectorSize) {
|
||||
log.warn(
|
||||
{ collection: collectionName(), oldSize: size, newSize: vectorSize },
|
||||
"Qdrant collection vector size changed — recreating collection",
|
||||
);
|
||||
await request("DELETE", `/collections/${collectionName()}`);
|
||||
}
|
||||
|
||||
await request("PUT", `/collections/${collectionName()}`, {
|
||||
vectors: { size: vectorSize, distance: "Cosine" },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collection: collectionName(),
|
||||
},
|
||||
"Failed to ensure Qdrant collection",
|
||||
);
|
||||
await request("DELETE", `/collections/${collectionName()}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await request("PUT", `/collections/${collectionName()}`, {
|
||||
vectors: { size: vectorSize, distance: "Cosine" },
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
collection: collectionName(),
|
||||
},
|
||||
"Failed to ensure Qdrant collection",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return ensureCollectionPromise;
|
||||
}
|
||||
|
||||
/** Upsert one embedding + verdict payload point. Returns false on failure. */
|
||||
@@ -147,10 +166,15 @@ export async function upsertQdrantPoint(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!(await ensureQdrantCollection(vector.length))) return false;
|
||||
await request("PUT", `/collections/${collectionName()}/points`, {
|
||||
points: [{ id: qdrantPointId(cacheKey), vector, payload }],
|
||||
wait: true,
|
||||
});
|
||||
await request(
|
||||
"PUT",
|
||||
`/collections/${collectionName()}/points`,
|
||||
{
|
||||
points: [{ id: qdrantPointId(cacheKey), vector, payload }],
|
||||
wait: true,
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Redis from "ioredis";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
|
||||
const log = createChildLogger("searxng-search");
|
||||
|
||||
const SEARXNG_BASE_URL = "https://searxng.imrnes.team";
|
||||
const SEARXNG_BASE_URL = config.SEARXNG_BASE_URL;
|
||||
const MAX_RESULTS = 3;
|
||||
const TIMEOUT_MS = 8000;
|
||||
const CACHE_TTL = 86400; // 24 hours
|
||||
@@ -12,6 +13,42 @@ const CACHE_PREFIX = "searxng:";
|
||||
|
||||
let redis: Redis | null = null;
|
||||
|
||||
/**
|
||||
* Exposes the shared SearXNG Redis connection so other modules (e.g. the
|
||||
* term glossary) reuse the same connection and cache prefix instead of
|
||||
* opening their own. Returns null when Redis is unavailable.
|
||||
*/
|
||||
export function getSearxngRedis(): Redis | null {
|
||||
return redis;
|
||||
}
|
||||
|
||||
/** Builds a namespaced SearXNG cache key (shared across modules). */
|
||||
export function makeSearxngCacheKey(namespace: string, key: string): string {
|
||||
return `${CACHE_PREFIX}${namespace}:${key.toLowerCase().trim()}`;
|
||||
}
|
||||
|
||||
/** Reads a value from the SearXNG Redis cache; null on miss/unavailable. */
|
||||
export async function searxngCacheGet(key: string): Promise<string | null> {
|
||||
if (!redis) return null;
|
||||
try {
|
||||
return await redis.get(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes a value to the SearXNG Redis cache, fire-and-forget. */
|
||||
export function searxngCacheSet(
|
||||
key: string,
|
||||
value: string,
|
||||
ttlSeconds: number,
|
||||
): void {
|
||||
if (!redis) return;
|
||||
redis.setex(key, ttlSeconds, value).catch(() => {
|
||||
// Cache write failed silently
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Redis connection for SearXNG cache.
|
||||
* Safe to call multiple times — only creates one connection.
|
||||
@@ -51,19 +88,26 @@ export interface SearxngResult {
|
||||
/**
|
||||
* Search SearXNG for a query and return structured results.
|
||||
* Uses Redis cache when available — same query within 24h returns cached results.
|
||||
*
|
||||
* @param engines Optional comma-separated SearXNG engine list to constrain
|
||||
* the search (e.g. "wikipedia"). When set, results are cached under a
|
||||
* separate cache namespace so engine-specific results never collide.
|
||||
*/
|
||||
export async function searchSearxng(
|
||||
query: string,
|
||||
category: "general" | "news" | "science" = "general",
|
||||
engines?: string,
|
||||
timeoutMs: number = TIMEOUT_MS,
|
||||
): Promise<SearxngResult[]> {
|
||||
const cacheKey = `${CACHE_PREFIX}${category}:${query.toLowerCase().trim()}`;
|
||||
const engineNs = engines ? `eng:${engines}` : "auto";
|
||||
const cacheKey = makeSearxngCacheKey(`${category}:${engineNs}`, query);
|
||||
|
||||
// Try cache first
|
||||
if (redis) {
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
log.debug({ query, category }, "SearXNG cache HIT");
|
||||
log.debug({ query, category, engines }, "SearXNG cache HIT");
|
||||
return JSON.parse(cached) as SearxngResult[];
|
||||
}
|
||||
} catch {
|
||||
@@ -73,8 +117,11 @@ export async function searchSearxng(
|
||||
|
||||
// Cache miss — hit SearXNG API
|
||||
try {
|
||||
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
|
||||
const { controller, clear } = createAbortControllerWithTimeout(TIMEOUT_MS);
|
||||
const engineParam = engines
|
||||
? `&engines=${encodeURIComponent(engines)}`
|
||||
: "";
|
||||
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}${engineParam}`;
|
||||
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* termGlossary.ts
|
||||
*
|
||||
* Per-word "kamus" enrichment for LLM moderation.
|
||||
*
|
||||
* Problem: the moderation LLM often meets words it does not know — regional
|
||||
* slang (Jawa/Sunda), foreign terms, niche anime/game jargon, or obscure
|
||||
* technical vocabulary. When it guesses, it either invents a wrong meaning
|
||||
* (false positive on a safe word) or misses a violation hidden in unfamiliar
|
||||
* wording (false negative on an unknown vulgar/slang term).
|
||||
*
|
||||
* Solution: extract candidate "unknown-looking" words from message content,
|
||||
* look each one up on Wikipedia via SearXNG, and inject the definitions into
|
||||
* the LLM prompt as a `<term_glossary>` block so verdicts are based on facts
|
||||
* instead of guesses.
|
||||
*
|
||||
* Cost control & persistence:
|
||||
* - successfully resolved definitions are PERSISTED PERMANENTLY in Postgres
|
||||
* (`term_glossary_cache`) — definitions rarely change, so a resolved term
|
||||
* is never searched again; only misses stay ephemeral (Redis/LRU, 1h);
|
||||
* - in-memory LRU + Redis (shared with the SearXNG cache) sit in front of
|
||||
* the DB as fast read caches, so repeat lookups are effectively free;
|
||||
* - lookups per batch are bounded (AI_GLOSSARY_MAX_TERMS);
|
||||
* - live SearXNG calls are rate-limit aware: concurrency 2 + stagger, retry
|
||||
* once on empty results, and misses cached for only 1h so a limiter/
|
||||
* network blip is not treated as a permanent miss;
|
||||
* - only results that read like actual definitions are accepted (Wikipedia
|
||||
* preferred; disambiguation/ads/translate-homepages rejected);
|
||||
* - everything degrades gracefully: no Redis, no SearXNG, no match
|
||||
* → the block is simply omitted and moderation proceeds as before.
|
||||
*/
|
||||
|
||||
import { LRUCache } from "lru-cache";
|
||||
import pLimit from "p-limit";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { delay } from "@/shared/utils/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { escapeXml } from "./moderationBuilders.js";
|
||||
import {
|
||||
makeSearxngCacheKey,
|
||||
searchSearxng,
|
||||
searxngCacheGet,
|
||||
searxngCacheSet,
|
||||
} from "./searxngSearch.js";
|
||||
import {
|
||||
getTermDefinitionFromDb,
|
||||
setTermDefinitionInDb,
|
||||
} from "./termGlossaryStore.js";
|
||||
|
||||
const log = createChildLogger("term-glossary");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Redis TTL for a successfully resolved definition (definitions are stable). */
|
||||
const DEF_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
/**
|
||||
* Redis TTL for a lookup that found nothing. Kept SHORT (1h): SearXNG
|
||||
* instances silently return empty result sets when rate-limited, so an empty
|
||||
* response is often a transient failure, not a real miss. A short TTL lets
|
||||
* the term be retried on a later batch instead of poisoning it for a day.
|
||||
*/
|
||||
const MISS_TTL_SECONDS = 60 * 60;
|
||||
const MISS_TTL_MS = MISS_TTL_SECONDS * 1000;
|
||||
/** Sentinel stored in caches for "term has no resolvable definition". */
|
||||
const EMPTY_SENTINEL = "__not_found__";
|
||||
/** Per-search timeout — keep glossary lookups snappy even on a slow SearXNG. */
|
||||
const GLOSSARY_SEARCH_TIMEOUT_MS = 5000;
|
||||
/** Delay before retrying a search that returned zero results. */
|
||||
const RETRY_DELAY_MS = 350;
|
||||
/** Max definition snippet length kept in the prompt. */
|
||||
const MAX_DEFINITION_CHARS = 300;
|
||||
/**
|
||||
* SearXNG rate-limits aggressive parallel bursts (returns 200 with empty
|
||||
* results). Never fire all terms at once — cap live searches at 2 concurrent
|
||||
* and stagger the start times slightly.
|
||||
*/
|
||||
const LIVE_SEARCH_CONCURRENCY = 2;
|
||||
const LIVE_SEARCH_STAGGER_MS = 250;
|
||||
|
||||
/** In-memory cache: term (lowercase) → definition | NOT_FOUND sentinel. */
|
||||
const NOT_FOUND: TermDefinition = {
|
||||
term: "__not_found__",
|
||||
definition: "",
|
||||
sourceUrl: "",
|
||||
};
|
||||
const termLru = new LRUCache<string, TermDefinition>({
|
||||
max: 2000,
|
||||
ttl: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
/** Serializes live SearXNG lookups (rate-limit aware) with a small stagger. */
|
||||
const liveSearchLimit = pLimit(LIVE_SEARCH_CONCURRENCY);
|
||||
let lastLiveSearchAt = 0;
|
||||
async function acquireLiveSlot(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const wait = lastLiveSearchAt + LIVE_SEARCH_STAGGER_MS - now;
|
||||
if (wait > 0) await delay(wait);
|
||||
lastLiveSearchAt = Date.now();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Term extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Word tokenizer — letters/digits plus internal -_'· (handles "well-known",
|
||||
* "node_modules", diacritics). */
|
||||
const WORD_RE = /[\p{L}\p{N}]+(?:[-_'’·][\p{L}\p{N}]+)*/gu;
|
||||
|
||||
/** Removes URLs, Discord mentions/custom emoji, code fences, markdown noise. */
|
||||
function cleanContent(raw: string): string {
|
||||
return raw
|
||||
.replace(/https?:\/\/\S+/gi, " ")
|
||||
.replace(/<@!?\d+>/g, " ")
|
||||
.replace(/<#\d+>/g, " ")
|
||||
.replace(/<a?:\w+:\d+>/g, " ")
|
||||
.replace(/[`*_~|>[\]]/g, " ")
|
||||
.replace(/[\p{Emoji}\p{Extended_Pictographic}]/gu, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Filters out tokens that are useless as glossary candidates (numbers,
|
||||
* repeated-char noise, mega-tokens). */
|
||||
function isNoiseWord(word: string): boolean {
|
||||
if (word.length > 28) return true;
|
||||
if (/^\d+$/.test(word)) return true;
|
||||
const lower = word.toLowerCase();
|
||||
// "aaaa…", "wwwwww" — single repeated character
|
||||
if (/^(.)\1{2,}$/.test(lower)) return true;
|
||||
// "wkwk", "hehe", "69" alternations — repeated 2–3 char base. "meme" is
|
||||
// the one legit 4-letter word this matches; it is whitelisted below.
|
||||
if (/^([a-z]{2,3})\1{1,}$/.test(lower)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Deterministic bonus for words that look like proper nouns or foreign. */
|
||||
function scoreWord(word: string): number {
|
||||
let score = 1;
|
||||
// Capitalized first letter (proper noun / title) but not ALL-CAPS acronyms
|
||||
if (/^[A-Z]/.test(word) && !/^[A-Z]{2,}$/.test(word)) score += 3;
|
||||
// Contains a letter outside basic latin → regional/foreign spelling
|
||||
if (/[\p{L}]/u.test(word.replace(/[A-Za-z]/g, ""))) score += 2;
|
||||
// Contains an internal apostrophe or hyphen → likely a named entity
|
||||
if (/[-_'’·]/.test(word)) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set(
|
||||
// ── Bahasa Indonesia ────────────────────────────────────────────────
|
||||
(
|
||||
" yang dan di ke dari ini itu dengan untuk pada dalam adalah akan telah sudah bisa dapat harus tidak juga saya kamu kita kami mereka dia aku kau gua lu lo gw gue elu anda kalian nya kah lah pun ya yah kan sih dong deh kok loh toh aja saja gitu gini begitu begini tapi tetapi namun atau karena sebab jika kalau bila maka supaya agar meski meskipun walau walaupun ketika saat setelah sebelum selama antara terhadap tentang mengenai bagi oleh secara sebagai seperti daripada tanpa hingga sampai sejak menuju bahwa padahal sebenarnya sepertinya mungkin memang jadi lalu terus akhirnya misalnya contohnya banyak sedikit semua seluruh setiap tiap beberapa ada bukan jangan boleh mau ingin pengen nggak ngak gak ga kagak ngga ndak nanti kemarin besok hari ini sekarang waktu itu masih sedang belum pernah sering selalu kadang jarang cepat lambat awal akhir baru lama besar kecil tinggi rendah panjang pendek baik buruk benar salah sama beda penting biasanya selamat terima kasih makasih sangat sekali paling cuma cuman hanya lebih kurang sekitar hampir ternyata rupanya begitu gimana bagaimana kenapa mengapa siapa apa mana kapan darimana kemana bilang ngomong omong kata tadi dulu terus lagi tetap pasti seharusnya sebaiknya seakan seolah kayaknya keliatan kelihatan ketahuan disini disitu disana kesini kesana bener pake pakai kayak emang lagian mulu istilah istilahnya banget" +
|
||||
// ── English ───────────────────────────────────────────────────────
|
||||
" the a an and or but if then else for to in on at by with without from of is are was were be been being have has had do does did will would can could should may might must shall this that these those it its i you he she we they them their there here when where why how what which who whom whose only very just about above after before below under over into onto within upon against between among during through across along around behind beyond near off out up down now then so as not no yes ok okay" +
|
||||
// ── Common net slang / acronyms the LLM already knows ──────────────
|
||||
" lol omg wtf idk btw tbh imo aka fyi nsfw smh nvm asap afk brb gg wp ty np mb sry thx kk oke okk ygy frfr"
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
/**
|
||||
* Words that are either already defined by the moderation rules, or are so
|
||||
* common (brands, tech vocabulary, project names) that a Wikipedia lookup is
|
||||
* a guaranteed miss/waste. Keeps the glossary focused on genuinely unknown
|
||||
* terms.
|
||||
*/
|
||||
const KNOWN_SAFE_TERMS = new Set(
|
||||
(
|
||||
"discord youtube google facebook instagram twitter tiktok whatsapp telegram netflix spotify steam github gitlab bitbucket chatgpt openai anthropic claude deepseek gemini llama copilot cursor vscode vscodium jetbrains intellij pycharm webstorm sublime codeblocks" +
|
||||
" docker kubernetes k8s linux ubuntu debian arch fedora manjaro kali windows macos android ios chrome firefox safari edge opera brave" +
|
||||
" react nextjs next vue svelte angular node nodejs deno bun pnpm yarn npm javascript typescript python golang go rust java kotlin swift cplusplus cpp css html json xml yaml toml regex backend frontend database mysql postgres postgresql mongodb redis qdrant sqlite nosql graphql rest websocket webhook" +
|
||||
" bug crash error debug fix issue pr merge commit push pull branch main master dev staging production server client app website web browser" +
|
||||
" stream streaming video audio voice call camera screen share screenshare gameplay gaming game play steam epic xbox playstation nintendo switch console" +
|
||||
" bot discordbot moderation moderator admin member user profile avatar channel server guild message chat dm reply forward embed sticker emoji role permission" +
|
||||
" meme code coding ngoding programmer program developer engineer software hardware cpu gpu ram rom storage disk network internet wifi lan ip dns vpn proxy cloud aws azure gcp vercel netlify heroku railway render vps hosting domain ssl login logout register account password email username" +
|
||||
" anime manga waifu husbando tsundere moe otaku wibu weeb otome isekai shonen seinen josei manga manhwa manhua doujin" +
|
||||
" anjay wkwk wkwkwk gws gaskeun santuy njir baka woy woi hadeh astaga asu anjing bangsat ngehe asal alay lebay caper mabar" +
|
||||
" asus bete imphnen impnhen ngab" +
|
||||
" syahadat sholat shalat solat puasa zakat haji umrah doa tuhan nabi allah yesus muhammad hashem" +
|
||||
" loli shota incest exhibition furry fursuit cosplay costume" +
|
||||
" gaza palestine israel yahudi yahud israel palestina israeli" +
|
||||
" hokkian mandarin arabic jawa sunda betawi minang bugis batak melayu inggris indonesia"
|
||||
).split(/\s+/),
|
||||
);
|
||||
|
||||
function isKnownTerm(word: string): boolean {
|
||||
return STOPWORDS.has(word) || KNOWN_SAFE_TERMS.has(word);
|
||||
}
|
||||
|
||||
/** True when a quoted phrase is mostly filler words (skip it). */
|
||||
function isMostlyStopwords(phrase: string): boolean {
|
||||
const words = phrase
|
||||
.toLowerCase()
|
||||
.split(/[^a-zà-öø-ÿ]+/i)
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) return true;
|
||||
const stopCount = words.filter((w) => STOPWORDS.has(w)).length;
|
||||
return stopCount / words.length >= 0.6;
|
||||
}
|
||||
|
||||
export interface ExtractGlossaryOptions {
|
||||
maxTerms?: number;
|
||||
minWordLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts candidate terms that the LLM might not know from message content.
|
||||
* Returns at most `maxTerms` terms (default from config), scored by how
|
||||
* "unknown-looking" they are (proper nouns, foreign spelling, quoted phrases).
|
||||
*/
|
||||
export function extractGlossaryTerms(
|
||||
contents: string[],
|
||||
options: ExtractGlossaryOptions = {},
|
||||
): string[] {
|
||||
const maxTerms = options.maxTerms ?? config.AI_GLOSSARY_MAX_TERMS;
|
||||
const minWordLength =
|
||||
options.minWordLength ?? config.AI_GLOSSARY_MIN_WORD_LENGTH;
|
||||
|
||||
const candidates = new Map<string, { word: string; score: number }>();
|
||||
|
||||
const push = (rawWord: string, score: number): void => {
|
||||
const clean = rawWord
|
||||
.trim()
|
||||
.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
|
||||
if (clean.length < minWordLength) return;
|
||||
const key = clean.toLowerCase();
|
||||
if (isKnownTerm(key) || isNoiseWord(clean)) return;
|
||||
const existing = candidates.get(key);
|
||||
if (existing) {
|
||||
existing.score += score + 1;
|
||||
} else {
|
||||
candidates.set(key, { word: clean, score });
|
||||
}
|
||||
};
|
||||
|
||||
for (const content of contents) {
|
||||
if (!content) continue;
|
||||
const cleaned = cleanContent(content);
|
||||
if (!cleaned) continue;
|
||||
|
||||
// Quoted phrases — explicit terms the user called out
|
||||
for (const m of cleaned.matchAll(/"([^"]{2,80})"/g)) {
|
||||
const phrase = m[1].trim();
|
||||
const wordCount = phrase.split(/\s+/).length;
|
||||
if (wordCount >= 2 && wordCount <= 6 && !isMostlyStopwords(phrase)) {
|
||||
push(phrase, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Individual words
|
||||
for (const m of cleaned.matchAll(WORD_RE)) {
|
||||
const w = m[0];
|
||||
if (w.length < minWordLength) continue;
|
||||
if (isNoiseWord(w)) continue;
|
||||
const key = w.toLowerCase();
|
||||
if (isKnownTerm(key)) continue;
|
||||
push(w, scoreWord(w));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates.values())
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, maxTerms)
|
||||
.map((c) => c.word);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Definition lookup (cached: LRU → Redis → SearXNG/Wikipedia)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TermDefinition {
|
||||
term: string;
|
||||
definition: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
/** Definition-like markers for accepting a non-Wikipedia search result. */
|
||||
const DEF_MARKERS =
|
||||
/adalah|merupakan|istilah (?:untuk|yang|yg)|artinya|sebutan|berarti|refers? to|known as|also called|short for|a term (?:for|used)|istilah dalam|kata (?:asing|serapan)? ?untuk/i;
|
||||
|
||||
/** True when the term appears in the result text (or a 4+ char word in the
|
||||
* result is part of the term). Lenient — "kafircel" matches a "Kafir"
|
||||
* article via substring, while a Google-Translate homepage snippet does not. */
|
||||
function hasTermOverlap(term: string, title: string, snippet: string): boolean {
|
||||
const termLower = term.toLowerCase();
|
||||
const text = `${title} ${snippet}`.toLowerCase();
|
||||
if (text.includes(termLower)) return true;
|
||||
const words = text.match(/[a-z0-9]{4,}/gi) ?? [];
|
||||
return words.some((w) => termLower.includes(w));
|
||||
}
|
||||
|
||||
/** Quality gate: is this result good enough to quote as a definition? */
|
||||
function isUsableDefinition(
|
||||
r: { title: string; url: string; snippet: string },
|
||||
term: string,
|
||||
isWiki: boolean,
|
||||
): boolean {
|
||||
const text = `${r.title} ${r.snippet}`;
|
||||
// Wikipedia disambiguation pages are not definitions
|
||||
if (/disambiguasi|disambiguation/i.test(text)) return false;
|
||||
if ((r.snippet ?? "").trim().length < 25) return false;
|
||||
if (!hasTermOverlap(term, r.title, r.snippet)) return false;
|
||||
// Wikipedia articles are accepted with just the overlap+length gate;
|
||||
// everything else must read like an actual definition, not an ad,
|
||||
// a translate homepage, or a navigation blurb.
|
||||
if (isWiki) return true;
|
||||
return DEF_MARKERS.test(r.snippet);
|
||||
}
|
||||
|
||||
/** Picks the best definition from search results, preferring a genuine
|
||||
* Wikipedia article; otherwise the first result that reads like a
|
||||
* definition. Returns null when nothing qualifies. */
|
||||
function pickDefinition(
|
||||
results: Array<{ title: string; url: string; snippet: string }>,
|
||||
term: string,
|
||||
): TermDefinition | null {
|
||||
const wiki = results.find((r) => /wikipedia\.org/i.test(r.url));
|
||||
const best = wiki && isUsableDefinition(wiki, term, true) ? wiki : null;
|
||||
if (!best) {
|
||||
for (const r of results) {
|
||||
if (isUsableDefinition(r, term, false)) {
|
||||
return buildDefinition(r, term);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return buildDefinition(best, term);
|
||||
}
|
||||
|
||||
function buildDefinition(
|
||||
best: { title: string; url: string; snippet: string },
|
||||
term: string,
|
||||
): TermDefinition {
|
||||
const snippet = (best.snippet || best.title || "").trim();
|
||||
const definition =
|
||||
snippet.length > MAX_DEFINITION_CHARS
|
||||
? `${snippet.slice(0, MAX_DEFINITION_CHARS - 1).trimEnd()}…`
|
||||
: snippet;
|
||||
return { term, definition, sourceUrl: best.url };
|
||||
}
|
||||
|
||||
/** Live (network) lookup — runs under the shared SearXNG rate-limit gate. */
|
||||
async function fetchDefinitionLive(
|
||||
term: string,
|
||||
key: string,
|
||||
cacheKey: string,
|
||||
): Promise<TermDefinition | null> {
|
||||
return liveSearchLimit(async () => {
|
||||
await acquireLiveSlot();
|
||||
try {
|
||||
let results = await searchSearxng(
|
||||
key,
|
||||
"general",
|
||||
undefined,
|
||||
GLOSSARY_SEARCH_TIMEOUT_MS,
|
||||
);
|
||||
let def = pickDefinition(results, term);
|
||||
// Zero results is usually the limiter kicking in, not a real miss —
|
||||
// retry once. Results-but-unusable = genuine miss, no retry.
|
||||
if (!def && results.length === 0) {
|
||||
await delay(RETRY_DELAY_MS);
|
||||
results = await searchSearxng(
|
||||
key,
|
||||
"general",
|
||||
undefined,
|
||||
GLOSSARY_SEARCH_TIMEOUT_MS,
|
||||
);
|
||||
def = pickDefinition(results, term);
|
||||
}
|
||||
|
||||
if (def) {
|
||||
// Persist permanently (definitions rarely change) — best-effort,
|
||||
// then warm the fast caches.
|
||||
void setTermDefinitionInDb(key, def.definition, def.sourceUrl);
|
||||
searxngCacheSet(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
definition: def.definition,
|
||||
sourceUrl: def.sourceUrl,
|
||||
}),
|
||||
DEF_TTL_SECONDS,
|
||||
);
|
||||
termLru.set(key, def);
|
||||
log.debug({ term: key }, "Term glossary resolved definition");
|
||||
return def;
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
{ term: key, error: err instanceof Error ? err.message : String(err) },
|
||||
"Term glossary lookup failed — skipping term",
|
||||
);
|
||||
}
|
||||
|
||||
// No definition — cache the miss with a SHORT TTL so a transient
|
||||
// limiter/network failure is retried on a later batch.
|
||||
searxngCacheSet(cacheKey, EMPTY_SENTINEL, MISS_TTL_SECONDS);
|
||||
termLru.set(key, NOT_FOUND, { ttl: MISS_TTL_MS });
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve one term: LRU → Redis → Postgres (permanent) → live SearXNG
|
||||
* (rate-limited). The fast caches sit in front of the DB; the DB is the
|
||||
* source of truth for successfully resolved definitions. */
|
||||
async function resolveTerm(term: string): Promise<TermDefinition | null> {
|
||||
const key = term.toLowerCase().trim();
|
||||
|
||||
// 1. In-memory LRU — same process, instant
|
||||
const lruHit = termLru.get(key);
|
||||
if (lruHit) return lruHit === NOT_FOUND ? null : lruHit;
|
||||
|
||||
// 2. Redis — shared across processes/workers. A miss sentinel here is NOT
|
||||
// a definitive answer: it may predate a permanent DB entry written by
|
||||
// another process, so we keep going and let the DB decide.
|
||||
const cacheKey = makeSearxngCacheKey("def", key);
|
||||
const cached = await searxngCacheGet(cacheKey);
|
||||
let redisMiss = false;
|
||||
if (cached !== null) {
|
||||
if (cached === EMPTY_SENTINEL) {
|
||||
redisMiss = true;
|
||||
} else {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as {
|
||||
definition?: string;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
if (parsed.definition) {
|
||||
const def: TermDefinition = {
|
||||
term,
|
||||
definition: parsed.definition,
|
||||
sourceUrl: parsed.sourceUrl ?? "",
|
||||
};
|
||||
termLru.set(key, def);
|
||||
return def;
|
||||
}
|
||||
} catch {
|
||||
// malformed cache entry — fall through to DB/live
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Postgres — permanent store for resolved definitions. A hit re-warms
|
||||
// the fast caches so the DB is not hit on every batch.
|
||||
const dbDef = await getTermDefinitionFromDb(key);
|
||||
if (dbDef) {
|
||||
const def: TermDefinition = {
|
||||
term,
|
||||
definition: dbDef.definition,
|
||||
sourceUrl: dbDef.sourceUrl,
|
||||
};
|
||||
termLru.set(key, def);
|
||||
searxngCacheSet(
|
||||
cacheKey,
|
||||
JSON.stringify({ definition: def.definition, sourceUrl: def.sourceUrl }),
|
||||
DEF_TTL_SECONDS,
|
||||
);
|
||||
log.debug({ term: key }, "Term glossary DB hit");
|
||||
return def;
|
||||
}
|
||||
|
||||
// 4. Redis already said "miss" recently and the DB has nothing — respect
|
||||
// that instead of hammering SearXNG again within the miss window.
|
||||
if (redisMiss) {
|
||||
termLru.set(key, NOT_FOUND, { ttl: MISS_TTL_MS });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Live search (rate-limited + staggered)
|
||||
return fetchDefinitionLive(term, key, cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up definitions for a batch of terms, in parallel. Returns a map of
|
||||
* term → definition for the terms that resolved. Errors/misses are skipped.
|
||||
* Live SearXNG calls are throttled internally (concurrency 2 + stagger).
|
||||
*/
|
||||
export async function lookupTermDefinitions(
|
||||
terms: string[],
|
||||
): Promise<Map<string, TermDefinition>> {
|
||||
const map = new Map<string, TermDefinition>();
|
||||
if (terms.length === 0) return map;
|
||||
|
||||
const results = await Promise.allSettled(terms.map(resolveTerm));
|
||||
for (let i = 0; i < terms.length; i++) {
|
||||
const r = results[i];
|
||||
if (r.status === "fulfilled" && r.value) {
|
||||
map.set(r.value.term, r.value);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Formats definitions as a `<term_glossary>` XML block for the LLM prompt:
|
||||
*
|
||||
* <term_glossary>
|
||||
* <term word="ngab" source="https://…">definisi…</term>
|
||||
* </term_glossary>
|
||||
*
|
||||
* Returns "" when there are no definitions (the block is then omitted).
|
||||
*/
|
||||
export function formatTermGlossary(
|
||||
defs: ReadonlyMap<string, TermDefinition>,
|
||||
): string {
|
||||
if (!defs || defs.size === 0) return "";
|
||||
const lines = Array.from(defs.values()).map(
|
||||
(d) =>
|
||||
` <term word="${escapeXml(d.term)}" source="${escapeXml(d.sourceUrl)}">${escapeXml(d.definition)}</term>`,
|
||||
);
|
||||
return `<term_glossary>\n${lines.join("\n")}\n</term_glossary>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience: full pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GlossaryBlockOptions extends ExtractGlossaryOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot helper: extract terms from message contents, look up definitions,
|
||||
* and return the formatted `<term_glossary>` block ("" when disabled or no
|
||||
* definitions found). Safe to call on every batch — cached lookups make it
|
||||
* cheap.
|
||||
*/
|
||||
export async function buildTermGlossaryBlock(
|
||||
contents: string[],
|
||||
options: GlossaryBlockOptions = {},
|
||||
): Promise<string> {
|
||||
const enabled = options.enabled ?? config.AI_GLOSSARY_ENABLED;
|
||||
if (!enabled) return "";
|
||||
if (contents.length === 0) return "";
|
||||
|
||||
const terms = extractGlossaryTerms(contents, options);
|
||||
if (terms.length === 0) return "";
|
||||
|
||||
const defs = await lookupTermDefinitions(terms);
|
||||
if (defs.size === 0) return "";
|
||||
|
||||
const block = formatTermGlossary(defs);
|
||||
log.debug(
|
||||
{ terms: terms.length, definitions: defs.size },
|
||||
"Term glossary block built",
|
||||
);
|
||||
return block;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* termGlossaryStore.ts
|
||||
*
|
||||
* Permanent Postgres layer for the term glossary. Resolved definitions
|
||||
* (which carry content) are persisted here because they rarely change —
|
||||
* Redis/LRU only act as fast read caches in front of this table. Terms with
|
||||
* no definition (misses) are deliberately NOT persisted; they stay ephemeral
|
||||
* in Redis with a short TTL so transient lookup failures get retried.
|
||||
*
|
||||
* All calls are best-effort: any DB error degrades to a cache miss (the
|
||||
* glossary then falls through to Redis/live search as if the DB layer
|
||||
* didn't exist).
|
||||
*/
|
||||
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
||||
|
||||
const log = createChildLogger("term-glossary-store");
|
||||
|
||||
export interface StoredTermDefinition {
|
||||
definition: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a permanently stored definition for a term (lowercase key).
|
||||
* Returns null when missing or on any DB error (callers fall through).
|
||||
* A successful read bumps hit_count for observability (fire-and-forget).
|
||||
*/
|
||||
export async function getTermDefinitionFromDb(
|
||||
term: string,
|
||||
): Promise<StoredTermDefinition | null> {
|
||||
try {
|
||||
const row = await executeGet(
|
||||
`SELECT definition, source_url FROM term_glossary_cache WHERE term = $1`,
|
||||
[term.toLowerCase().trim()],
|
||||
);
|
||||
if (!row) return null;
|
||||
try {
|
||||
await executeAll(
|
||||
`UPDATE term_glossary_cache SET hit_count = hit_count + 1 WHERE term = $1`,
|
||||
[term.toLowerCase().trim()],
|
||||
);
|
||||
} catch {
|
||||
// hit_count is observability only — never fail a read for it
|
||||
}
|
||||
return {
|
||||
definition: row.definition as string,
|
||||
sourceUrl: (row.source_url as string | null) ?? "",
|
||||
};
|
||||
} catch (error) {
|
||||
log.debug(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"getTermDefinitionFromDb failed — falling back to live search",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a resolved definition permanently (UPSERT by term).
|
||||
* Only called for successful resolutions — never for misses.
|
||||
* Best-effort: a DB write failure does not affect the returned definition.
|
||||
*/
|
||||
export async function setTermDefinitionInDb(
|
||||
term: string,
|
||||
definition: string,
|
||||
sourceUrl: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await executeAll(
|
||||
`INSERT INTO term_glossary_cache (term, definition, source_url, resolved_at, hit_count)
|
||||
VALUES ($1, $2, $3, $4, 0)
|
||||
ON CONFLICT (term) DO UPDATE SET
|
||||
definition = EXCLUDED.definition,
|
||||
source_url = EXCLUDED.source_url,
|
||||
resolved_at = EXCLUDED.resolved_at`,
|
||||
[term.toLowerCase().trim(), definition, sourceUrl, Date.now()],
|
||||
);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"setTermDefinitionInDb failed — definition stays memory/Redis only",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
* the LLM for analysis. Extracted from moderationOrchestrator.ts.
|
||||
*/
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { delay } from "@/shared/utils/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { resizeImageForVision } from "../attachment-upload/imageResizer.js";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
MessageRecord,
|
||||
@@ -14,25 +16,32 @@ import type {
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import type { ModerationPromptContent, RetryState } from "./llmCaller.js";
|
||||
import { callModerationLLM } from "./llmCaller.js";
|
||||
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
||||
import {
|
||||
buildReferenceXml,
|
||||
buildUserProfileRef,
|
||||
buildUserProfilesBlock,
|
||||
escapeXml,
|
||||
formatReputationAttrs,
|
||||
getAnalysisContent,
|
||||
resolveDisplayName,
|
||||
resolveIsBot,
|
||||
resolveIsEdited,
|
||||
truncateForAi,
|
||||
} from "./moderationBuilders.js";
|
||||
import {
|
||||
buildSystemPrompt as buildSystemPromptModular,
|
||||
sanitizeAiContent,
|
||||
} from "./moderationPrompt.js";
|
||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||
import { logModerationAnalysis } from "./responseLogger.js";
|
||||
import {
|
||||
extractSearchQueries,
|
||||
formatSearchResults,
|
||||
searchSearxng,
|
||||
} from "./searxngSearch.js";
|
||||
import { buildTermGlossaryBlock } from "./termGlossary.js";
|
||||
import { getRecentCorrectedModerations } from "./textCacheStore.js";
|
||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||
import { getUserProfile } from "./userProfileStore.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
import type { MessageImagePart } from "./visionAnalyzer.js";
|
||||
|
||||
const log = createChildLogger("textBatchProcessor");
|
||||
|
||||
@@ -69,7 +78,7 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function runTextOnlyBatch(
|
||||
targets: MessageRecord[],
|
||||
contextText: string,
|
||||
contextBlock: string,
|
||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||
if (!targets.length) return { results: [], raw: null };
|
||||
|
||||
@@ -84,22 +93,33 @@ export async function runTextOnlyBatch(
|
||||
allUrls.add(url);
|
||||
}
|
||||
const urlArr = Array.from(allUrls).slice(0, 10);
|
||||
if (urlArr.length === 0) return new Map<string, string>();
|
||||
if (urlArr.length === 0) {
|
||||
return {
|
||||
text: new Map<string, string>(),
|
||||
image: new Map<string, { data: Buffer; mimeType: string }>(),
|
||||
title: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
urlArr.map((url) => fetchUrlSafely(url)),
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
const textMap = new Map<string, string>();
|
||||
const imageMap = new Map<string, { data: Buffer; mimeType: string }>();
|
||||
const titleMap = new Map<string, string>();
|
||||
for (let i = 0; i < urlArr.length; i++) {
|
||||
const r = results[i];
|
||||
if (
|
||||
r.status === "fulfilled" &&
|
||||
r.value.type === "text" &&
|
||||
r.value.textContent
|
||||
) {
|
||||
map.set(urlArr[i], r.value.textContent);
|
||||
if (r.status !== "fulfilled") continue;
|
||||
const v = r.value;
|
||||
if (v.type === "text" && v.textContent) {
|
||||
textMap.set(urlArr[i], v.textContent);
|
||||
if (v.title) titleMap.set(urlArr[i], v.title);
|
||||
} else if (v.type === "image" && v.data && v.mimeType) {
|
||||
// Direct image link (or og:image followed from an HTML page) —
|
||||
// kept for vision analysis below.
|
||||
imageMap.set(urlArr[i], { data: v.data, mimeType: v.mimeType });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
return { text: textMap, image: imageMap, title: titleMap };
|
||||
})();
|
||||
|
||||
const searxngPromise = (async () => {
|
||||
@@ -122,10 +142,19 @@ export async function runTextOnlyBatch(
|
||||
return map;
|
||||
})();
|
||||
|
||||
const [urlFetchMap, searxngResults] = await Promise.all([
|
||||
// Term glossary — per-word Wikipedia lookups for words the LLM may not
|
||||
// know (slang, jargon, regional language). Cached in Redis + in-memory, so
|
||||
// repeat terms resolve instantly and only genuinely new words hit SearXNG.
|
||||
const glossaryPromise = buildTermGlossaryBlock(
|
||||
targets.map((msg) => getAnalysisContent(msg)),
|
||||
).catch(() => "");
|
||||
|
||||
const [urlFetchMaps, searxngResults, glossaryBlock] = await Promise.all([
|
||||
urlFetchPromise,
|
||||
searxngPromise,
|
||||
glossaryPromise,
|
||||
]);
|
||||
const urlFetchMap = urlFetchMaps.text;
|
||||
|
||||
// Deduplicate identical short messages
|
||||
const shortContentGroups = new Map<string, MessageRecord[]>();
|
||||
@@ -171,25 +200,90 @@ export async function runTextOnlyBatch(
|
||||
const batch = subBatches[i];
|
||||
const targetIds = batch.map((t) => t.id);
|
||||
|
||||
// User reputation + profiles
|
||||
// User reputation + profiles (raw summary text — deduplicated into a
|
||||
// single <user_profiles> map per batch; messages only reference it).
|
||||
const userContexts = new Map<string, string>();
|
||||
const userProfiles = new Map<string, string>();
|
||||
const userProfiles = new Map<
|
||||
string,
|
||||
{
|
||||
text: string;
|
||||
asOf?: number | null;
|
||||
}
|
||||
>();
|
||||
for (const msg of batch) {
|
||||
if (!userContexts.has(msg.user_id)) {
|
||||
const rep = await initializeUserReputation(msg.user_id, msg.guild_id);
|
||||
userContexts.set(
|
||||
msg.user_id,
|
||||
`<user_reputation trust_score="${rep.trust_score}" />`,
|
||||
);
|
||||
const repAttrs = formatReputationAttrs(rep);
|
||||
const repXml = `<user_reputation ${repAttrs}/>`;
|
||||
userContexts.set(msg.user_id, repXml);
|
||||
}
|
||||
if (!userProfiles.has(msg.user_id)) {
|
||||
const profile = await getUserProfile(msg.user_id);
|
||||
userProfiles.set(
|
||||
msg.user_id,
|
||||
profile
|
||||
? `<user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>`
|
||||
: "",
|
||||
);
|
||||
userProfiles.set(msg.user_id, {
|
||||
text: profile?.profile_summary ?? "",
|
||||
asOf: profile?.last_analyzed_at ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
const userProfilesBlock = buildUserProfilesBlock(userProfiles);
|
||||
|
||||
// ── URL images → multimodal vision evidence ─────────────────────────
|
||||
// The text batch fetches inline URLs; whenever one resolved to an image
|
||||
// (direct image link, or og:image followed from an HTML page), run the
|
||||
// vision model and append its description as media evidence. If any
|
||||
// message in the sub-batch produced image evidence, the prompt switches
|
||||
// to "mixed" mode so media-analysis instructions/examples are injected
|
||||
// — a link to media is analyzed as media, not as bare text.
|
||||
const batchImageEvidence = new Map<string, string[]>();
|
||||
let batchHasImageEvidence = false;
|
||||
const urlImages = urlFetchMaps.image;
|
||||
const urlTitles = urlFetchMaps.title;
|
||||
if (urlImages.size > 0) {
|
||||
const maxDim = config.AI_LLM_IMAGE_MAX_DIMENSION ?? 1024;
|
||||
const evidenceSets = await Promise.all(
|
||||
batch.map(async (msg) => {
|
||||
const content = getAnalysisContent(msg);
|
||||
const pics = extractUrlsFromText(content)
|
||||
.slice(0, 3)
|
||||
.filter((url) => urlImages.has(url));
|
||||
if (pics.length === 0) return { id: msg.id, lines: [] as string[] };
|
||||
const lines = await Promise.all(
|
||||
pics.map(async (url) => {
|
||||
const img = urlImages.get(url);
|
||||
if (!img) return null;
|
||||
try {
|
||||
const { data: resizedBuffer, mimeType: resizedMime } =
|
||||
await resizeImageForVision(img.data, maxDim);
|
||||
const part: MessageImagePart = {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`,
|
||||
},
|
||||
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${msg.id}]`,
|
||||
};
|
||||
// Bound vision time so a dead vision model can't stall the
|
||||
// whole text batch — a timeout just skips the evidence.
|
||||
const timedOut = delay(15000).then(() => null as string | null);
|
||||
return await Promise.race([
|
||||
analyzeSingleMediaImage(msg.id, part),
|
||||
timedOut,
|
||||
]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: msg.id,
|
||||
lines: lines.filter((l): l is string => Boolean(l)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const set of evidenceSets) {
|
||||
if (set.lines.length > 0) {
|
||||
batchImageEvidence.set(set.id, set.lines);
|
||||
batchHasImageEvidence = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,8 +298,7 @@ export async function runTextOnlyBatch(
|
||||
: undefined;
|
||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||
const systemText = buildSystemPromptModular({
|
||||
contextText,
|
||||
mode: "text",
|
||||
mode: batchHasImageEvidence ? "mixed" : "text",
|
||||
correction,
|
||||
correctedExamples,
|
||||
channelCulture,
|
||||
@@ -214,38 +307,59 @@ export async function runTextOnlyBatch(
|
||||
const messagesBlock = (
|
||||
await Promise.all(
|
||||
batch.map(async (msg) => {
|
||||
const content = getAnalysisContent(msg);
|
||||
const content = truncateForAi(getAnalysisContent(msg));
|
||||
const msgUrls = extractUrlsFromText(content);
|
||||
const urlContexts = msgUrls
|
||||
.map((url) => {
|
||||
const ft = urlFetchMap.get(url);
|
||||
return ft
|
||||
? `<web_content url="${escapeXml(url)}">${escapeXml(ft)}</web_content>`
|
||||
: null;
|
||||
if (!ft) return null;
|
||||
const title = urlTitles.get(url);
|
||||
const titleAttr = title ? ` title="${escapeXml(title)}"` : "";
|
||||
return `<web_content url="${escapeXml(url)}"${titleAttr}>${escapeXml(ft)}</web_content>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const webContext = urlContexts ? `\n${urlContexts}` : "";
|
||||
const mediaEvidenceCtx = (batchImageEvidence.get(msg.id) ?? [])
|
||||
.map((line) => `\n${line}`)
|
||||
.join("");
|
||||
const userCtx = userContexts.get(msg.user_id) ?? "";
|
||||
const userProfileCtx = userProfiles.get(msg.user_id) ?? "";
|
||||
const userProfileRef = (
|
||||
userProfiles.get(msg.user_id)?.text ?? ""
|
||||
).trim()
|
||||
? buildUserProfileRef(msg.user_id)
|
||||
: "";
|
||||
const refXml = await buildReferenceXml(msg);
|
||||
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${userProfileCtx ? `\n ${userProfileCtx}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}\n</message>`;
|
||||
const repetitionCount = groupMapping.get(msg.id)?.length ?? 1;
|
||||
const isBot = resolveIsBot(msg);
|
||||
const isEdited = resolveIsEdited(msg);
|
||||
return `<message id="${escapeXml(msg.id)}" user="${escapeXml(resolveDisplayName(msg))}" time="${new Date(msg.created_at).toISOString()}"${repetitionCount > 1 ? ` repetitions="${repetitionCount}"` : ""}${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
|
||||
}),
|
||||
)
|
||||
).join("\n");
|
||||
|
||||
const searxngBlock =
|
||||
searxngResults.size > 0
|
||||
? `\n\n<web_searches>\n${Array.from(searxngResults.entries())
|
||||
? `<web_searches>\n${Array.from(searxngResults.entries())
|
||||
.map(
|
||||
([q, xml]) =>
|
||||
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
|
||||
)
|
||||
.join("\n")}\n</web_searches>`
|
||||
: "";
|
||||
// Data/instruction separation: the system prompt is stable per mode —
|
||||
// all per-batch context (profiles, conversation, web evidence) lives in
|
||||
// the USER payload, ordered oldest-first so targets come last.
|
||||
const userBlocks = [
|
||||
userProfilesBlock?.trimEnd() ?? "",
|
||||
contextBlock?.trimEnd() ?? "",
|
||||
searxngBlock,
|
||||
glossaryBlock,
|
||||
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||
].filter((b) => b.trim().length > 0);
|
||||
return {
|
||||
system: systemText,
|
||||
user: `${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
|
||||
user: userBlocks.join("\n\n"),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createChildLogger } from "@/shared/logger/index";
|
||||
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
||||
import { findBestEmbeddingMatch } from "./embeddingClient.js";
|
||||
import {
|
||||
deleteExpiredQdrantPoints,
|
||||
deleteQdrantPoint,
|
||||
deleteQdrantPointsByContentHash,
|
||||
isQdrantConfigured,
|
||||
@@ -62,13 +61,30 @@ export function makeCustomEmojiCacheKey(emojiId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic cache key for an image data URL.
|
||||
* Hashes the first 128 chars of the data URL (enough to identify the image
|
||||
* without storing the full base64 string as the key).
|
||||
* Generate a deterministic cache key for an image from its source URL
|
||||
* (Discord CDN / embed URL / inline URL).
|
||||
*
|
||||
* The CDN URL is the stable identity of an attachment: re-analysis of the
|
||||
* same message (recovery worker, retries) always hits the cache regardless
|
||||
* of resize/encoding output. Query params are stripped (Discord signed
|
||||
* tokens `?ex=&is=&hm=` and render variants `?format=&width=`) so the same
|
||||
* attachment resolves to the same key even when fetched with different
|
||||
* signatures or sizes.
|
||||
*
|
||||
* No SHA/phash — the CDN URL is the cache key itself. This makes
|
||||
* re-analysis of the SAME attachment cache-hit, while different attachments
|
||||
* (different URLs) never collide.
|
||||
*/
|
||||
export function makeImageCacheKey(dataUrl: string): string {
|
||||
const prefix = dataUrl.slice(0, 128);
|
||||
const hash = createHash("sha256").update(prefix).digest("hex").slice(0, 16);
|
||||
export function makeImageCacheKey(imageUrl: string): string {
|
||||
// Hash the URL to a fixed-length key. The raw Discord CDN URL is short,
|
||||
// but callers sometimes pass base64 data URLs (can be multi-MB) or very
|
||||
// long signed/external URLs. text_analysis_cache.text is the PK and lives
|
||||
// in a B-tree index with an 8191-byte per-row limit — inserting a long URL
|
||||
// as the key aborts the whole INSERT ("index row requires N bytes, maximum
|
||||
// size is 8191"), which fails acquireMediaAnalysisLock and silently skips
|
||||
// every media analysis. A 32-char sha256 keeps the key well under the limit
|
||||
// and is still deterministic (same attachment → same key).
|
||||
const hash = createHash("sha256").update(imageUrl).digest("hex").slice(0, 32);
|
||||
return `image:${hash}`;
|
||||
}
|
||||
|
||||
@@ -515,64 +531,6 @@ export async function setCachedTextModeration(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Perceptual hash helpers for image deduplication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a deterministic cache key for a perceptual hash.
|
||||
* The phash value is a string like "a1b2c3d4e5f6..." from the imghash library.
|
||||
*/
|
||||
export function makePhashCacheKey(phash: string): string {
|
||||
return `phash:${phash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a cached media analysis by perceptual hash.
|
||||
* Returns the cached analysis string or null if not found/expired.
|
||||
*/
|
||||
export async function getCachedMediaByPhash(
|
||||
phash: string,
|
||||
): Promise<string | null> {
|
||||
const cacheKey = makePhashCacheKey(phash);
|
||||
return getCachedMediaAnalysis(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a media analysis result keyed by perceptual hash.
|
||||
*/
|
||||
export async function upsertCachedMediaByPhash(
|
||||
phash: string,
|
||||
analysisResult: string,
|
||||
source: "vision_llm",
|
||||
expiresAt: number,
|
||||
): Promise<void> {
|
||||
const cacheKey = makePhashCacheKey(phash);
|
||||
return upsertCachedMediaAnalysis(cacheKey, analysisResult, source, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute perceptual hash from image buffer using imghash.
|
||||
* Returns a hexadecimal string representation of the hash.
|
||||
* Returns null if hashing fails (e.g., invalid image data).
|
||||
*/
|
||||
export async function computeImagePhash(
|
||||
buffer: Buffer,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Dynamic import — imghash is ESM with a default export containing { hash, hashRaw, ... }
|
||||
const imghashModule: {
|
||||
default?: { hash?: (buf: Buffer) => Promise<string> };
|
||||
} = await import("imghash");
|
||||
const hashFn = imghashModule.default?.hash;
|
||||
if (typeof hashFn !== "function") return null;
|
||||
const hash = await hashFn(buffer);
|
||||
return hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Corrected Moderation (false-positive) helpers for dynamic few-shot injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface FetchedUrlContext {
|
||||
data?: Buffer;
|
||||
mimeType?: string;
|
||||
textContent?: string;
|
||||
/** Page title from og:title / <title> — strong signal for the LLM. */
|
||||
title?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -86,6 +88,50 @@ function extractOgImage(html: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OgMeta {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
siteName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts OpenGraph / twitter meta + <title> from raw HTML. Both attribute
|
||||
* orders are accepted (<meta property=... content=...> and reversed).
|
||||
*/
|
||||
export function extractOgMeta(html: string): OgMeta {
|
||||
const metaValue = (name: string): string | null => {
|
||||
const re = new RegExp(
|
||||
`<meta[^>]*(?:property|name)=["']${name}["'][^>]*content=["']([^"']+)["']`,
|
||||
"i",
|
||||
);
|
||||
const m = html.match(re);
|
||||
if (m?.[1]) return m[1].replace(/&/g, "&").replace(/"/g, '"');
|
||||
const reRev = new RegExp(
|
||||
`<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["']${name}["']`,
|
||||
"i",
|
||||
);
|
||||
const mRev = html.match(reRev);
|
||||
return mRev?.[1]
|
||||
? mRev[1].replace(/&/g, "&").replace(/"/g, '"')
|
||||
: null;
|
||||
};
|
||||
|
||||
const title =
|
||||
metaValue("og:title") ||
|
||||
metaValue("twitter:title") ||
|
||||
html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
|
||||
null;
|
||||
const description =
|
||||
metaValue("og:description") ||
|
||||
metaValue("twitter:description") ||
|
||||
metaValue("description") ||
|
||||
null;
|
||||
const siteName =
|
||||
metaValue("og:site_name") || metaValue("application-name") || null;
|
||||
|
||||
return { title, description, siteName };
|
||||
}
|
||||
|
||||
function truncateAndCleanHtml(html: string, maxLen = 1000): string {
|
||||
// Strip <script> and <style> entirely
|
||||
let text = html.replace(
|
||||
@@ -176,6 +222,7 @@ export async function fetchUrlSafely(
|
||||
url,
|
||||
type: "text",
|
||||
textContent: cleaned,
|
||||
title: extractOgMeta(text).title ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,19 +16,45 @@ import type {
|
||||
import { llmVision } from "./llmClient.js";
|
||||
import {
|
||||
acquireMediaAnalysisLock,
|
||||
computeImagePhash,
|
||||
deleteCachedMediaAnalysis,
|
||||
FAILED_ANALYSIS_PREFIX,
|
||||
getCachedMediaAnalysis,
|
||||
getCachedMediaByPhash,
|
||||
inFlightVisionCalls,
|
||||
makeCustomEmojiCacheKey,
|
||||
makeImageCacheKey,
|
||||
makeStickerCacheKey,
|
||||
upsertCachedMediaAnalysis,
|
||||
upsertCachedMediaByPhash,
|
||||
visionLruCache,
|
||||
} from "./mediaCache.js";
|
||||
|
||||
/**
|
||||
* Detect vision outputs where the model claims it saw no image at all
|
||||
* ("Maaf, saya tidak melihat gambar apapun...", "Tidak ada gambar yang
|
||||
* terlampir...", "I cannot see any image..."). Such text is NOT a valid
|
||||
* analysis — caching it poisons the image cache for 24h (image/phash keys),
|
||||
* so every re-analysis of the same image returns the "no image" text and the
|
||||
* moderation LLM writes "lampiran gagal terbaca". These outputs must be
|
||||
* treated as failures: never cached, and ignored when read back from cache.
|
||||
*/
|
||||
export function isNoImageSeenText(text: string | null | undefined): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
return (
|
||||
/tidak (?:melihat|ada|terlihat) (?:gambar|foto|image)/i.test(lower) ||
|
||||
/tidak (?:ada )?(?:gambar|foto|image) (?:apapun|yang terlampir)/i.test(
|
||||
lower,
|
||||
) ||
|
||||
/gambar apapun/i.test(lower) ||
|
||||
/tanpa (?:input )?(?:visual|gambar|image)/i.test(lower) ||
|
||||
/\bno image (?:provided|attached|detected|found|was provided)?/i.test(
|
||||
lower,
|
||||
) ||
|
||||
/(?:cannot|can't) see (?:any |an |the )?image/i.test(lower) ||
|
||||
/i (?:do not|don't) (?:see|detect) (?:any |an |the )?image/i.test(lower) ||
|
||||
/there (?:is|are) no image/i.test(lower)
|
||||
);
|
||||
}
|
||||
|
||||
import {
|
||||
buildMediaCandidates,
|
||||
downloadAndExtractFrame,
|
||||
@@ -37,21 +63,27 @@ import {
|
||||
} from "./mediaDownloader.js";
|
||||
import {
|
||||
buildReferenceXml,
|
||||
buildUserProfileRef,
|
||||
escapeXml,
|
||||
formatReputationAttrs,
|
||||
getAnalysisContent,
|
||||
resolveDisplayName,
|
||||
resolveIsBot,
|
||||
resolveIsEdited,
|
||||
truncateForAi,
|
||||
} from "./moderationBuilders.js";
|
||||
import {
|
||||
buildCustomEmojiVisionPrompt,
|
||||
buildGeneralImageVisionPrompt,
|
||||
buildStickerTextOnlyWarning,
|
||||
buildStickerVisionPrompt,
|
||||
sanitizeAiContent,
|
||||
} from "./moderationPrompt.js";
|
||||
import {
|
||||
extractSearchQueries,
|
||||
formatSearchResults,
|
||||
searchSearxng,
|
||||
} from "./searxngSearch.js";
|
||||
import { buildTermGlossaryBlock } from "./termGlossary.js";
|
||||
import { extractUrlsFromText } from "./urlFetcher.js";
|
||||
import { getUserProfile } from "./userProfileStore.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
@@ -110,18 +142,35 @@ export const analyzeSingleMediaImage = async (
|
||||
|
||||
// Layer 0: LRU
|
||||
const lruCached = visionLruCache.get(cacheKey);
|
||||
if (lruCached) {
|
||||
if (lruCached && !isNoImageSeenText(lruCached)) {
|
||||
log.debug({ cacheKey }, "Vision LRU cache HIT (in-memory)");
|
||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${lruCached}`;
|
||||
}
|
||||
if (lruCached) {
|
||||
// Poisoned entry ("I see no image") — drop it and re-analyze.
|
||||
log.warn({ cacheKey }, "Vision LRU cache HIT was no-image-seen — dropping");
|
||||
visionLruCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// Layer 1: DB
|
||||
const cached = await getCachedMediaAnalysis(cacheKey);
|
||||
if (cached) {
|
||||
if (cached && !isNoImageSeenText(cached)) {
|
||||
visionLruCache.set(cacheKey, cached);
|
||||
log.debug({ cacheKey }, "Media analysis cache HIT (DB → LRU)");
|
||||
log.debug(
|
||||
{ cacheKey, messageId, cachedLen: cached.length },
|
||||
"Media analysis cache HIT (DB → LRU)",
|
||||
);
|
||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`;
|
||||
}
|
||||
if (cached) {
|
||||
// Poisoned DB entry — purge it so later messages re-analyze.
|
||||
log.warn(
|
||||
{ cacheKey },
|
||||
"Media analysis cache HIT was no-image-seen — purging",
|
||||
);
|
||||
await deleteCachedMediaAnalysis(cacheKey).catch(() => {});
|
||||
visionLruCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
// In-flight dedupe
|
||||
const existing = inFlightVisionCalls.get(cacheKey);
|
||||
@@ -154,39 +203,19 @@ export const analyzeSingleMediaImage = async (
|
||||
return FAILED_ANALYSIS_PREFIX;
|
||||
}
|
||||
|
||||
// phash check
|
||||
let phash: string | null = null;
|
||||
if (image.image_url.url.startsWith("data:")) {
|
||||
try {
|
||||
const base64Data = image.image_url.url.split(",")[1];
|
||||
if (base64Data) {
|
||||
const imgBuffer = Buffer.from(base64Data, "base64");
|
||||
phash = await computeImagePhash(imgBuffer);
|
||||
if (phash) {
|
||||
const phashCached = await getCachedMediaByPhash(phash);
|
||||
if (phashCached) {
|
||||
visionLruCache.set(cacheKey, phashCached);
|
||||
await upsertCachedMediaAnalysis(
|
||||
cacheKey,
|
||||
phashCached,
|
||||
"vision_llm",
|
||||
Date.now() + 24 * 60 * 60 * 1000,
|
||||
).catch(() => {});
|
||||
return phashCached;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
phash = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Vision API call
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const content = await llmVision(promptText, image.image_url);
|
||||
if (content) {
|
||||
if (content && !isNoImageSeenText(content)) {
|
||||
// Defensive: log when a vision analysis is cached so we can trace
|
||||
// if the SAME analysis text is being stored for DIFFERENT cache keys
|
||||
// (which would indicate the vision model is returning duplicates).
|
||||
log.debug(
|
||||
{ cacheKey, messageId, contentLen: content.length },
|
||||
"Vision analysis cached (new entry)",
|
||||
);
|
||||
await upsertCachedMediaAnalysis(
|
||||
cacheKey,
|
||||
content,
|
||||
@@ -194,17 +223,18 @@ export const analyzeSingleMediaImage = async (
|
||||
Date.now() + 24 * 60 * 60 * 1000,
|
||||
);
|
||||
visionLruCache.set(cacheKey, content);
|
||||
if (phash) {
|
||||
upsertCachedMediaByPhash(
|
||||
phash,
|
||||
content,
|
||||
"vision_llm",
|
||||
Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||
).catch(() => {});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
log.warn({ messageId }, "Vision API null response");
|
||||
if (content) {
|
||||
// Model claims it saw no image — same as a null response: NOT a
|
||||
// valid analysis, and caching it would poison the cache key.
|
||||
log.warn(
|
||||
{ messageId, cacheKey },
|
||||
"Vision returned no-image-seen text — not caching",
|
||||
);
|
||||
} else {
|
||||
log.warn({ messageId }, "Vision API null response");
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
@@ -231,6 +261,7 @@ export const analyzeSingleMediaImage = async (
|
||||
"Vision failed after 3 attempts",
|
||||
);
|
||||
await deleteCachedMediaAnalysis(cacheKey).catch(() => {});
|
||||
visionLruCache.delete(cacheKey);
|
||||
return FAILED_ANALYSIS_PREFIX;
|
||||
})();
|
||||
|
||||
@@ -344,6 +375,12 @@ export async function prepareMediaMessage(
|
||||
searxngXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
|
||||
}
|
||||
|
||||
// Term glossary — cached per-word Wikipedia definitions for words the LLM
|
||||
// may not know. Bounded and cached (in-memory + Redis), so this adds no
|
||||
// meaningful latency to the media path either.
|
||||
const glossaryXml = await buildTermGlossaryBlock([content]).catch(() => "");
|
||||
const glossaryCtx = glossaryXml ? `\n${glossaryXml}` : "";
|
||||
|
||||
// Build XML block
|
||||
const webTexts = webTextMap.get(targetId) ?? [];
|
||||
const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? [];
|
||||
@@ -366,7 +403,19 @@ export async function prepareMediaMessage(
|
||||
const rep = await initializeUserReputation(target.user_id, target.guild_id);
|
||||
const profile = await getUserProfile(target.user_id);
|
||||
const refXml = await buildReferenceXml(target);
|
||||
// Profile is emitted ONCE per batch in a <user_profiles> map (see
|
||||
// mediaBatchProcessor); here we only reference it to avoid repeating the
|
||||
// full summary on every message of the same user.
|
||||
const profileRef = profile?.profile_summary?.trim()
|
||||
? buildUserProfileRef(target.user_id)
|
||||
: "";
|
||||
|
||||
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(target.username)}">\n <user_reputation trust_score="${rep.trust_score}" />${profile ? `\n <user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
|
||||
// Rich reputation — attrs only, no user history injection (per channel context preference)
|
||||
const repAttrs = formatReputationAttrs(rep);
|
||||
const repXml = `<user_reputation ${repAttrs}/>`;
|
||||
|
||||
const isBot = resolveIsBot(target);
|
||||
const isEdited = resolveIsEdited(target);
|
||||
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${repXml}${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}${glossaryCtx}\n</message>`;
|
||||
return { targetId, messageBlock };
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import { createChildLogger } from "@/shared/logger/index";
|
||||
const log = createChildLogger("imageResizer");
|
||||
|
||||
/**
|
||||
* Prepare an image buffer for optimal vision LLM analysis.
|
||||
* Prepare an image buffer for vision LLM analysis.
|
||||
*
|
||||
* - Resizes to maxDim x maxDim maintaining aspect ratio (only if larger)
|
||||
* - Converts to PNG (lossless) to preserve full image detail
|
||||
* - Resizes to maxDim x maxDim maintaining aspect ratio WITHOUT upscaling
|
||||
* (small images such as stickers/emojis are passed through at original size,
|
||||
* just re-encoded)
|
||||
* - Encodes as JPEG (lossy, quality ~85). Photos compress VERY poorly in
|
||||
* lossless PNG — a 1024px Facebook photo balloons to multi-MB PNG base64 that
|
||||
* the vision model silently rejects (empty response → "Vision API null").
|
||||
* JPEG keeps the same photo at ~100–400KB, which the model processes fine and
|
||||
* stays well below request/token size limits.
|
||||
|
||||
* - Falls back to original buffer if sharp fails
|
||||
*
|
||||
* @param buf - Raw image buffer
|
||||
@@ -20,22 +27,17 @@ export async function resizeImageForVision(
|
||||
): Promise<{ data: Buffer; mimeType: string }> {
|
||||
try {
|
||||
const metadata = await sharp(buf).metadata();
|
||||
const inputFormat = metadata.format ?? "jpeg";
|
||||
|
||||
// Skip resize entirely if already within max dimension
|
||||
if ((metadata.width ?? 0) <= maxDim && (metadata.height ?? 0) <= maxDim) {
|
||||
return { data: buf, mimeType: `image/${inputFormat}` };
|
||||
}
|
||||
|
||||
// Resize dimension only — convert to PNG lossless to preserve detail
|
||||
// Always (re-)encode to JPEG and fit inside maxDim without upscaling.
|
||||
// Skipping the encode for already-small images left raw originals in
|
||||
// their native (often lossless PNG or full-quality) form, which could
|
||||
// still bloat data URLs and trip the vision model's size limit.
|
||||
const resized = await sharp(buf)
|
||||
.resize(maxDim, maxDim, {
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.png()
|
||||
.resize(maxDim, maxDim, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
|
||||
const inputFormat = metadata.format ?? "jpeg";
|
||||
log.debug(
|
||||
{
|
||||
originalSize: buf.length,
|
||||
@@ -45,10 +47,10 @@ export async function resizeImageForVision(
|
||||
((buf.length - resized.length) / buf.length) * 100,
|
||||
),
|
||||
},
|
||||
"Image resized for vision analysis (lossless PNG)",
|
||||
"Image resized for vision analysis (JPEG)",
|
||||
);
|
||||
|
||||
return { data: resized, mimeType: "image/png" };
|
||||
return { data: resized, mimeType: "image/jpeg" };
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
|
||||
@@ -83,12 +83,7 @@ export class CommandHandler {
|
||||
|
||||
// Create domain-specific handlers with their dependencies
|
||||
this.voiceHandler = new VoiceHandler(client, voiceController);
|
||||
this.mediaHandler = new MediaHandler(client, () =>
|
||||
voiceController.getStatus(),
|
||||
);
|
||||
// Give media handler access to disconnect/reconnect voice around screen
|
||||
// share (GoLive needs its own WebRTC connection).
|
||||
this.mediaHandler.setVoiceController(() => voiceController);
|
||||
this.mediaHandler = new MediaHandler();
|
||||
this.guildHandler = new GuildHandler(client);
|
||||
this.moderationHandler = new ModerationHandler(client);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
COMMAND_GUILDS_LIST,
|
||||
COMMAND_GUILDS_TEXT_CHANNELS,
|
||||
COMMAND_MEDIA_LOOP,
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
@@ -69,6 +70,7 @@ export function createHandlerRegistry(
|
||||
registry.set(COMMAND_MEDIA_VOLUME, (cmd) =>
|
||||
mediaHandler.handleMediaVolume(cmd),
|
||||
);
|
||||
registry.set(COMMAND_MEDIA_LOOP, (cmd) => mediaHandler.handleMediaLoop(cmd));
|
||||
|
||||
// Guild commands
|
||||
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import {
|
||||
extractMediaInfo,
|
||||
resolveMediaUrl,
|
||||
transcodeToHighQualityOgg,
|
||||
} from "../voice-recording/mediaSource.js";
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaQueueItem,
|
||||
} from "../voice-recording/mediaTypes.js";
|
||||
import { discordPlayer } from "../voice-recording/player.js";
|
||||
import {
|
||||
ScreenShareController,
|
||||
type ScreenShareVoiceStatus,
|
||||
} from "../voice-recording/screenShareController.js";
|
||||
import { setMediaStatusKey } from "./mediaStatusSink.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,6 +31,7 @@ export interface MediaStatusPayload {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaStatusItem | null;
|
||||
queue: MediaStatusItem[];
|
||||
}
|
||||
@@ -45,6 +42,9 @@ export interface MediaStatusPayload {
|
||||
|
||||
const mediaQueue: MediaQueueItem[] = [];
|
||||
let currentTrackItem: MediaQueueItem | null = null;
|
||||
let loopEnabled = false;
|
||||
/** Active ffmpeg transcode (killed on stop/skip). */
|
||||
let currentTranscodeCleanup: (() => void) | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -67,6 +67,7 @@ function buildStatusPayload(): MediaStatusPayload {
|
||||
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
|
||||
activeMode: currentTrackItem?.mode ?? null,
|
||||
musicVolume: discordPlayer.getMusicVolume(),
|
||||
loop: loopEnabled,
|
||||
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
|
||||
queue: mediaQueue.map(mapToStatusItem),
|
||||
};
|
||||
@@ -78,17 +79,8 @@ function buildStatusPayload(): MediaStatusPayload {
|
||||
|
||||
export class MediaHandler {
|
||||
private logger = createChildLogger("media-handler");
|
||||
private screenController: ScreenShareController | null = null;
|
||||
private screenPlayback: { stop(): void } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: Client | null = null,
|
||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
}),
|
||||
) {
|
||||
constructor() {
|
||||
// Register auto-advance on natural track end. advanceQueue mutates the
|
||||
// module-level currentTrackItem/queue, so we must re-publish the status
|
||||
// key afterward: otherwise the backend's Redis `media:status` cache (and
|
||||
@@ -102,31 +94,11 @@ export class MediaHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give MediaHandler access to the VoiceController so screen-share can
|
||||
* disconnect/reconnect the @discordjs audio connection around a GoLive
|
||||
* stream (Discord allows only one voice session per user).
|
||||
*/
|
||||
private voiceControllerAccessor:
|
||||
| (() => {
|
||||
disconnectGuild(guildId: string): Promise<void>;
|
||||
connect(guildId: string, channelId: string): Promise<unknown>;
|
||||
getStatus(): {
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
};
|
||||
})
|
||||
| null = null;
|
||||
|
||||
setVoiceController(accessor: typeof this.voiceControllerAccessor): void {
|
||||
this.voiceControllerAccessor = accessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the latest media state to Redis so the backend/frontend see queue
|
||||
* advances that happen outside a command (natural track end, screen-share
|
||||
* done). CommandHandler owns the Redis status-key writes for command-triggered
|
||||
* changes; this covers the side-effect-only path.
|
||||
* advances that happen outside a command (natural track end). CommandHandler
|
||||
* owns the Redis status-key writes for command-triggered changes; this
|
||||
* covers the side-effect-only path.
|
||||
*/
|
||||
private publishStatus(): void {
|
||||
try {
|
||||
@@ -146,7 +118,6 @@ export class MediaHandler {
|
||||
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
|
||||
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
|
||||
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
|
||||
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
|
||||
|
||||
if (!url) {
|
||||
@@ -169,74 +140,6 @@ export class MediaHandler {
|
||||
};
|
||||
}
|
||||
|
||||
// Screen share (GoLive) path — bypasses the audio queue entirely.
|
||||
if (mode === "screen") {
|
||||
try {
|
||||
if (!this.client) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Gateway client not initialized",
|
||||
};
|
||||
}
|
||||
if (!this.screenController) {
|
||||
this.screenController = new ScreenShareController(
|
||||
this.client,
|
||||
this.getVoiceStatus,
|
||||
// releaseVoice — disconnect the @discordjs/voice connection so the
|
||||
// dank074 Streamer can take over (Discord: one voice session/user).
|
||||
async (status) => {
|
||||
const vc = this.voiceControllerAccessor?.();
|
||||
const guildId = status.activeGuildId ?? null;
|
||||
if (vc && guildId) {
|
||||
await vc.disconnectGuild(guildId);
|
||||
}
|
||||
},
|
||||
// restoreVoice — reconnect the @discordjs audio connection after
|
||||
// the stream ends so mic/listen keep working.
|
||||
async (status) => {
|
||||
const vc = this.voiceControllerAccessor?.();
|
||||
if (vc && status.activeGuildId && status.activeChannelId) {
|
||||
await vc.connect(status.activeGuildId, status.activeChannelId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
const playback = await this.screenController.start(url);
|
||||
this.screenPlayback = playback;
|
||||
currentTrackItem = {
|
||||
id: randomUUID(),
|
||||
source: url,
|
||||
title: url,
|
||||
kind: "url",
|
||||
mode: "screen",
|
||||
requestedBy,
|
||||
addedAt: Date.now(),
|
||||
status: "playing",
|
||||
};
|
||||
playback.done
|
||||
.catch((err) => {
|
||||
this.logger.error(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Screen playback promise rejected",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
this.screenPlayback = null;
|
||||
if (currentTrackItem?.mode === "screen") {
|
||||
currentTrackItem = null;
|
||||
}
|
||||
});
|
||||
this.logger.info({ url }, "Screen share started");
|
||||
return { id: cmd.id, success: true, data: buildStatusPayload() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error({ error: message }, "Screen share failed to start");
|
||||
return { id: cmd.id, success: false, data: null, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight metadata fetch for display — the full resolve happens in playNext
|
||||
let title: string = url;
|
||||
let duration: number | undefined;
|
||||
@@ -258,7 +161,7 @@ export class MediaHandler {
|
||||
source: url,
|
||||
title,
|
||||
kind: "url" as const,
|
||||
mode,
|
||||
mode: "music",
|
||||
requestedBy,
|
||||
addedAt: Date.now(),
|
||||
status: "queued",
|
||||
@@ -302,12 +205,6 @@ export class MediaHandler {
|
||||
}
|
||||
|
||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
// Stop screen share if active — playback.done.finally clears the item.
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
if (currentTrackItem?.mode === "screen") {
|
||||
currentTrackItem = null;
|
||||
}
|
||||
discordPlayer.stop("music");
|
||||
currentTrackItem = null;
|
||||
mediaQueue.length = 0; // Clear entire queue
|
||||
@@ -336,6 +233,17 @@ export class MediaHandler {
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaLoop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
loopEnabled = Boolean(cmd.payload.loop);
|
||||
this.logger.info({ loop: loopEnabled }, "Media loop toggled");
|
||||
this.publishStatus();
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: buildStatusPayload(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -350,6 +258,8 @@ export class MediaHandler {
|
||||
discordPlayer.stop("music");
|
||||
currentTrackItem = null;
|
||||
}
|
||||
currentTranscodeCleanup?.();
|
||||
currentTranscodeCleanup = null;
|
||||
|
||||
const next = mediaQueue.shift();
|
||||
if (!next) {
|
||||
@@ -372,11 +282,20 @@ export class MediaHandler {
|
||||
next.title = resolution.title ?? next.title;
|
||||
next.duration = resolution.duration ?? next.duration;
|
||||
|
||||
discordPlayer.playStream(resolution.stream, "music", {
|
||||
inputType: StreamType.Arbitrary,
|
||||
inlineVolume: true,
|
||||
volume: discordPlayer.getMusicVolume(),
|
||||
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
|
||||
// 192kbps) with volume baked into the encode. This avoids the double
|
||||
// lossy encode that inlineVolume would cause and gives Discord the
|
||||
// cleanest possible stream.
|
||||
const transcoded = transcodeToHighQualityOgg(
|
||||
resolution.stream,
|
||||
discordPlayer.getMusicVolume(),
|
||||
);
|
||||
|
||||
discordPlayer.playStream(transcoded.stream, "music", {
|
||||
inputType: StreamType.OggOpus,
|
||||
inlineVolume: false,
|
||||
});
|
||||
currentTranscodeCleanup = transcoded.cleanup;
|
||||
|
||||
this.logger.info({ title: next.title }, "Playback started");
|
||||
} catch (err) {
|
||||
@@ -403,10 +322,21 @@ export class MediaHandler {
|
||||
|
||||
/**
|
||||
* Called by the idle callback — delegates to playNext since the player is
|
||||
* already idle and currentTrackItem is already null.
|
||||
* already idle and currentTrackItem is already null. When loop mode is
|
||||
* enabled and a music track ended naturally, requeue it so it plays again.
|
||||
*/
|
||||
private async advanceQueue(): Promise<void> {
|
||||
const finished = currentTrackItem;
|
||||
currentTrackItem = null;
|
||||
|
||||
if (loopEnabled && finished && finished.mode === "music") {
|
||||
mediaQueue.unshift(finished);
|
||||
this.logger.info(
|
||||
{ title: finished.title },
|
||||
"Loop enabled — replaying finished track",
|
||||
);
|
||||
}
|
||||
|
||||
await this.playNext();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
incrementCounter,
|
||||
registerCollector,
|
||||
setGauge,
|
||||
startMetricsServer,
|
||||
stopMetricsServer,
|
||||
|
||||
@@ -15,7 +15,11 @@ interface Metric {
|
||||
|
||||
const metrics = new Map<string, Metric>();
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
// Collectors run on every scrape so gauges reflect live pipeline state
|
||||
// without callers having to push updates on every event.
|
||||
const collectors: Array<() => void> = [];
|
||||
|
||||
const startTs = Date.now();
|
||||
|
||||
function key(name: string, labels?: Record<string, string>): string {
|
||||
if (!labels) return name;
|
||||
@@ -26,7 +30,9 @@ function key(name: string, labels?: Record<string, string>): string {
|
||||
return `${name}{${labelStr}}`;
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────
|
||||
export function registerCollector(fn: () => void): void {
|
||||
collectors.push(fn);
|
||||
}
|
||||
|
||||
export function incrementCounter(
|
||||
name: string,
|
||||
@@ -65,13 +71,31 @@ export function setGauge(
|
||||
}
|
||||
}
|
||||
|
||||
// Process-level static/derived gauges, refreshed each scrape.
|
||||
registerCollector(() => {
|
||||
const uptimeSec = Math.floor((Date.now() - startTs) / 1000);
|
||||
setGauge("process_uptime_seconds", uptimeSec);
|
||||
const mem = process.memoryUsage();
|
||||
setGauge("process_resident_bytes", mem.rss);
|
||||
setGauge("process_heap_used_bytes", mem.heapUsed);
|
||||
setGauge("process_heap_total_bytes", mem.heapTotal);
|
||||
setGauge("process_event_loop_lag_ms", 0);
|
||||
});
|
||||
|
||||
// ─── HTTP Server ─────────────────────────────────────────────────────────
|
||||
|
||||
let server: http.Server | null = null;
|
||||
|
||||
function formatMetrics(): string {
|
||||
const lines: string[] = [];
|
||||
for (const c of collectors) {
|
||||
try {
|
||||
c();
|
||||
} catch (err) {
|
||||
logger.warn({ error: String(err) }, "Metrics collector failed");
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const [fullName, metric] of metrics) {
|
||||
const baseName = fullName.includes("{")
|
||||
? fullName.slice(0, fullName.indexOf("{"))
|
||||
@@ -80,7 +104,6 @@ function formatMetrics(): string {
|
||||
lines.push(`# TYPE ${baseName} ${metric.type}`);
|
||||
lines.push(`${fullName} ${metric.value}`);
|
||||
}
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface MessageLocationInput {
|
||||
}
|
||||
|
||||
const EXCLUDED_CHANNEL_IDS = new Set(config.EXCLUDED_CHANNEL_IDS);
|
||||
const BOT_EXCLUDED_CHANNEL_IDS = new Set(config.BOT_EXCLUDED_CHANNEL_IDS);
|
||||
|
||||
function isBotExcludedChannel(message: Message): boolean {
|
||||
if (!message.author?.bot) return false;
|
||||
const id = getParentChannelId(message) ?? message.channelId;
|
||||
return id != null && BOT_EXCLUDED_CHANNEL_IDS.has(id);
|
||||
}
|
||||
const EXCLUDED_THREAD_IDS = new Set(config.EXCLUDED_THREAD_IDS);
|
||||
|
||||
function isExcludedThread(message: {
|
||||
@@ -274,7 +281,7 @@ export function registerMessageCapture(client: Client): void {
|
||||
|
||||
client.on("messageCreate", async (message) => {
|
||||
if (!shouldCaptureForAnyTarget(message, targets)) return;
|
||||
if (message.author?.bot) return;
|
||||
if (isBotExcludedChannel(message)) return;
|
||||
if (isAgeRestrictedMessage(message)) return;
|
||||
if (isExcludedThread(message)) return;
|
||||
|
||||
@@ -293,7 +300,7 @@ export function registerMessageCapture(client: Client): void {
|
||||
|
||||
client.on("messageUpdate", async (_oldMessage, newMessage) => {
|
||||
if (!shouldCaptureForAnyTarget(newMessage, targets)) return;
|
||||
if (newMessage.author?.bot) return;
|
||||
if (isBotExcludedChannel(newMessage as Message)) return;
|
||||
if (isAgeRestrictedMessage(newMessage as Message)) return;
|
||||
if (isExcludedThread(newMessage)) return;
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface MessageLocation {
|
||||
threadId: string | null;
|
||||
threadName: string | null;
|
||||
channelName: string | null;
|
||||
/** Channel topic (resmi/deskripsi channel) — strong context for judging
|
||||
* whether a message fits the channel's purpose. Guarded: some channel
|
||||
* types (threads on older API builds) expose no topic. */
|
||||
topic?: string | null;
|
||||
nsfw?: boolean;
|
||||
nsfwLevel?: string | null;
|
||||
ageRestricted?: boolean;
|
||||
@@ -107,12 +111,17 @@ export function getMessageLocation(message: Message): MessageLocation {
|
||||
nsfw?: boolean;
|
||||
nsfwLevel?: string | null;
|
||||
};
|
||||
const topic =
|
||||
"topic" in channel && typeof channel.topic === "string"
|
||||
? channel.topic
|
||||
: null;
|
||||
if (!channel.isThread?.()) {
|
||||
return {
|
||||
channelId: message.channelId,
|
||||
threadId: null,
|
||||
threadName: null,
|
||||
channelName: "name" in channel ? channel.name : null,
|
||||
topic,
|
||||
nsfw:
|
||||
typeof safetyChannel.nsfw === "boolean"
|
||||
? safetyChannel.nsfw
|
||||
@@ -133,6 +142,7 @@ export function getMessageLocation(message: Message): MessageLocation {
|
||||
threadId: channel.id,
|
||||
threadName: channel.name,
|
||||
channelName: channel.parent?.name ?? null,
|
||||
topic,
|
||||
nsfw:
|
||||
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
|
||||
nsfwLevel:
|
||||
@@ -505,7 +515,7 @@ export function renderDiscordMentions(
|
||||
content: string,
|
||||
metadata: string | null | undefined,
|
||||
): string {
|
||||
if (!content || !content.includes("<")) return content;
|
||||
if (!content?.includes("<")) return content;
|
||||
const parsed = parseRichMessageMetadata(metadata);
|
||||
const roleName = new Map(
|
||||
(parsed?.mentionedRoles ?? []).map((r) => [r.id, r.name] as const),
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface AIAnalysisUpdate {
|
||||
confidence?: number | null;
|
||||
recommendedAction?: MessageRecord["ai_recommended_action"] | null;
|
||||
analyzedAt?: number | null;
|
||||
/** Wall-clock time the AI analysis (LLM call) took, in milliseconds. */
|
||||
analysisDurationMs?: number | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
@@ -42,6 +44,7 @@ function buildAIAnalysisSet(result: AIAnalysisUpdate, now?: number) {
|
||||
ai_confidence: result.confidence ?? result.score ?? null,
|
||||
ai_recommended_action: result.recommendedAction ?? null,
|
||||
ai_analyzed_at: result.analyzedAt ?? now ?? Date.now(),
|
||||
ai_analysis_duration_ms: result.analysisDurationMs ?? null,
|
||||
ai_error: result.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray, type SQL, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, type SQL } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type * as schema from "../../shared/database/schema.js";
|
||||
import { moderationActionsTable } from "../../shared/database/schema.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { createChildLogger, type Logger } from "@/shared/logger/index";
|
||||
import type * as schema from "../../shared/database/schema.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray, type SQL, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, type SQL } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import type * as schema from "../../shared/database/schema.js";
|
||||
import { messageReviewsTable } from "../../shared/database/schema.js";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
@@ -33,6 +36,87 @@ export interface ResolveOptions {
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
export interface TranscodeResult {
|
||||
stream: Readable;
|
||||
/** Kill the ffmpeg child (used on stop/skip). */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode a source stream to high-quality OggOpus (48kHz stereo, 192kbps).
|
||||
*
|
||||
* Discord voice downmixes whatever we feed it to the channel's bitrate, so the
|
||||
* best we can do is hand it a clean 48kHz stereo Opus stream instead of the
|
||||
* raw source (which may be mono, low-bitrate, or a non-Opus container). The
|
||||
* volume is baked into the encode with `-af volume=` so the player does not
|
||||
* need inlineVolume re-encoding (double lossy encode).
|
||||
*/
|
||||
export function transcodeToHighQualityOgg(
|
||||
input: Readable,
|
||||
volume: number,
|
||||
): TranscodeResult {
|
||||
const proc = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vn",
|
||||
"-ac",
|
||||
"2",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"-af",
|
||||
`volume=${volume}`,
|
||||
"-f",
|
||||
"ogg",
|
||||
"pipe:1",
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "ignore"] },
|
||||
);
|
||||
|
||||
input.pipe(proc.stdin);
|
||||
// ffmpeg teardown closes stdin while the upstream source may still write —
|
||||
// swallow EPIPE / destroyed-stream errors so they don't crash the gateway.
|
||||
proc.stdin.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (
|
||||
err.code === "EPIPE" ||
|
||||
err.code === "ERR_STREAM_DESTROYED" ||
|
||||
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||
) {
|
||||
logger.debug(
|
||||
{ code: err.code },
|
||||
"Transcode stdin closed during teardown",
|
||||
);
|
||||
} else {
|
||||
logger.error({ error: err.message }, "Transcode stdin error");
|
||||
}
|
||||
});
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const cleanup = () => {
|
||||
activeProcesses.delete(proc);
|
||||
if (proc.exitCode === null) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
proc.once("exit", () => activeProcesses.delete(proc));
|
||||
|
||||
// If ffmpeg fails, surface the error to the consumer stream so the
|
||||
// AudioPlayer's error handler can advance the queue.
|
||||
const output = proc.stdout;
|
||||
output.on("error", () => cleanup());
|
||||
|
||||
return { stream: output, cleanup };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal state
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -133,6 +217,85 @@ function buildNotInstalledError(): Error {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the yt-dlp --cookies args. YouTube blocks anonymous embeds with a
|
||||
* "Sign in to confirm you're not a bot" 403 unless yt-dlp is given a logged-
|
||||
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
|
||||
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
|
||||
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
|
||||
* may 403 — playback will fail gracefully, not crash).
|
||||
*/
|
||||
var _cachedCookiePath: string | null = null;
|
||||
function buildCookieArgs(): string[] {
|
||||
// Single source of truth: BWS injects the account cookies via env
|
||||
// (gmw_yt_downloader_cookies → GMW_YT_DOWNLOADER_COOKIES by bws-exec).
|
||||
// We materialize them to a temp Netscape file because yt-dlp --cookies
|
||||
// only accepts a file path, not stdin, and multiline env values are not
|
||||
// reliable to pass directly on the spawn argv. Falls back to the on-disk
|
||||
// file at GMW_YT_COOKIES_PATH (or /etc/gmw-discord-gateway/ytcookies.txt)
|
||||
// which the Nix deploy writes from BWS once at start.
|
||||
if (_cachedCookiePath) return ["--cookies", _cachedCookiePath];
|
||||
const envCookies = process.env.GMW_YT_DOWNLOADER_COOKIES?.trim();
|
||||
if (envCookies?.includes("LOGIN_INFO")) {
|
||||
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
|
||||
writeFileSync(fdPath, envCookies);
|
||||
try {
|
||||
chmodSync(fdPath, 0o600);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
_cachedCookiePath = fdPath;
|
||||
logger.info(
|
||||
{ cookiePath: fdPath, source: "GMW_YT_DOWNLOADER_COOKIES env" },
|
||||
"Using YouTube cookies (from BWS env)",
|
||||
);
|
||||
return ["--cookies", fdPath];
|
||||
}
|
||||
const cookiePath =
|
||||
process.env.GMW_YT_COOKIES_PATH ?? "/etc/gmw-discord-gateway/ytcookies.txt";
|
||||
try {
|
||||
if (cookiePath && existsSync(cookiePath)) {
|
||||
// Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites
|
||||
// the cookie file on close (`--cookies` implies write-back). The system
|
||||
// file is owned by another user (root/deploy) and the service user
|
||||
// cannot write it → PermissionError → yt-dlp exits 1 → playback
|
||||
// fails for every attempt. Copy to a per-run temp file (like the env
|
||||
// branch above) so write-back lands somewhere we own; if the original
|
||||
// is not readable we fall back to anonymous (YouTube may 403 → the
|
||||
// screen-share controller retries via Invidious mirrors without auth).
|
||||
let cookieContents: string;
|
||||
try {
|
||||
cookieContents = readFileSync(cookiePath, "utf8");
|
||||
} catch {
|
||||
logger.warn(
|
||||
{ cookiePath },
|
||||
"Cookie file not readable; continuing without cookies (anon)",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
|
||||
writeFileSync(fdPath, cookieContents);
|
||||
try {
|
||||
chmodSync(fdPath, 0o600);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
_cachedCookiePath = fdPath;
|
||||
logger.info(
|
||||
{ cookiePath: fdPath, source: "on-disk file (copied)" },
|
||||
"Using YouTube cookies for yt-dlp",
|
||||
);
|
||||
return ["--cookies", fdPath];
|
||||
}
|
||||
} catch {
|
||||
/* ignore — fallback to anon */
|
||||
}
|
||||
logger.warn(
|
||||
"No YouTube cookies available; yt-dlp will use anonymous (YouTube may 403)",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -157,6 +320,7 @@ export function resolveMediaUrl(
|
||||
): Promise<MediaSourceResolution> {
|
||||
return new Promise<MediaSourceResolution>((resolve, reject) => {
|
||||
const format = options?.quality ?? "bestaudio";
|
||||
const cookieArgs = buildCookieArgs();
|
||||
const args = [
|
||||
"-f",
|
||||
format,
|
||||
@@ -164,6 +328,7 @@ export function resolveMediaUrl(
|
||||
"-",
|
||||
"--no-progress",
|
||||
"--no-warnings",
|
||||
...cookieArgs,
|
||||
"--print",
|
||||
"before_dl:title",
|
||||
"--print",
|
||||
@@ -183,6 +348,20 @@ export function resolveMediaUrl(
|
||||
// `--print` headers to stderr — pipe stdout immediately so the child
|
||||
// never blocks on a full pipe while we wait for the headers on stderr.
|
||||
const mediaStream = new PassThrough();
|
||||
// Teardown (player stop / ffmpeg exit) destroys this stream while
|
||||
// yt-dlp may still push bytes — without a listener an EPIPE /
|
||||
// ERR_STREAM_DESTROYED surfaces as an uncaughtException.
|
||||
mediaStream.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (
|
||||
err.code === "EPIPE" ||
|
||||
err.code === "ERR_STREAM_DESTROYED" ||
|
||||
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||
) {
|
||||
logger.debug({ code: err.code }, "Media stream closed during teardown");
|
||||
} else {
|
||||
logger.error({ error: err.message }, "Media stream error");
|
||||
}
|
||||
});
|
||||
proc.stdout.pipe(mediaStream);
|
||||
|
||||
let stderrBuf = "";
|
||||
@@ -279,245 +458,6 @@ export function resolveMediaUrl(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a media URL to a single playable input stream for screen share /
|
||||
* GoLive streaming.
|
||||
*
|
||||
* yt-dlp `--get-url` with `bestvideo+bestaudio` prints the video-only and
|
||||
* audio-only URLs on SEPARATE lines. The old code took only the first line
|
||||
* (video-only) → ffmpeg had no audio track → GoLive stream had no sound.
|
||||
*
|
||||
* This returns a single input that `prepareStream` (which accepts only ONE
|
||||
* ffmpeg input) can consume while STILL including audio:
|
||||
* - If yt-dlp offers a merged progressive URL (one URL, video+audio) it is
|
||||
* returned directly.
|
||||
* - Otherwise the video-only + audio-only DASH URLs are fetched in the SAME
|
||||
* yt-dlp run (signature URLs expire quickly) and merged locally by an
|
||||
* ffmpeg process into a single NUT stream, which is streamed to the
|
||||
* consumer over a Readable. NUT over stdin auto-probes cleanly (verified:
|
||||
* av1+opus merge → H264+opus transcode).
|
||||
*
|
||||
* @returns a direct video URL (string) or a Readable of the merged NUT stream.
|
||||
*/
|
||||
export function getDirectScreenInput(url: string): Promise<string | Readable> {
|
||||
return new Promise<string | Readable>((resolve, reject) => {
|
||||
const args = [
|
||||
url,
|
||||
"--dump-single-json",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
// NOTE: deliberately NOT --no-simulate. Simulate mode still resolves the
|
||||
// requested format URLs into the JSON (requested_formats[].url), and it
|
||||
// avoids yt-dlp writing .part files into the process CWD — which is the
|
||||
// read-only Nix store dir for the deployed gateway (EACCES).
|
||||
];
|
||||
|
||||
logger.info({ url }, "Spawning yt-dlp for screen share input resolution");
|
||||
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
activeProcesses.add(proc);
|
||||
|
||||
let stdoutBuf = "";
|
||||
let stderrBuf = "";
|
||||
const MAX_STDERR = 4096;
|
||||
const MAX_STDOUT = 8 * 1024 * 1024; // JSON metadata + requested format URLs
|
||||
|
||||
if (proc.stdout) {
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
if (stdoutBuf.length < MAX_STDOUT) {
|
||||
stdoutBuf += chunk
|
||||
.toString("utf8")
|
||||
.slice(0, MAX_STDOUT - stdoutBuf.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
if (stderrBuf.length < MAX_STDERR) {
|
||||
stderrBuf += chunk
|
||||
.toString("utf8")
|
||||
.slice(0, MAX_STDERR - stderrBuf.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||
activeProcesses.delete(proc);
|
||||
if (err.code === "ENOENT") {
|
||||
reject(buildNotInstalledError());
|
||||
} else {
|
||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
activeProcesses.delete(proc);
|
||||
|
||||
if (code !== 0) {
|
||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||
reject(
|
||||
new Error(
|
||||
`yt-dlp screen input resolution exited with code ${code}${detail}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
|
||||
} catch (parseErr) {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to parse yt-dlp JSON for screen input: ${(parseErr as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolveScreenInput(parsed).then(resolve, (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
reject(
|
||||
new Error(`Failed to build screen input for "${url}": ${message}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From a parsed yt-dlp JSON info dict, decide how to feed a single ffmpeg
|
||||
* input with both video and audio.
|
||||
*/
|
||||
async function resolveScreenInput(
|
||||
info: Record<string, unknown>,
|
||||
): Promise<string | Readable> {
|
||||
const requested = info.requested_formats as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
|
||||
// Merged/progressive single URL (video+audio in one). Common when yt-dlp
|
||||
// selects a single format (e.g. format 18 progressive mp4) or when a direct
|
||||
// muxed URL is available.
|
||||
const singleUrl = info.url as string | undefined;
|
||||
const singleHasAudio =
|
||||
info.acodec !== "none" &&
|
||||
typeof info.acodec === "string" &&
|
||||
info.acodec.length > 0;
|
||||
|
||||
if (typeof singleUrl === "string" && singleUrl && singleHasAudio) {
|
||||
logger.debug("Screen share uses merged progressive single URL");
|
||||
return singleUrl;
|
||||
}
|
||||
|
||||
// Separate video-only + audio-only DASH formats → merge locally via ffmpeg.
|
||||
if (Array.isArray(requested) && requested.length >= 2) {
|
||||
const video = requested.find(
|
||||
(rf) => rf.vcodec && String(rf.vcodec) !== "none",
|
||||
);
|
||||
const audio = requested.find(
|
||||
(rf) => rf.acodec && String(rf.acodec) !== "none",
|
||||
);
|
||||
const videoUrl = video?.url as string | undefined;
|
||||
const audioUrl = audio?.url as string | undefined;
|
||||
|
||||
if (
|
||||
typeof videoUrl === "string" &&
|
||||
videoUrl.length > 0 &&
|
||||
typeof audioUrl === "string" &&
|
||||
audioUrl.length > 0
|
||||
) {
|
||||
return mergeScreenStreams(videoUrl, audioUrl);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"yt-dlp returned neither a merged progressive URL nor a video+audio format pair",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a video-only URL and an audio-only URL into a single NUT stream using
|
||||
* a child ffmpeg process. Both URLs come from the same yt-dlp run, so they
|
||||
* share the same signature/expiry and are consumed immediately.
|
||||
*/
|
||||
function mergeScreenStreams(videoUrl: string, audioUrl: string): Readable {
|
||||
logger.info("Merging video+audio DASH streams into a single NUT input");
|
||||
|
||||
const ffmpeg = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-reconnect",
|
||||
"1",
|
||||
"-reconnect_streamed",
|
||||
"1",
|
||||
"-reconnect_delay_max",
|
||||
"5",
|
||||
"-i",
|
||||
videoUrl,
|
||||
"-i",
|
||||
audioUrl,
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-f",
|
||||
"nut",
|
||||
"pipe:1",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
// Track so cleanup() can terminate the merge during graceful shutdown.
|
||||
activeProcesses.add(ffmpeg);
|
||||
ffmpeg.once("exit", () => {
|
||||
activeProcesses.delete(ffmpeg);
|
||||
});
|
||||
|
||||
// Prevent the ffmpeg stderr from filling the pipe buffer / leaking.
|
||||
let stderrBuf = "";
|
||||
const MAX_STDERR = 4096;
|
||||
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
|
||||
if (stderrBuf.length < MAX_STDERR) {
|
||||
stderrBuf += chunk.toString("utf8");
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on("error", (err) => {
|
||||
const msg =
|
||||
err.message === "spawn ffmpeg ENOENT"
|
||||
? "FFmpeg not found! Install ffmpeg in the container."
|
||||
: err.message;
|
||||
logger.error({ error: msg }, "Screen stream merge ffmpeg error");
|
||||
});
|
||||
|
||||
ffmpeg.on("exit", (code) => {
|
||||
const stderr = stderrBuf.trim();
|
||||
logger.warn(
|
||||
{ code, stderr: stderr.slice(-500) || undefined },
|
||||
"Screen stream merge ffmpeg exited",
|
||||
);
|
||||
});
|
||||
|
||||
const stream = ffmpeg.stdout;
|
||||
stream.setMaxListeners(32);
|
||||
return stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||
* without downloading the audio stream.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { StreamType } from "@discordjs/voice";
|
||||
|
||||
export type MediaMode = "music" | "screen";
|
||||
export type MediaMode = "music";
|
||||
export type MediaSourceKind =
|
||||
| "url"
|
||||
| "local"
|
||||
@@ -32,6 +32,7 @@ export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
@@ -50,23 +51,7 @@ export interface MusicPlayer {
|
||||
play(source: ResolvedMediaSource): MusicPlayback;
|
||||
}
|
||||
|
||||
export interface ScreenSharePlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareController {
|
||||
isActive(): boolean;
|
||||
start(source: string): Promise<ScreenSharePlayback>;
|
||||
}
|
||||
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music";
|
||||
|
||||
export interface DiscordPlayOptions {
|
||||
inputType?: StreamType;
|
||||
|
||||
@@ -18,7 +18,7 @@ export class DiscordPlayer {
|
||||
private connection: VoiceConnection | null = null;
|
||||
private owner: DiscordPlayerOwner = "none";
|
||||
private resource: AudioResource | null = null;
|
||||
private musicVolume = 1;
|
||||
private musicVolume = 0.3;
|
||||
private idleCallback: (() => void) | null = null;
|
||||
/** Set before manual stop() calls to distinguish from natural track end. */
|
||||
private manualStop = false;
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import {
|
||||
Encoders,
|
||||
playStream,
|
||||
prepareStream,
|
||||
Streamer,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { getDirectScreenInput } from "./mediaSource.js";
|
||||
import type { ScreenSharePlayback } from "./mediaTypes.js";
|
||||
import { discordPlayer } from "./player.js";
|
||||
|
||||
const logger = createChildLogger("screen-share");
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord Go Live (screenshare) via @dank074/discord-video-stream.
|
||||
*
|
||||
* Pipeline:
|
||||
* URL (YouTube, dll.) → yt-dlp direct video URL → ffmpeg (H264 720p30) →
|
||||
* playStream({ type: "go-live" }) → Discord voice channel as Go Live.
|
||||
*
|
||||
* Restored from the pre-microservices implementation (commit d50ce86,
|
||||
* src/media/screenShareController.ts) — the interface survived in
|
||||
* mediaTypes.ts but the implementation was lost during the split.
|
||||
*/
|
||||
export class ScreenShareController {
|
||||
private logger = createChildLogger("screen-share");
|
||||
private streamer: Streamer | null = null;
|
||||
private active: ScreenSharePlayback | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: Client,
|
||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus,
|
||||
/** Disconnect the @discordjs/voice connection so the Streamer can take
|
||||
* over the voice channel (Discord allows only ONE voice session per user
|
||||
* — two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */
|
||||
private readonly releaseVoice: (
|
||||
status: ScreenShareVoiceStatus,
|
||||
) => void | Promise<void>,
|
||||
/** Reconnect the @discordjs/voice connection after the stream ends. */
|
||||
private readonly restoreVoice: (
|
||||
status: ScreenShareVoiceStatus,
|
||||
) => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active !== null;
|
||||
}
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = this.getVoiceStatus();
|
||||
if (!status.connected || !status.activeGuildId || !status.activeChannelId) {
|
||||
throw new Error("Connect to a voice channel before sharing screen");
|
||||
}
|
||||
|
||||
if (this.active || discordPlayer.getOwner() !== "none") {
|
||||
throw new Error("Another media mode is active");
|
||||
}
|
||||
|
||||
try {
|
||||
const input = await getDirectScreenInput(source);
|
||||
if (!this.streamer) {
|
||||
this.streamer = new Streamer(this.client);
|
||||
}
|
||||
|
||||
const guild = this.client.guilds.cache.get(status.activeGuildId);
|
||||
const channel = guild?.channels.cache.get(status.activeChannelId);
|
||||
if (
|
||||
!channel ||
|
||||
(channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE")
|
||||
) {
|
||||
throw new Error(
|
||||
`Voice channel ${status.activeChannelId} not found for screen share`,
|
||||
);
|
||||
}
|
||||
|
||||
// Free the @discordjs/voice connection BEFORE the Streamer joins, so
|
||||
// the user has only one voice session (Discord requirement).
|
||||
await this.releaseVoice(status);
|
||||
|
||||
await Promise.race([
|
||||
this.streamer.joinVoiceChannel(channel),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Timed out joining voice channel for screen share"),
|
||||
),
|
||||
15000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const { command, output } = prepareStream(input, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
bitrateVideo: 2500,
|
||||
bitrateVideoMax: 4000,
|
||||
includeAudio: true,
|
||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
// Restore the @discordjs/voice connection after the stream ends (both
|
||||
// natural end and failure), so the user can keep using audio/mic.
|
||||
const restoreAfter = () => {
|
||||
if (!stopped) {
|
||||
stopped = true;
|
||||
try {
|
||||
command.kill("SIGTERM");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.streamer?.voiceConnection?.stop();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
if (this.restoreVoice) {
|
||||
// Best-effort restore after a short delay. Discord often needs the
|
||||
// Streamer's session fully torn down before @discordjs/voice can
|
||||
// re-join; if that races, the reconnect times out — the FE shows
|
||||
// disconnected and the user just clicks Connect again. This is an
|
||||
// accepted UX tradeoff for GoLive (single voice session per user).
|
||||
setTimeout(() => {
|
||||
Promise.resolve(this.restoreVoice(status)).catch((err) => {
|
||||
this.logger.warn(
|
||||
{ error: err instanceof Error ? err.message : String(err) },
|
||||
"Failed to restore voice connection after screen share (user can reconnect manually)",
|
||||
);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
const done = playStream(output, this.streamer, {
|
||||
type: "go-live",
|
||||
})
|
||||
.catch((err) => {
|
||||
// Never let a stream failure become an unhandledRejection — that
|
||||
// crashed the whole gateway. Log + surface via the done promise.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
{ error: message, source },
|
||||
"Screen stream failed during playback",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
restoreAfter();
|
||||
this.active = null;
|
||||
});
|
||||
this.active = {
|
||||
done,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
command.kill("SIGTERM");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
// Leave the voice channel the Streamer joined (its own connection).
|
||||
try {
|
||||
this.streamer?.voiceConnection?.stop();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
this.active = null;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info({ source }, "Screen share started");
|
||||
return this.active;
|
||||
} catch (error) {
|
||||
this.active = null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ error: message, source }, "Screen stream failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ export class VoiceTransmitter {
|
||||
private gate = Promise.resolve();
|
||||
/** Set true before sending SIGTERM so exit handler knows it's intentional */
|
||||
private _expectedExit = false;
|
||||
/** True while the underlying stream is in a drain state (backpressure) */
|
||||
private draining = false;
|
||||
|
||||
/**
|
||||
* Start listening for PCM audio data from Redis and stream to Discord
|
||||
@@ -56,6 +54,24 @@ export class VoiceTransmitter {
|
||||
// Create PCM input stream
|
||||
this.pcmStream = new PassThrough();
|
||||
this.pcmStream.setMaxListeners(32); // drain listeners accumulate during backpressure
|
||||
// Voice teardown (stop / disconnect / ffmpeg exit) destroys this stream
|
||||
// while Redis PCM messages may still be in flight. Without a listener,
|
||||
// EPIPE / ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END surface as
|
||||
// an uncaughtException and crash the whole gateway.
|
||||
this.pcmStream.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (
|
||||
err.code === "EPIPE" ||
|
||||
err.code === "ERR_STREAM_DESTROYED" ||
|
||||
err.code === "ERR_STREAM_WRITE_AFTER_END"
|
||||
) {
|
||||
logger.debug(
|
||||
{ code: err.code },
|
||||
"PCM stream closed during voice teardown — ignoring",
|
||||
);
|
||||
} else {
|
||||
logger.error({ error: err.message }, "PCM stream error");
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn FFmpeg to encode 24kHz mono PCM → OggOpus
|
||||
// Input: 24kHz mono s16le (raw PCM)
|
||||
@@ -146,7 +162,12 @@ export class VoiceTransmitter {
|
||||
);
|
||||
|
||||
this.redisSub.on("message", (channel, message) => {
|
||||
if (channel !== this.TRANSMIT_CHANNEL || !this.pcmStream) return;
|
||||
if (
|
||||
!this.isActive ||
|
||||
channel !== this.TRANSMIT_CHANNEL ||
|
||||
!this.pcmStream
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
@@ -156,16 +177,24 @@ export class VoiceTransmitter {
|
||||
const canContinue = stream.write(pcmBuffer);
|
||||
// Backpressure: queue until drain
|
||||
if (!canContinue) {
|
||||
this.draining = true;
|
||||
stream.once("drain", () => {
|
||||
this.draining = false;
|
||||
// Re-acquire stream reference (could have been replaced by restart)
|
||||
const currentStream = this.pcmStream;
|
||||
if (!currentStream) return;
|
||||
if (!currentStream || !this.isActive) return;
|
||||
// Flush queued chunks
|
||||
while (this.backpressureQueue.length > 0) {
|
||||
const queued = this.backpressureQueue.shift()!;
|
||||
if (!currentStream.write(queued)) break;
|
||||
const queued = this.backpressureQueue.shift();
|
||||
if (!queued) break;
|
||||
try {
|
||||
if (!currentStream.write(queued)) break;
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
{
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"PCM flush write failed during teardown — ignoring",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -204,7 +233,6 @@ export class VoiceTransmitter {
|
||||
this.isActive = false;
|
||||
|
||||
this.backpressureQueue = [];
|
||||
this.draining = false;
|
||||
|
||||
if (this.pcmStream) {
|
||||
this.pcmStream.removeAllListeners("drain");
|
||||
|
||||
@@ -32,6 +32,13 @@ export const configSchema = z
|
||||
.default("")
|
||||
.transform((v) => v.split(",").filter(Boolean))
|
||||
.describe("Thread IDs to exclude from capture"),
|
||||
BOT_EXCLUDED_CHANNEL_IDS: z
|
||||
.string()
|
||||
.default("1206269771340058694")
|
||||
.transform((v) => v.split(",").filter(Boolean))
|
||||
.describe(
|
||||
"Channel IDs where bot messages are NOT captured/analyzed (bot detection stays on everywhere else)",
|
||||
),
|
||||
|
||||
// ── Legacy voice ─────────────────────────────────────────────────────
|
||||
VOICE_GUILD_ID: z.string().min(1).optional(),
|
||||
@@ -87,11 +94,20 @@ export const configSchema = z
|
||||
POSTGRES_USER: z.string().optional(),
|
||||
POSTGRES_PASSWORD: z.string().optional(),
|
||||
POSTGRES_DB: z.string().optional(),
|
||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||
// Idle-pool floor. Kept at 0 so the gateway (main + 4 Piscina worker
|
||||
// threads, each owning its own pg Pool) does not hold ~10 permanently
|
||||
// open idle connections to PgBouncer. The pool still grows on demand up
|
||||
// to POSTGRES_POOL_MAX; min:0 only drops idle clients after
|
||||
// idleTimeoutMillis. This both trims RSS and frees PgBouncer slots.
|
||||
POSTGRES_POOL_MIN: z.coerce.number().int().min(0).default(0),
|
||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||
|
||||
// ── Redis ────────────────────────────────────────────────────────────
|
||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
||||
// ── SearXNG ───────────────────────────────────────────────────────────
|
||||
// Instance for web search + term glossary lookups. Override when the
|
||||
// default instance is down/rate-limited.
|
||||
SEARXNG_BASE_URL: z.string().url().default("https://searxng.imrnes.team"),
|
||||
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
|
||||
VOICE_PCM_WS_ENABLED: z
|
||||
.string()
|
||||
@@ -133,7 +149,17 @@ export const configSchema = z
|
||||
.url()
|
||||
.default("https://9router.asepharyana.my.id/v1"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||
// Vision uses the SAME router/base URL as text moderation
|
||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||
// multimodal endpoint was removed.
|
||||
AI_LLM_VISION_MODEL: z.string().default("multimodal"),
|
||||
AI_LLM_DISABLE_THINKING: z
|
||||
.string()
|
||||
.default("true")
|
||||
.transform((v) => v === "true")
|
||||
.describe(
|
||||
"Disable LLM chain-of-thought (reasoning/thinking) to speed up AI analysis. Set false to restore thinking.",
|
||||
),
|
||||
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
||||
AI_LLM_EMBEDDING_MIN_SIMILARITY: z.coerce
|
||||
.number()
|
||||
@@ -171,6 +197,24 @@ export const configSchema = z
|
||||
.int()
|
||||
.positive()
|
||||
.default(30000),
|
||||
// Term glossary — per-word Wikipedia lookups (via SearXNG) for words the
|
||||
// LLM may not know (slang, jargon, regional language, foreign terms).
|
||||
// Definitions are cached (in-memory + Redis) so repeat lookups are fast.
|
||||
// Disable to skip glossary lookups entirely and analyze without them.
|
||||
AI_GLOSSARY_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
// Max glossary terms looked up per analysis batch (keeps latency bounded).
|
||||
AI_GLOSSARY_MAX_TERMS: z.coerce.number().int().min(1).max(20).default(6),
|
||||
// Min word length for a term to be considered glossary-worthy.
|
||||
AI_GLOSSARY_MIN_WORD_LENGTH: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(2)
|
||||
.max(20)
|
||||
.default(5),
|
||||
|
||||
// ── AI Analysis Timing ──────────────────────────────────────────────
|
||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||
@@ -189,6 +233,17 @@ export const configSchema = z
|
||||
.int()
|
||||
.positive()
|
||||
.default(20),
|
||||
// Recency gates for conversation context. A silence longer than GAP_MS
|
||||
// between context messages = the conversation restarted (older messages
|
||||
// dropped); MAX_AGE_MS caps how far back context is considered relevant.
|
||||
AI_ANALYSIS_CONTEXT_GAP_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(12 * 60 * 1000),
|
||||
AI_ANALYSIS_CONTEXT_MAX_AGE_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(45 * 60 * 1000),
|
||||
AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
@@ -242,6 +297,19 @@ export const configSchema = z
|
||||
.default(false),
|
||||
AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""),
|
||||
|
||||
// ── Nickname Reset (offensive_username enforcement) ────────────────
|
||||
// When the only violation is the member's server nickname, reset the
|
||||
// nickname to the default username instead of deleting the message.
|
||||
AUTO_NICKNAME_RESET_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === "true")
|
||||
.default(true),
|
||||
AUTO_NICKNAME_RESET_COOLDOWN_MS: z.coerce
|
||||
.number()
|
||||
.positive()
|
||||
.default(10 * 60 * 1000),
|
||||
|
||||
// ── Retention ───────────────────────────────────────────────────────
|
||||
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
|
||||
|
||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||
}),
|
||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", {
|
||||
mode: "number",
|
||||
}),
|
||||
ai_error: pgText("ai_error"),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -435,6 +438,32 @@ export const pgStickerCacheTable = pgTable(
|
||||
|
||||
export const stickerCacheTable = pgStickerCacheTable;
|
||||
|
||||
/**
|
||||
* Term Glossary Cache Table (PostgreSQL)
|
||||
* Permanently stores resolved term definitions (Wikipedia/SearXNG lookups).
|
||||
* Definitions rarely change, so once a term is successfully resolved it is
|
||||
* persisted here forever — Redis/LRU only act as fast read caches in front.
|
||||
* Terms with NO definition (misses) are NOT stored here; they stay ephemeral
|
||||
* in Redis with a short TTL so transient lookup failures get retried.
|
||||
*/
|
||||
export const pgTermGlossaryCacheTable = pgTable(
|
||||
"term_glossary_cache",
|
||||
{
|
||||
term: pgText("term").primaryKey(),
|
||||
definition: pgText("definition").notNull(),
|
||||
source_url: pgText("source_url").notNull().default(""),
|
||||
resolved_at: pgBigint("resolved_at", { mode: "number" }).notNull(),
|
||||
hit_count: pgInteger("hit_count").notNull().default(0),
|
||||
},
|
||||
(table) => ({
|
||||
resolvedAtIdx: pgIndex("idx_term_glossary_cache_resolved_at").on(
|
||||
table.resolved_at,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const termGlossaryCacheTable = pgTermGlossaryCacheTable;
|
||||
|
||||
// =============================================================================
|
||||
// Meta / System
|
||||
// =============================================================================
|
||||
@@ -580,6 +609,11 @@ export type TextAnalysisCacheInsert =
|
||||
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
|
||||
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
|
||||
|
||||
// Term Glossary Cache
|
||||
export type TermGlossaryCache = typeof termGlossaryCacheTable.$inferSelect;
|
||||
export type TermGlossaryCacheInsert =
|
||||
typeof termGlossaryCacheTable.$inferInsert;
|
||||
|
||||
// Muxer Jobs
|
||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
|
||||
@@ -615,6 +649,7 @@ export const pgModerationActionsTable = pgTable(
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
"reset_nickname",
|
||||
],
|
||||
}).notNull(),
|
||||
reason: pgText("reason"),
|
||||
|
||||
@@ -198,7 +198,8 @@ export type ModerationActionType =
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user";
|
||||
| "ban_user"
|
||||
| "reset_nickname";
|
||||
|
||||
export interface ModerationAction {
|
||||
id: string;
|
||||
|
||||
@@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue";
|
||||
export const COMMAND_MEDIA_SKIP = "media:skip";
|
||||
export const COMMAND_MEDIA_STOP = "media:stop";
|
||||
export const COMMAND_MEDIA_VOLUME = "media:volume";
|
||||
export const COMMAND_MEDIA_LOOP = "media:loop";
|
||||
export const COMMAND_MODERATION_ACTION = "moderation:action";
|
||||
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
||||
|
||||
|
||||
@@ -108,5 +108,8 @@ export async function retryWithBackoff<T>(
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
// lastError is always set: the for-loop only exits via break when attempt
|
||||
// === retries, which only happens in the catch branch that sets lastError.
|
||||
if (!lastError) throw new Error("Unknown retry error");
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Context enrichment builders — rich <user_reputation> attrs, <user_history>,
|
||||
// <user_profiles> as_of, bot/edited detection (pure, no DB)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildUserHistoryXml,
|
||||
buildUserProfilesBlock,
|
||||
formatReputationAttrs,
|
||||
resolveIsBot,
|
||||
resolveIsEdited,
|
||||
} from "../src/modules/ai-moderation/moderationBuilders.js";
|
||||
import type { MessageRecord } from "../src/modules/message-capture/types.js";
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
function msg(overrides: Partial<MessageRecord> = {}): MessageRecord {
|
||||
return {
|
||||
id: "m1",
|
||||
guild_id: "g1",
|
||||
channel_id: "c1",
|
||||
thread_id: null,
|
||||
user_id: "u1",
|
||||
username: "user1",
|
||||
avatar_url: null,
|
||||
content: "hai",
|
||||
edited_content: null,
|
||||
created_at: NOW,
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
type: "text",
|
||||
is_reply: null,
|
||||
is_forward: null,
|
||||
is_crosspost: null,
|
||||
reference_message_id: null,
|
||||
reference_channel_id: null,
|
||||
reference_guild_id: null,
|
||||
metadata: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe("formatReputationAttrs — rich reputation signal", () => {
|
||||
it("emits trust, infraction count and clean streak", () => {
|
||||
const attrs = formatReputationAttrs({
|
||||
trust_score: 62,
|
||||
total_infractions: 3,
|
||||
clean_message_streak: 45,
|
||||
last_infraction_at: null,
|
||||
});
|
||||
expect(attrs).toContain('trust_score="62"');
|
||||
expect(attrs).toContain('total_infractions="3"');
|
||||
expect(attrs).toContain('clean_streak="45"');
|
||||
});
|
||||
|
||||
it("derives last_offense_days_ago and marks repeat offenders (7-day window)", () => {
|
||||
const attrs = formatReputationAttrs(
|
||||
{
|
||||
trust_score: 50,
|
||||
total_infractions: 2,
|
||||
clean_message_streak: 0,
|
||||
last_infraction_at: NOW - 2 * DAY_MS,
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(attrs).toContain('last_offense_days_ago="2"');
|
||||
expect(attrs).toContain('repeat_offender="true"');
|
||||
});
|
||||
|
||||
it("does NOT mark repeat offender when the last offense is older than 7 days", () => {
|
||||
const attrs = formatReputationAttrs(
|
||||
{
|
||||
trust_score: 50,
|
||||
total_infractions: 2,
|
||||
clean_message_streak: 10,
|
||||
last_infraction_at: NOW - 30 * DAY_MS,
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(attrs).toContain('last_offense_days_ago="30"');
|
||||
expect(attrs).not.toContain("repeat_offender");
|
||||
});
|
||||
|
||||
it("omits offense-derived attrs when the user has no recorded infraction date", () => {
|
||||
const attrs = formatReputationAttrs({
|
||||
trust_score: 85,
|
||||
total_infractions: 0,
|
||||
clean_message_streak: 120,
|
||||
last_infraction_at: null,
|
||||
});
|
||||
expect(attrs).not.toContain("last_offense_days_ago");
|
||||
expect(attrs).not.toContain("repeat_offender");
|
||||
});
|
||||
|
||||
it("clamps a future/skewed timestamp to days_ago=0", () => {
|
||||
const attrs = formatReputationAttrs(
|
||||
{
|
||||
trust_score: 50,
|
||||
total_infractions: 1,
|
||||
clean_message_streak: 0,
|
||||
last_infraction_at: NOW + 5 * DAY_MS,
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(attrs).toContain('last_offense_days_ago="0"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => {
|
||||
it("returns empty when there is no real history", () => {
|
||||
expect(buildUserHistoryXml([])).toBe("");
|
||||
expect(
|
||||
buildUserHistoryXml([{ content: " ", severity: "low", created_at: 1 }]),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("renders <infraction> rows with severity and recency", () => {
|
||||
const xml = buildUserHistoryXml(
|
||||
[
|
||||
{
|
||||
content: "beli barang murah disini https://scam.example",
|
||||
severity: "high",
|
||||
created_at: NOW - 3 * DAY_MS,
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(xml).toContain("<user_history>");
|
||||
expect(xml).toContain('severity="high"');
|
||||
expect(xml).toContain('time_ago_days="3"');
|
||||
expect(xml).toContain("beli barang murah disini");
|
||||
});
|
||||
|
||||
it("caps long snippets and XML-escapes content", () => {
|
||||
const xml = buildUserHistoryXml(
|
||||
[
|
||||
{
|
||||
content: "x".repeat(300),
|
||||
severity: "low",
|
||||
created_at: NOW - DAY_MS,
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(xml.length).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUserProfilesBlock — deduplicated map with staleness", () => {
|
||||
it("emits as_of when the profile has a last-generated timestamp", () => {
|
||||
const block = buildUserProfilesBlock(
|
||||
new Map([
|
||||
[
|
||||
"u1",
|
||||
{
|
||||
text: "Developer teknis, bahasa Indonesia",
|
||||
asOf: NOW - 3 * DAY_MS,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(block).toContain('<user_profile user_id="u1"');
|
||||
expect(block).toContain(
|
||||
`as_of="${new Date(NOW - 3 * DAY_MS).toISOString()}"`,
|
||||
);
|
||||
expect(block).toContain("Developer teknis");
|
||||
});
|
||||
|
||||
it("omits as_of when absent, and drops empty profiles", () => {
|
||||
const block = buildUserProfilesBlock(
|
||||
new Map([
|
||||
["u1", { text: "profil aktif", asOf: null }],
|
||||
["u2", { text: " " }],
|
||||
]),
|
||||
);
|
||||
expect(block).toContain('user_id="u1"');
|
||||
expect(block).not.toContain("as_of");
|
||||
expect(block).not.toContain("u2");
|
||||
});
|
||||
|
||||
it("returns empty for no profiles", () => {
|
||||
expect(buildUserProfilesBlock(new Map())).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIsBot / resolveIsEdited — message flags", () => {
|
||||
it("reads author.bot from captured metadata", () => {
|
||||
const bot = msg({
|
||||
metadata: JSON.stringify({
|
||||
author: { id: "x", username: "bot", bot: true },
|
||||
}),
|
||||
});
|
||||
const human = msg({
|
||||
metadata: JSON.stringify({
|
||||
author: { id: "y", username: "user", bot: false },
|
||||
}),
|
||||
});
|
||||
expect(resolveIsBot(bot)).toBe(true);
|
||||
expect(resolveIsBot(human)).toBe(false);
|
||||
expect(resolveIsBot(msg())).toBe(false);
|
||||
});
|
||||
|
||||
it("flags edited content only when edited_content is present (the edit path)", () => {
|
||||
expect(resolveIsEdited(msg({ edited_content: "versi baru" }))).toBe(true);
|
||||
expect(resolveIsEdited(msg())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Conversation context v2 — recency gating + location context (pure, no DB)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildConversationContext,
|
||||
buildLocationContext,
|
||||
formatMessageForPrompt,
|
||||
truncateContextLine,
|
||||
} from "../src/modules/ai-moderation/conversationContext.js";
|
||||
import { buildConversationContextBlock } from "../src/modules/ai-moderation/moderationBuilders.js";
|
||||
import { extractOgMeta } from "../src/modules/ai-moderation/urlFetcher.js";
|
||||
import type { MessageRecord } from "../src/modules/message-capture/types.js";
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
function msg(id: string, createdAt: number, content = "hai"): MessageRecord {
|
||||
return {
|
||||
id,
|
||||
guild_id: "g1",
|
||||
channel_id: "c1",
|
||||
thread_id: null,
|
||||
user_id: `u_${id}`,
|
||||
username: `user_${id}`,
|
||||
avatar_url: null,
|
||||
content,
|
||||
edited_content: null,
|
||||
created_at: createdAt,
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
type: "text",
|
||||
is_reply: null,
|
||||
is_forward: null,
|
||||
is_crosspost: null,
|
||||
reference_message_id: null,
|
||||
reference_channel_id: null,
|
||||
reference_guild_id: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
function target(id = "t1", createdAt = NOW): MessageRecord {
|
||||
return {
|
||||
...msg(id, createdAt),
|
||||
content: "pesan yang dianalisis",
|
||||
};
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
|
||||
describe("buildConversationContext — recency gating", () => {
|
||||
it("keeps an ONGOING conversation — recent messages, small gaps", () => {
|
||||
const context = [
|
||||
msg("a", NOW - 8 * MIN),
|
||||
msg("b", NOW - 6 * MIN),
|
||||
msg("c", NOW - 4 * MIN),
|
||||
msg("d", NOW - 2 * MIN),
|
||||
];
|
||||
const { lines, descriptor, dropped } = buildConversationContext({
|
||||
contextBefore: context,
|
||||
targets: [target()],
|
||||
maxTokens: 8000,
|
||||
gapMs: 12 * MIN,
|
||||
maxAgeMs: 45 * MIN,
|
||||
});
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(dropped).toBe(0);
|
||||
expect(descriptor).toContain("status=ongoing");
|
||||
});
|
||||
|
||||
it("drops messages before a silence gap — conversation RESTARTED", () => {
|
||||
const context = [
|
||||
msg("old1", NOW - 40 * MIN),
|
||||
msg("old2", NOW - 38 * MIN),
|
||||
msg("fresh", NOW - 5 * MIN),
|
||||
];
|
||||
const { lines, descriptor, dropped } = buildConversationContext({
|
||||
contextBefore: context,
|
||||
targets: [target()],
|
||||
maxTokens: 8000,
|
||||
gapMs: 12 * MIN,
|
||||
maxAgeMs: 45 * MIN,
|
||||
});
|
||||
// 40min-old messages are within maxAge but 33min before "fresh" → gap gate
|
||||
expect(lines.some((l) => l.includes("old1"))).toBe(false);
|
||||
expect(lines.some((l) => l.includes("fresh"))).toBe(true);
|
||||
expect(dropped).toBe(2);
|
||||
expect(descriptor).toContain("status=sparse");
|
||||
expect(descriptor).toContain("gap_before_min=");
|
||||
});
|
||||
|
||||
it("drops everything older than maxAge — stale noise, cold_start anchor kept", () => {
|
||||
const context = [
|
||||
msg("ancient", NOW - 120 * MIN),
|
||||
msg("stale", NOW - 60 * MIN),
|
||||
];
|
||||
const { lines, descriptor, dropped } = buildConversationContext({
|
||||
contextBefore: context,
|
||||
targets: [target()],
|
||||
maxTokens: 8000,
|
||||
gapMs: 12 * MIN,
|
||||
maxAgeMs: 45 * MIN,
|
||||
});
|
||||
// Age gate drops both from the real context block, but the cold-start
|
||||
// anchor keeps the nearest 2 so the LLM still senses the channel.
|
||||
expect(dropped).toBe(2);
|
||||
expect(descriptor).toContain("status=cold_start");
|
||||
expect(lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a 2-message anchor on cold start so the LLM senses the channel", () => {
|
||||
const context = [
|
||||
msg("far1", NOW - 100 * MIN),
|
||||
msg("far2", NOW - 99 * MIN),
|
||||
msg("near1", NOW - 50 * MIN),
|
||||
];
|
||||
const { lines, descriptor } = buildConversationContext({
|
||||
contextBefore: context,
|
||||
targets: [target()],
|
||||
maxTokens: 8000,
|
||||
gapMs: 12 * MIN,
|
||||
maxAgeMs: 45 * MIN,
|
||||
});
|
||||
expect(lines).toHaveLength(2); // nearest 2 kept as anchor
|
||||
expect(lines.some((l) => l.includes("near1"))).toBe(true);
|
||||
expect(descriptor).toContain("status=cold_start");
|
||||
});
|
||||
|
||||
it("respects the token budget (older lines dropped first)", () => {
|
||||
const context = Array.from({ length: 20 }, (_, i) =>
|
||||
msg(`m${i}`, NOW - (i + 1) * MIN),
|
||||
);
|
||||
const { lines } = buildConversationContext({
|
||||
contextBefore: context,
|
||||
targets: [target()],
|
||||
maxTokens: 600,
|
||||
gapMs: 12 * MIN,
|
||||
maxAgeMs: 45 * MIN,
|
||||
});
|
||||
expect(lines.length).toBeLessThan(20);
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessageForPrompt — server nickname (displayName)", () => {
|
||||
it("renders member.displayName when captured (per-server nickname)", () => {
|
||||
const m = msg("n1", NOW - MIN);
|
||||
m.metadata = JSON.stringify({
|
||||
member: {
|
||||
displayName: "Si Goblok Server",
|
||||
roles: [],
|
||||
joinedTimestamp: null,
|
||||
},
|
||||
});
|
||||
const line = formatMessageForPrompt(m, "context");
|
||||
expect(line).toContain("user=Si Goblok Server");
|
||||
expect(line).not.toContain("user_user_n1");
|
||||
});
|
||||
|
||||
it("falls back to global username when displayName missing", () => {
|
||||
const line = formatMessageForPrompt(
|
||||
msg("n2", NOW - MIN, "halo"),
|
||||
"context",
|
||||
);
|
||||
expect(line).toContain("user=user_n2");
|
||||
});
|
||||
|
||||
it("truncates an oversized context message so one paste cannot eat the whole budget", () => {
|
||||
const huge = "A".repeat(5000);
|
||||
const line = formatMessageForPrompt(msg("n3", NOW - MIN, huge), "context");
|
||||
expect(line).toContain("…[konteks dipotong: terlalu panjang]");
|
||||
expect(line.length).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it("keeps short context content intact", () => {
|
||||
expect(truncateContextLine("pendek")).toBe("pendek");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
|
||||
it("renders a structured <location_context/> element from captured metadata", () => {
|
||||
const t = target();
|
||||
t.metadata = JSON.stringify({
|
||||
channel: {
|
||||
channelName: "general",
|
||||
threadName: "tanya coding",
|
||||
nsfw: false,
|
||||
ageRestricted: false,
|
||||
},
|
||||
});
|
||||
const line = buildLocationContext([t]);
|
||||
expect(line).toContain("<location_context");
|
||||
expect(line).toContain('channel_id="c1"');
|
||||
expect(line).toContain('channel_name="general"');
|
||||
expect(line).toContain('thread_name="tanya coding"');
|
||||
expect(line).toContain('nsfw="false"');
|
||||
expect(line).toContain('age_restricted="false"');
|
||||
});
|
||||
|
||||
it("includes the channel topic (escaped) when captured", () => {
|
||||
const t = target();
|
||||
t.metadata = JSON.stringify({
|
||||
channel: {
|
||||
channelName: "rules",
|
||||
topic: "Diskusi coding & programming — no self-promo",
|
||||
nsfw: false,
|
||||
},
|
||||
});
|
||||
const line = buildLocationContext([t]);
|
||||
expect(line).toContain(
|
||||
'topic="Diskusi coding & programming — no self-promo"',
|
||||
);
|
||||
});
|
||||
|
||||
it("caps an oversized topic and omits empty/absent topic", () => {
|
||||
const t = target();
|
||||
t.metadata = JSON.stringify({
|
||||
channel: { channelName: "general", topic: "x".repeat(500), nsfw: false },
|
||||
});
|
||||
const line = buildLocationContext([t]);
|
||||
const match = line.match(/topic="([^"]*)"/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1].length).toBeLessThanOrEqual(201);
|
||||
|
||||
const t2 = target();
|
||||
t2.metadata = JSON.stringify({ channel: { channelName: "general" } });
|
||||
expect(buildLocationContext([t2])).not.toContain("topic=");
|
||||
});
|
||||
|
||||
it("returns empty when no metadata", () => {
|
||||
expect(buildLocationContext([target()])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildConversationContextBlock — structured USER-message context", () => {
|
||||
it("wraps location + descriptor + lines into XML blocks", () => {
|
||||
const block = buildConversationContextBlock({
|
||||
location: buildLocationContext(
|
||||
(() => {
|
||||
const t = target();
|
||||
t.metadata = JSON.stringify({
|
||||
channel: { channelName: "general", nsfw: false },
|
||||
});
|
||||
return [t];
|
||||
})(),
|
||||
),
|
||||
descriptor: "[conversation_flow] status=ongoing context_msgs=1 dropped=0",
|
||||
lines: ["[context] id=a time=2027-01-01T00:00:00.000Z user=user_a: hai"],
|
||||
});
|
||||
expect(block).toContain("<location_context");
|
||||
expect(block).toContain("<conversation_context>");
|
||||
expect(block).toContain("[conversation_flow] status=ongoing");
|
||||
expect(block).toContain("[context] id=a");
|
||||
// location block comes before conversation block
|
||||
expect(block.indexOf("<location_context")).toBeLessThan(
|
||||
block.indexOf("<conversation_context>"),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the conversation block when there are no lines", () => {
|
||||
const block = buildConversationContextBlock({
|
||||
location: "",
|
||||
descriptor: "",
|
||||
lines: [],
|
||||
});
|
||||
expect(block).toBe("");
|
||||
});
|
||||
|
||||
it("keeps only the location block when lines are empty but location exists", () => {
|
||||
const block = buildConversationContextBlock({
|
||||
location: '<location_context channel_id="c1"/>',
|
||||
descriptor: "",
|
||||
lines: [],
|
||||
});
|
||||
expect(block).toBe('<location_context channel_id="c1"/>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOgMeta — page title/site for <web_content>", () => {
|
||||
it("extracts og:title, og:description and og:site_name", () => {
|
||||
const html = `
|
||||
<html><head>
|
||||
<title>Fallback title</title>
|
||||
<meta property="og:title" content="Judul Halaman & Keren" />
|
||||
<meta property="og:description" content="Deskripsi halaman" />
|
||||
<meta property="og:site_name" content="Contoh Site" />
|
||||
<meta property="og:image" content="https://img.example.com/x.png" />
|
||||
</head></html>`;
|
||||
const meta = extractOgMeta(html);
|
||||
expect(meta.title).toBe("Judul Halaman & Keren");
|
||||
expect(meta.description).toBe("Deskripsi halaman");
|
||||
expect(meta.siteName).toBe("Contoh Site");
|
||||
});
|
||||
|
||||
it("falls back to <title> when og:title missing", () => {
|
||||
const html = "<html><head><title>Plain Title</title></head></html>";
|
||||
expect(extractOgMeta(html).title).toBe("Plain Title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// makeImageCacheKey — regression for hash collision bug
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Bug (2026-08-12): makeImageCacheKey() only hashed the first 128 chars of the
|
||||
// data URL. Since all resized images share the same MIME prefix
|
||||
// ('data:image/png;base64,') + identical base64 header bytes, nearly every
|
||||
// image got the SAME hash → 'image:<same-hash>' → all images reused the
|
||||
// first cached vision analysis ("konten judi").
|
||||
//
|
||||
// Fix: hash the ENTIRE data URL. This test verifies the fix and prevents
|
||||
// regression.
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeImageCacheKey } from "../src/modules/ai-moderation/textCacheStore.js";
|
||||
|
||||
function oldBuggyHash(dataUrl: string): string {
|
||||
const prefix = dataUrl.slice(0, 128);
|
||||
return `image:${createHash("sha256").update(prefix).digest("hex").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
describe("makeImageCacheKey — collision prevention", () => {
|
||||
it("produces different keys for images whose first 128 chars are identical", () => {
|
||||
// Two data URLs that SHARE the first 128 chars (same MIME + identical
|
||||
// base64 header) but differ after — this is the real-world scenario
|
||||
// that caused the collision bug.
|
||||
const sharedPrefix =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // pad to >128 chars
|
||||
|
||||
const imgA = `${sharedPrefix}UNIQUE_TO_A`;
|
||||
const imgB = `${sharedPrefix}UNIQUE_TO_B`;
|
||||
|
||||
// Under the OLD buggy scheme: same prefix → same hash → COLLISION
|
||||
expect(oldBuggyHash(imgA)).toBe(oldBuggyHash(imgB));
|
||||
|
||||
// Under the FIXED scheme: full data URL hashed → different keys
|
||||
const keyA = makeImageCacheKey(imgA);
|
||||
const keyB = makeImageCacheKey(imgB);
|
||||
expect(keyA).not.toBe(keyB);
|
||||
});
|
||||
|
||||
it("produces same key for identical input", () => {
|
||||
const dataUrl = "data:image/png;base64,samebase64dataheremari";
|
||||
expect(makeImageCacheKey(dataUrl)).toBe(makeImageCacheKey(dataUrl));
|
||||
});
|
||||
|
||||
it("prefix is always 'image:'", () => {
|
||||
const key = makeImageCacheKey("data:image/png;base64,test");
|
||||
expect(key.startsWith("image:")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resizeImageForVision } from "../src/modules/attachment-upload/imageResizer.js";
|
||||
|
||||
// Build a worst-case (poorly-compressing) 1024x1024 image, like a real photo.
|
||||
async function makeNoisyBuffer(): Promise<Buffer> {
|
||||
const w = 1024,
|
||||
h = 1024;
|
||||
const buf = Buffer.alloc(w * h * 3);
|
||||
let seed = 99;
|
||||
const rnd = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x7fffffff;
|
||||
};
|
||||
for (let i = 0; i < buf.length; i++) buf[i] = Math.floor(rnd() * 256);
|
||||
return sharp(buf, { raw: { width: w, height: h, channels: 3 } })
|
||||
.png({ compressionLevel: 1 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe("resizeImageForVision — vision input encoding", () => {
|
||||
it("always encodes to JPEG (not lossless PNG)", async () => {
|
||||
const src = await makeNoisyBuffer();
|
||||
// Source PNG is large & lossless (the old failure mode)
|
||||
expect(src.length).toBeGreaterThan(300_000);
|
||||
|
||||
const { data, mimeType } = await resizeImageForVision(src, 1024);
|
||||
expect(mimeType).toBe("image/jpeg");
|
||||
// JPEG encoding must shrink the data URL payload well below MB range.
|
||||
expect(data.length).toBeLessThan(800_000);
|
||||
});
|
||||
|
||||
it("re-encodes already-small images (no passthrough of raw originals)", async () => {
|
||||
const small = await sharp({
|
||||
create: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
channels: 3,
|
||||
background: { r: 200, g: 100, b: 50 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const { data, mimeType } = await resizeImageForVision(small, 1024);
|
||||
expect(mimeType).toBe("image/jpeg");
|
||||
// Even a tiny PNG must come out re-encoded (bounded), not the raw PNG bytes.
|
||||
expect(data.length).toBeLessThan(small.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// llmClient chunk extraction — reasoning_content fallback (pure, no network)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Regression: 9router "multimodal" combo routed to cloudflare gemma-4-26b
|
||||
// which streams ALL output in delta.reasoning_content with content:"" — the
|
||||
// old extractor returned empty text → llmVision reported "Vision API null
|
||||
// response" → every image moderation batch fell back to text-only analysis
|
||||
// (LLM kept writing "Meskipun analisis gambar gagal").
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractChunkText } from "../src/modules/ai-moderation/llmClient.js";
|
||||
|
||||
describe("extractChunkText — streaming chunk text extraction", () => {
|
||||
it("reads delta.content (standard OpenAI streaming)", () => {
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [{ delta: { content: "halo" }, finish_reason: null }],
|
||||
}),
|
||||
).toBe("halo");
|
||||
});
|
||||
|
||||
it("falls back to delta.reasoning_content when content is empty — reasoning-only models (cloudflare gemma)", () => {
|
||||
// Exact shape seen from 9router → cloudflare-ai/@cf/google/gemma-4-26b:
|
||||
// {"choices":[{"delta":{"content":"","reasoning_content":"Task","role":"assistant"},"finish_reason":null,...}]}
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "", reasoning_content: "Task" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("Task");
|
||||
});
|
||||
|
||||
it('falls back to delta.reasoning — mimo via 9router streams reasoning there with content:""', () => {
|
||||
// Exact shape seen from 9router → mimo-v2.5-free (2026-08-11):
|
||||
// {"choices":[{"delta":{"content":"","reasoning":"The user wants a","role":"assistant"},"finish_reason":null,...}]}
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "", reasoning: "The user wants a" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("The user wants a");
|
||||
});
|
||||
|
||||
it("joins delta.reasoning_details[].text when present", () => {
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "",
|
||||
reasoning: "",
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: " detailed", index: 0 },
|
||||
{ type: "reasoning.text", text: " description", index: 1 },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(" detailed description");
|
||||
});
|
||||
|
||||
it("prefers content over reasoning when both present (deepseek-style final answer)", () => {
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "jawaban akhir", reasoning_content: "pikiran" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("jawaban akhir");
|
||||
});
|
||||
|
||||
it("handles Anthropic-style message.content", () => {
|
||||
expect(extractChunkText({ message: { content: "via message" } })).toBe(
|
||||
"via message",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles top-level content / response fields (local LLM proxies)", () => {
|
||||
expect(extractChunkText({ content: "top-level" })).toBe("top-level");
|
||||
expect(extractChunkText({ response: "via response" })).toBe("via response");
|
||||
});
|
||||
|
||||
it("returns empty string for null/undefined/empty chunks", () => {
|
||||
expect(extractChunkText(null)).toBe("");
|
||||
expect(extractChunkText(undefined)).toBe("");
|
||||
expect(extractChunkText({})).toBe("");
|
||||
expect(
|
||||
extractChunkText({
|
||||
choices: [{ delta: { content: "", reasoning_content: null } }],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// llmClient buildLlmParams — disable-thinking injection (pure, no network)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLlmParams } from "../src/modules/ai-moderation/llmClient.js";
|
||||
|
||||
const baseOpts = {
|
||||
messages: [{ role: "user" as const, content: "halo" }],
|
||||
};
|
||||
|
||||
describe("buildLlmParams — disable-thinking injection", () => {
|
||||
it("injects no thinking-disabling params when disableThinking is false", () => {
|
||||
const params = buildLlmParams(baseOpts, false);
|
||||
expect(
|
||||
(params as Record<string, unknown>).reasoning_effort,
|
||||
).toBeUndefined();
|
||||
expect((params as Record<string, unknown>).reasoning).toBeUndefined();
|
||||
expect(
|
||||
(params as Record<string, unknown>).chat_template_kwargs,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("injects all provider variants when disableThinking is true", () => {
|
||||
const params = buildLlmParams(baseOpts, true) as Record<string, unknown>;
|
||||
expect(params.reasoning_effort).toBe("none");
|
||||
expect(params.reasoning).toEqual({ enabled: false });
|
||||
expect(params.chat_template_kwargs).toEqual({ enable_thinking: false });
|
||||
expect(params.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("keeps caller-supplied max_tokens / jsonResponse / stream intact", () => {
|
||||
const params = buildLlmParams(
|
||||
{
|
||||
...baseOpts,
|
||||
max_tokens: 16384,
|
||||
stream: true,
|
||||
jsonResponse: { type: "json_object" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
expect(params.max_tokens).toBe(16384);
|
||||
expect(params.stream).toBe(true);
|
||||
expect(params.response_format).toEqual({ type: "json_object" });
|
||||
// thinking-disabled params still present
|
||||
expect((params as Record<string, unknown>).chat_template_kwargs).toEqual({
|
||||
enable_thinking: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to config default model when none supplied", () => {
|
||||
// config.AI_LLM_MODEL defaults to "text"
|
||||
const params = buildLlmParams(baseOpts, false);
|
||||
expect(params.model).toBe("text");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Nickname-only enforcement — offensive username flag handling (pure, no DB)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isNicknameOnlyViolation,
|
||||
parseModerationFlags,
|
||||
} from "../src/modules/ai-moderation/autoDeleteEligibility.js";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
MessageRecord,
|
||||
} from "../src/modules/message-capture/types.js";
|
||||
|
||||
function msg(flagsJson: string | null): MessageRecord {
|
||||
return {
|
||||
id: "m1",
|
||||
guild_id: "g1",
|
||||
channel_id: "c1",
|
||||
thread_id: null,
|
||||
user_id: "u1",
|
||||
username: "user1",
|
||||
avatar_url: null,
|
||||
content: "halo semua",
|
||||
edited_content: null,
|
||||
created_at: Date.now(),
|
||||
edited_at: null,
|
||||
deleted_at: null,
|
||||
type: "text",
|
||||
is_reply: null,
|
||||
is_forward: null,
|
||||
is_crosspost: null,
|
||||
reference_message_id: null,
|
||||
reference_channel_id: null,
|
||||
reference_guild_id: null,
|
||||
metadata: null,
|
||||
ai_moderation_flags: flagsJson,
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseModerationFlags", () => {
|
||||
it("parses JSON array from stored column", () => {
|
||||
expect(parseModerationFlags(msg('["offensive_username","sara"]'))).toEqual([
|
||||
"offensive_username",
|
||||
"sara",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] for null / malformed values", () => {
|
||||
expect(parseModerationFlags(msg(null))).toEqual([]);
|
||||
expect(parseModerationFlags(msg("not-json"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("prefers structured analysisResult flags", () => {
|
||||
const result = { flags: ["vulgar_language"] } as AnalysisResult;
|
||||
expect(parseModerationFlags(msg('["old_flag"]'), result)).toEqual([
|
||||
"vulgar_language",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNicknameOnlyViolation", () => {
|
||||
it("true when the ONLY flag is offensive_username", () => {
|
||||
expect(isNicknameOnlyViolation(msg('["offensive_username"]'))).toBe(true);
|
||||
});
|
||||
|
||||
it("false when other flags ride along (message itself violated)", () => {
|
||||
expect(isNicknameOnlyViolation(msg('["offensive_username","sara"]'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isNicknameOnlyViolation(msg('["harassment"]'))).toBe(false);
|
||||
});
|
||||
|
||||
it("false when no flags at all", () => {
|
||||
expect(isNicknameOnlyViolation(msg(null))).toBe(false);
|
||||
expect(isNicknameOnlyViolation(msg("[]"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 1. AppError Hierarchy
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AppError,
|
||||
ConfigError,
|
||||
@@ -9,7 +11,6 @@ import {
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "../src/shared/errors/index.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("AppError subclasses", () => {
|
||||
it("AppError carries code, statusCode, and details", () => {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Screen share input resolution tests
|
||||
//
|
||||
// Verifies the decision logic of getDirectScreenInput:
|
||||
// - merged progressive URL → returned directly
|
||||
// - video+audio DASH pair → local ffmpeg merge (Readable)
|
||||
// - neither → rejection
|
||||
//
|
||||
// Both yt-dlp and ffmpeg are faked via PATH shim scripts so the test does not
|
||||
// hit the network or need real binaries.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getDirectScreenInput } from "../src/modules/voice-recording/mediaSource.js";
|
||||
|
||||
// ─── fake bin dir ──────────────────────────────────────────────────────────────
|
||||
let fakeBinDir: string | null = null;
|
||||
const realPath = process.env.PATH;
|
||||
|
||||
beforeAll(() => {
|
||||
fakeBinDir = mkdtempSync(join(tmpdir(), "gmw-fake-bins-"));
|
||||
|
||||
// Fake yt-dlp: prints the JSON file named in GMW_FAKE_YTDLP_JSON.
|
||||
// If the file is missing → exits 1 (mimics yt-dlp failure).
|
||||
const ytShim = `#!/usr/bin/env bash
|
||||
if [ -n "$GMW_FAKE_YTDLP_JSON" ] && [ -f "$GMW_FAKE_YTDLP_JSON" ]; then
|
||||
cat "$GMW_FAKE_YTDLP_JSON"
|
||||
exit 0
|
||||
fi
|
||||
echo "yt-dlp: fake JSON missing" >&2
|
||||
exit 1
|
||||
`;
|
||||
writeFileSync(join(fakeBinDir, "yt-dlp"), ytShim);
|
||||
chmodSync(join(fakeBinDir, "yt-dlp"), 0o755);
|
||||
|
||||
// Fake ffmpeg: writes a small nut-ish payload to stdout so the returned
|
||||
// Readable actually emits data (the merge path in mergeScreenStreams).
|
||||
const ffShim = `#!/usr/bin/env bash
|
||||
# Fake ffmpeg — ignore args, emit a few bytes so consumers see a live stream.
|
||||
head -c 4096 /dev/urandom
|
||||
exit 0
|
||||
`;
|
||||
writeFileSync(join(fakeBinDir, "ffmpeg"), ffShim);
|
||||
chmodSync(join(fakeBinDir, "ffmpeg"), 0o755);
|
||||
|
||||
process.env.PATH = `${fakeBinDir}:${process.env.PATH}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (fakeBinDir) {
|
||||
rmSync(fakeBinDir, { recursive: true, force: true });
|
||||
}
|
||||
process.env.PATH = realPath;
|
||||
});
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
function writeFakeJson(payload: Record<string, unknown>): string {
|
||||
const p = join(
|
||||
tmpdir(),
|
||||
`gmw-fake-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
||||
);
|
||||
writeFileSync(p, JSON.stringify(payload));
|
||||
return p;
|
||||
}
|
||||
|
||||
function dashPairInfo(videoUrl: string, audioUrl: string) {
|
||||
return {
|
||||
url: null,
|
||||
acodec: "none", // top-level is not a single merged format
|
||||
vcodec: "av01",
|
||||
requested_formats: [
|
||||
{
|
||||
format_id: "136",
|
||||
vcodec: "avc1.4d401f",
|
||||
acodec: "none",
|
||||
url: videoUrl,
|
||||
},
|
||||
{ format_id: "140", vcodec: "none", acodec: "mp4a.40.2", url: audioUrl },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── tests ─────────────────────────────────────────────────────────────────────
|
||||
describe("getDirectScreenInput", () => {
|
||||
it("returns the single merged progressive URL when the info has one", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
||||
url: "https://cdn.example/progressive.mp4",
|
||||
acodec: "mp4a.40.2",
|
||||
vcodec: "avc1",
|
||||
});
|
||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||
expect(result).toBe("https://cdn.example/progressive.mp4");
|
||||
});
|
||||
|
||||
it("returns a live Readable when a video+audio DASH pair must be merged", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson(
|
||||
dashPairInfo(
|
||||
"https://cdn.example/video.mp4",
|
||||
"https://cdn.example/audio.m4a",
|
||||
),
|
||||
);
|
||||
const result = await getDirectScreenInput("https://youtu.be/abc");
|
||||
expect(Readable.isReadable(result)).toBe(true);
|
||||
|
||||
// The fake ffmpeg emits bytes; collect a chunk to prove the stream flows.
|
||||
const bytes = await new Promise<number>((resolve, reject) => {
|
||||
const stream = result as Readable;
|
||||
let got = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
got += chunk.length;
|
||||
});
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(got));
|
||||
stream.resume();
|
||||
});
|
||||
expect(bytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects when yt-dlp returns neither a merged URL nor a format pair", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_JSON = writeFakeJson({
|
||||
url: null,
|
||||
acodec: "none",
|
||||
vcodec: "none",
|
||||
requested_formats: [],
|
||||
});
|
||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
||||
/neither a merged progressive URL nor a video\+audio/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects when yt-dlp exits non-zero", async () => {
|
||||
process.env.GMW_FAKE_YTDLP_JSON = "/nonexistent/gmw-fake.json";
|
||||
await expect(getDirectScreenInput("https://youtu.be/abc")).rejects.toThrow(
|
||||
/screen input resolution exited with code 1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Term glossary — pure extraction/formatting tests (no DB, Redis, or network)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractGlossaryTerms,
|
||||
formatTermGlossary,
|
||||
} from "../src/modules/ai-moderation/termGlossary.js";
|
||||
|
||||
describe("extractGlossaryTerms — filters out words the LLM already knows", () => {
|
||||
it("returns [] for common conversational Indonesian", () => {
|
||||
const terms = extractGlossaryTerms(
|
||||
["anjay mabar yuk gaskeun gua gas", "iya bener banget sih"],
|
||||
{ maxTerms: 6 },
|
||||
);
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts uncommon/foreign-looking words and skips stopwords + brands", () => {
|
||||
const terms = extractGlossaryTerms(
|
||||
[
|
||||
"tadi gua baca soal tempeh di discord",
|
||||
"kayaknya istilahnya shirkmaxxing deh",
|
||||
],
|
||||
{ maxTerms: 6 },
|
||||
);
|
||||
// "tempeh" and "shirkmaxxing" are candidates; "discord"/"istilahnya" are not
|
||||
expect(terms).toContain("tempeh");
|
||||
expect(terms).toContain("shirkmaxxing");
|
||||
expect(terms).not.toContain("discord");
|
||||
expect(terms).not.toContain("istilahnya");
|
||||
});
|
||||
|
||||
it("strips URLs, mentions, and custom emoji before extracting", () => {
|
||||
const terms = extractGlossaryTerms(
|
||||
["cek https://example.com/foo <@123456> <:hadeh:987> kafircel"],
|
||||
{ maxTerms: 6 },
|
||||
);
|
||||
expect(terms).toContain("kafircel");
|
||||
expect(terms.some((t) => /example|hadeh|123/.test(t))).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts quoted phrases as a single term", () => {
|
||||
const terms = extractGlossaryTerms(['dia bilang "kostum hewan" itu aneh'], {
|
||||
maxTerms: 6,
|
||||
});
|
||||
expect(terms).toContain("kostum hewan");
|
||||
});
|
||||
|
||||
it("skips repeated-char noise like wkwkwk and aaaaa", () => {
|
||||
const terms = extractGlossaryTerms(["wkwkwkwk aaaaa xixixi"], {
|
||||
maxTerms: 6,
|
||||
});
|
||||
expect(terms).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects maxTerms and prioritizes proper nouns", () => {
|
||||
const terms = extractGlossaryTerms(
|
||||
["aku suka Xenogears sama Chrono Cross terus Yakuza"],
|
||||
{ maxTerms: 2 },
|
||||
);
|
||||
expect(terms.length).toBeLessThanOrEqual(2);
|
||||
expect(terms[0]).toBe("Xenogears");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTermGlossary — XML block shape", () => {
|
||||
it("returns '' for an empty map", () => {
|
||||
expect(formatTermGlossary(new Map())).toBe("");
|
||||
});
|
||||
|
||||
it("wraps definitions in <term_glossary> with escaped attributes/content", () => {
|
||||
const block = formatTermGlossary(
|
||||
new Map([
|
||||
[
|
||||
"kafircel",
|
||||
{
|
||||
term: "kafircel",
|
||||
definition: "sebutan <memes> untuk & orang",
|
||||
sourceUrl: "https://id.wikipedia.org/wiki/Mem",
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
expect(block).toContain("<term_glossary>");
|
||||
expect(block).toContain('<term word="kafircel"');
|
||||
expect(block).toContain("<memes>");
|
||||
expect(block).toContain("&");
|
||||
expect(block).toContain("</term_glossary>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// isNoImageSeenText — vision outputs that claim "no image" must not be cached
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Regression (2026-08-11): the vision model sometimes answered "Maaf, saya
|
||||
// tidak melihat gambar apapun yang terlampir..." and that text was cached as
|
||||
// a VALID vision_llm result. Every later analysis of the same image (same
|
||||
// hash / phash) then hit the poisoned cache and the moderation LLM wrote
|
||||
// "lampiran yang gagal terbaca" — image analysis seemed permanently broken
|
||||
// even though 9router was responding fine.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isNoImageSeenText } from "../src/modules/ai-moderation/visionAnalyzer.js";
|
||||
|
||||
describe("isNoImageSeenText — poisoned vision output detection", () => {
|
||||
it("detects the exact poisoned strings seen in production", () => {
|
||||
expect(
|
||||
isNoImageSeenText(
|
||||
"Maaf, saya tidak melihat gambar apapun yang terlampir dalam pesan Anda. Mohon kirimkan ulang gambarnya agar saya bisa mendeskripsikannya secara objektif dan spesifik.",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isNoImageSeenText(
|
||||
"Tidak ada gambar yang terlampir. Tidak bisa deskripsi tanpa input visual.",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects English variants", () => {
|
||||
expect(isNoImageSeenText("I cannot see any image in this message")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isNoImageSeenText("No image provided")).toBe(true);
|
||||
expect(isNoImageSeenText("there is no image attached")).toBe(true);
|
||||
expect(isNoImageSeenText("I don't see an image")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag legitimate image descriptions", () => {
|
||||
expect(
|
||||
isNoImageSeenText(
|
||||
"Gambar ini menampilkan dua panel komik, seorang gadis berambut biru tersipu saat dipuji.",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isNoImageSeenText("Ini adalah screenshot dari sebuah website rekrutmen."),
|
||||
).toBe(false);
|
||||
expect(isNoImageSeenText("Emoji menampilkan ekspresi wajah tertawa.")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isNoImageSeenText(null)).toBe(false);
|
||||
expect(isNoImageSeenText(undefined)).toBe(false);
|
||||
expect(isNoImageSeenText("")).toBe(false);
|
||||
});
|
||||
});
|
||||
+30
-13
@@ -3,26 +3,41 @@
|
||||
Next.js 16 (App Router), React 19, TypeScript strict, Tailwind v4, shadcn/ui, base-ui.
|
||||
|
||||
Key points:
|
||||
- **All pages** are `"use client"` — the dashboard is fully client-rendered
|
||||
- **API client** at `src/lib/api/` — fetch-based, covers all 30+ backend endpoints
|
||||
- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event subscriptions
|
||||
- **Static export**: `output: "export"` in next.config.ts, served via nginx
|
||||
- **Server-side rendered (SSR)** — `output: "standalone"` in next.config.ts; pages
|
||||
are React Server Components that fetch initial data from the backend at
|
||||
render-time, then hydrate interactive client components (no blank-spinner-first-load).
|
||||
- **Server data layer** at `src/lib/api/server.ts` — server-only fetchers that
|
||||
call the backend directly via `GMW_BACKEND_URL` (default `http://127.0.0.1:4001`).
|
||||
Never import from a client component.
|
||||
- **API client** at `src/lib/api/client.ts` — browser-side fetch for live ops,
|
||||
same-origin through the reverse proxy.
|
||||
- **WebSocket** at `src/lib/ws/` — auto-reconnecting client with typed event
|
||||
subscriptions. Realtime state (voice, media, messages) stays client-side.
|
||||
- **Shared realtime state is server-authoritative**: the backend aggregates the
|
||||
gateway's `voice_active_user` deltas into a live speaker snapshot
|
||||
(`GET /api/voice/status` → `activeSpeakers`, plus WS `voice_state` sent on
|
||||
connect). Every browser converges on the same voice state; `useSpeakers`
|
||||
seeds from the server snapshot instead of accumulating per-tab.
|
||||
- **No authentication**: all endpoints are public
|
||||
|
||||
## Data flow (match these — do not invent endpoints)
|
||||
|
||||
```
|
||||
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ frontend
|
||||
↑ REST /api/* (same-origin)
|
||||
└ WS /ws (events + PCM binary)
|
||||
Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→ Next.js SSR
|
||||
↑ REST /api/* (server: GMW_BACKEND_URL
|
||||
└ WS /ws (events + PCM binary) 127.0.0.1:4001)
|
||||
↑ browser WS (same-origin /ws)
|
||||
```
|
||||
|
||||
- **Base URL**: API + WS default to same-origin. `gmw-proxy` nginx (:4009)
|
||||
proxies `/api` and `/ws` to the backend on :4001. Public host:
|
||||
`imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
|
||||
- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL`
|
||||
(e.g. https://imphnen.asepharyana.my.id).
|
||||
- **Never hardcode a host** in api/ws clients — same-origin or env override only.
|
||||
- **Rendering**: `gmw-proxy` nginx (:4009) proxies `/` → Next standalone server
|
||||
(:4017, `node .next/standalone/server.js`), and `/api` + `/ws` → backend :4001.
|
||||
Public host: `imphnen.asepharyana.my.id` (Caddy reverse proxy → :4009).
|
||||
- **SSR seed pattern**: each `page.tsx` is a server component that fetches via
|
||||
`src/lib/api/server.ts` and passes typed data to a `view.tsx` client
|
||||
component; the hooks take `initialData` as SWR `fallbackData`.
|
||||
- Local dev overrides: `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_WS_URL` for the
|
||||
browser; `GMW_BACKEND_URL` for the server.
|
||||
- **Never hardcode a host** in api/ws clients — same-origin/env or GMW_BACKEND_URL only.
|
||||
|
||||
## Backend response shapes that bite
|
||||
|
||||
@@ -34,3 +49,5 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→
|
||||
- Dashboard endpoints: `/api/dashboard/stats|users|channels` (+ `/:id` details).
|
||||
- Channel/guild names live inside `message.metadata` JSON (`channel.channelName`),
|
||||
not top-level.
|
||||
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
|
||||
snapshot from `src/modules/voice/live-speaker.ts` on the backend).
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactCompiler: true,
|
||||
output: "export",
|
||||
output: "standalone",
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -10,27 +10,16 @@
|
||||
"format": "biome format --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@shadcn/react": "^0.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.27.0",
|
||||
"motion": "^12.0.0",
|
||||
"next": "16.2.12",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.12.2",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.4.2",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"three": "^0.180.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
@@ -38,8 +27,10 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/three": "^0.180.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"puppeteer-core": "^25.7.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+251
-3004
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
import { AnalysisView } from "./view";
|
||||
|
||||
import { SearchPanel } from "@/components/analysis/search-panel";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in-up">
|
||||
<SearchPanel />
|
||||
</div>
|
||||
);
|
||||
return <AnalysisView />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||
import { EmptyState, LoadingState, SectionHeader } from "@/components/shared";
|
||||
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
||||
import type { AiStatus } from "@/lib/types";
|
||||
|
||||
function aiTone(
|
||||
s?: AiStatus | null,
|
||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
|
||||
function formatAnalysisDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function AnalysisView() {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: channels } = useChannels();
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
query ? "amber" : "signal",
|
||||
0.3,
|
||||
query ? "analyzing" : "search",
|
||||
);
|
||||
}, [query, ambient]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="size-5 text-signal" />
|
||||
<div>
|
||||
<div className="eyebrow">Semantic search</div>
|
||||
<h2 className="display text-2xl text-ink">Search the archive</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-4">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="h-12 pl-12 text-base"
|
||||
placeholder="Find messages, patterns, flags…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||
<div className="mono mt-2 text-xs text-ink-faint">
|
||||
Type at least 2 characters…
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="results"
|
||||
title="Matches"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{(search.data ?? []).length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{query.trim().length >= 2 && search.isLoading && (
|
||||
<LoadingState label="Scanning" />
|
||||
)}
|
||||
{(search.data ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search className="size-7" />}
|
||||
title="No matches yet"
|
||||
description="Run a search to surface messages across the guild."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{(search.data ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
{m.ai_status && (
|
||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||
{m.ai_analysis_duration_ms &&
|
||||
m.ai_analysis_duration_ms > 0
|
||||
? `${m.ai_status} · ${formatAnalysisDuration(m.ai_analysis_duration_ms)}`
|
||||
: m.ai_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<div className="space-y-5 lg:col-span-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-signal" /> Top reactors
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div
|
||||
key={r.user_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-ink">{r.username}</span>
|
||||
<span className="mono text-xs text-signal">
|
||||
+{r.net_count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="channels"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-signal" /> Top channels
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{(channels ?? []).slice(0, 6).map((c) => (
|
||||
<div
|
||||
key={c.channel_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className="flex-1 truncate text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{c.total_messages}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(channels ?? []).length === 0 && (
|
||||
<div className="py-4 text-center text-xs text-ink-faint">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +1,18 @@
|
||||
"use client";
|
||||
import { getActivity, getDashboardStats } from "@/lib/api/server";
|
||||
import { DashboardView } from "./view";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Hash,
|
||||
Heart,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ActivityChart } from "@/components/dashboard/activity-chart";
|
||||
import { ChannelsSection } from "@/components/dashboard/channels-section";
|
||||
import { HourlyActivityChart } from "@/components/dashboard/hourly-activity-chart";
|
||||
import { ModerationDonut } from "@/components/dashboard/moderation-donut";
|
||||
import { ReactionsSection } from "@/components/dashboard/reactions-section";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { TopChannelsChart } from "@/components/dashboard/top-channels-chart";
|
||||
import { UsersSection } from "@/components/dashboard/users-section";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useActivity, useStats } from "@/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DashboardTab = "stats" | "users" | "channels" | "reactions";
|
||||
|
||||
const DAY_RANGES = [7, 14, 30] as const;
|
||||
|
||||
const MODERATION_COLORS: Record<string, string> = {
|
||||
Clean: "oklch(0.72 0.16 155)",
|
||||
Flagged: "oklch(0.62 0.19 25)",
|
||||
Warned: "oklch(0.78 0.15 80)",
|
||||
Error: "oklch(0.55 0.02 245)",
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [tab, setTab] = useState<DashboardTab>("stats");
|
||||
const [days, setDays] = useState<number>(14);
|
||||
const { data: stats, isLoading, error, mutate: refetch } = useStats();
|
||||
const { data: activity, isLoading: activityLoading } = useActivity(days);
|
||||
|
||||
const subNavTabs = [
|
||||
{ id: "stats", label: "Stats", icon: <Hash className="size-3" /> },
|
||||
{ id: "users", label: "Users", icon: <Users className="size-3" /> },
|
||||
{ id: "channels", label: "Channels", icon: <Hash className="size-3" /> },
|
||||
{ id: "reactions", label: "Reactions", icon: <Heart className="size-3" /> },
|
||||
];
|
||||
|
||||
const moderationData = stats
|
||||
? [
|
||||
{
|
||||
name: "Clean",
|
||||
value: stats.total_clean,
|
||||
color: MODERATION_COLORS.Clean,
|
||||
},
|
||||
{
|
||||
name: "Flagged",
|
||||
value: stats.total_flagged,
|
||||
color: MODERATION_COLORS.Flagged,
|
||||
},
|
||||
{
|
||||
name: "Warned",
|
||||
value: stats.total_warned,
|
||||
color: MODERATION_COLORS.Warned,
|
||||
},
|
||||
{
|
||||
name: "Error",
|
||||
value: stats.total_error,
|
||||
color: MODERATION_COLORS.Error,
|
||||
},
|
||||
].filter((d) => d.value > 0)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={subNavTabs}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as DashboardTab)}
|
||||
/>
|
||||
|
||||
{tab === "stats" && (
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading || !stats ? (
|
||||
<LoadingSkeleton count={6} height="h-28" columns={3} />
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
label="Total Messages"
|
||||
value={stats.total_messages}
|
||||
icon={Hash}
|
||||
/>
|
||||
<StatCard
|
||||
label="Today"
|
||||
value={stats.today_messages}
|
||||
icon={Clock}
|
||||
/>
|
||||
<StatCard
|
||||
label="Users"
|
||||
value={stats.total_users}
|
||||
icon={Users}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active 24h"
|
||||
value={stats.active_users_24h}
|
||||
icon={Sparkles}
|
||||
/>
|
||||
<StatCard
|
||||
label="Flagged"
|
||||
value={stats.total_flagged}
|
||||
icon={AlertCircle}
|
||||
variant="danger"
|
||||
/>
|
||||
<StatCard
|
||||
label="Clean"
|
||||
value={stats.total_clean}
|
||||
icon={Shield}
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{DAY_RANGES.map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
onClick={() => setDays(range)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-[10px] font-medium uppercase tracking-wide rounded-md transition-colors",
|
||||
days === range
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-text-secondary/60 hover:text-text-primary",
|
||||
)}
|
||||
>
|
||||
{range}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="xl:col-span-2">
|
||||
{activityLoading ? (
|
||||
<LoadingSkeleton count={1} height="h-56" />
|
||||
) : (
|
||||
<ActivityChart data={activity?.daily} />
|
||||
)}
|
||||
</div>
|
||||
<ModerationDonut data={moderationData} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="xl:col-span-2">
|
||||
{activityLoading ? (
|
||||
<LoadingSkeleton count={1} height="h-40" />
|
||||
) : (
|
||||
<HourlyActivityChart data={activity?.hourly} />
|
||||
)}
|
||||
</div>
|
||||
<TopChannelsChart
|
||||
data={stats.top_channels.map((c) => ({
|
||||
name: c.channel_name ?? c.channel_id,
|
||||
count: c.message_count,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "users" && <UsersSection />}
|
||||
|
||||
{tab === "channels" && <ChannelsSection />}
|
||||
|
||||
{tab === "reactions" && <ReactionsSection />}
|
||||
</div>
|
||||
);
|
||||
export default async function DashboardPage() {
|
||||
let stats: Awaited<ReturnType<typeof getDashboardStats>> | undefined;
|
||||
let activity: Awaited<ReturnType<typeof getActivity>> | undefined;
|
||||
try {
|
||||
[stats, activity] = await Promise.all([
|
||||
getDashboardStats(),
|
||||
getActivity(14),
|
||||
]);
|
||||
} catch {
|
||||
// Backend unavailable — client hooks will surface the error state.
|
||||
}
|
||||
return <DashboardView initialStats={stats} initialActivity={activity} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Radio,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { ErrorState, LoadingState } from "@/components/shared";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||
import {
|
||||
useActivity,
|
||||
useStats,
|
||||
useTopReactions,
|
||||
useTopReactors,
|
||||
} from "@/hooks";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
function deriveSignal(stats?: DashboardStats) {
|
||||
if (!stats) return { tone: "signal" as const, label: "nominal" };
|
||||
const total = stats.total_flagged + stats.total_clean || 1;
|
||||
const ratio = stats.total_flagged / total;
|
||||
if (stats.moderation_overview.error > 0)
|
||||
return { tone: "vermilion" as const, label: "moderation fault" };
|
||||
if (ratio > 0.25)
|
||||
return { tone: "vermilion" as const, label: "elevated flags" };
|
||||
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
|
||||
return { tone: "signal" as const, label: "nominal" };
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
initialStats,
|
||||
initialActivity,
|
||||
}: {
|
||||
initialStats?: DashboardStats;
|
||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||
}) {
|
||||
const { data: stats, isLoading, error } = useStats(initialStats);
|
||||
const { data: activity } = useActivity(14, initialActivity as never);
|
||||
const { data: reactors } = useTopReactors();
|
||||
const { data: reactions } = useTopReactions();
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
const s = deriveSignal(stats);
|
||||
ambient.set(
|
||||
s.tone,
|
||||
0.3 + Math.min(0.5, (stats?.today_flagged ?? 0) / 50),
|
||||
s.label,
|
||||
);
|
||||
}, [stats, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading grid" />;
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
|
||||
const s = stats;
|
||||
const total = s.total_flagged + s.total_clean || 1;
|
||||
const cleanRatio = s.total_clean / total;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Hero */}
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||
<h2 className="display text-[2.6rem] leading-none text-ink glow-signal">
|
||||
Ambient Field
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-ink-soft">
|
||||
Real-time moderation, voice & media presence across the monitored
|
||||
guild. {formatNumber(s.total_messages)} messages captured.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ink-soft">
|
||||
<Radio className="size-4 text-signal animate-breathe" />
|
||||
<span className="mono text-xs uppercase tracking-wider">
|
||||
{deriveSignal(s).label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Messages"
|
||||
value={formatNumber(s.total_messages)}
|
||||
tone="signal"
|
||||
icon={<MessageSquare className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||
hint={`${s.today_flagged} today`}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Active 24h"
|
||||
value={formatNumber(s.active_users_24h)}
|
||||
tone="signal"
|
||||
icon={<Users className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Voice clips"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
icon={<Mic className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
{/* Activity */}
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="14-day signal"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-signal" /> messages
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-vermilion" /> flagged
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{activity ? (
|
||||
<AreaActivity daily={activity.daily} />
|
||||
) : (
|
||||
<LoadingState label="streaming" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
{/* Two-column: channels + moderation */}
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||
<div className="space-y-2.5">
|
||||
{s.top_channels.slice(0, 7).map((c) => {
|
||||
const pct =
|
||||
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
||||
100;
|
||||
return (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="w-40 truncate text-sm text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-14 text-right text-xs text-ink-faint">
|
||||
{formatNumber(c.message_count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||
<div className="flex items-center gap-5">
|
||||
<RadialGauge
|
||||
value={cleanRatio}
|
||||
tone={
|
||||
cleanRatio > 0.8
|
||||
? "signal"
|
||||
: cleanRatio > 0.6
|
||||
? "amber"
|
||||
: "vermilion"
|
||||
}
|
||||
label={`${Math.round(cleanRatio * 100)}%`}
|
||||
sublabel="clean"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
<Row
|
||||
icon={<ShieldAlert className="size-4 text-signal" />}
|
||||
label="Clean"
|
||||
value={formatNumber(s.total_clean)}
|
||||
/>
|
||||
<Row
|
||||
icon={<Flag className="size-4 text-vermilion" />}
|
||||
label="Flagged"
|
||||
value={formatNumber(s.total_flagged)}
|
||||
/>
|
||||
<Row
|
||||
icon={<Activity className="size-4 text-amber" />}
|
||||
label="Warned"
|
||||
value={formatNumber(s.total_warned)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||
<Mini
|
||||
label="pending"
|
||||
value={s.moderation_overview.pending}
|
||||
tone="amber"
|
||||
/>
|
||||
<Mini
|
||||
label="processing"
|
||||
value={s.moderation_overview.processing}
|
||||
tone="signal"
|
||||
/>
|
||||
<Mini
|
||||
label="errors"
|
||||
value={s.moderation_overview.error}
|
||||
tone="vermilion"
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
|
||||
{/* Reactors + reactions */}
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||
<div className="space-y-2">
|
||||
{(reactors ?? []).slice(0, 6).map((r, i) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<span className="flex-1 truncate text-sm text-ink">
|
||||
{r.username}
|
||||
</span>
|
||||
<span className="mono text-xs text-signal">
|
||||
+{formatNumber(r.net_count)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||
<div className="space-y-3">
|
||||
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||
<div key={m.message_id} className="flex items-start gap-3">
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||
<span
|
||||
key={`${m.message_id}-${i}`}
|
||||
className="text-lg leading-none"
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">
|
||||
{m.content || "(no text)"}
|
||||
</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono text-xs text-ink-soft">
|
||||
{m.reaction_count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
{icon}
|
||||
<span className="flex-1 text-ink-soft">{label}</span>
|
||||
<span className="mono text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "signal" | "amber" | "vermilion";
|
||||
}) {
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
return (
|
||||
<div>
|
||||
<div className={`display text-xl ${color}`}>{value}</div>
|
||||
<div className="eyebrow mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyHint() {
|
||||
return (
|
||||
<div className="py-6 text-center text-xs text-ink-faint">
|
||||
Awaiting data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { SWRConfig } from "swr";
|
||||
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||
import {
|
||||
ChatbotProvider,
|
||||
useChatbot,
|
||||
} from "@/components/chatbot/chatbot-context";
|
||||
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||
import { TopNav } from "@/components/layout/top-nav";
|
||||
import { MiniPlayer } from "@/components/media/mini-player";
|
||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||
import { useWebSocket, WsProvider } from "@/lib/ws/context";
|
||||
|
||||
function ChatbotGuildSync({ guildId }: { guildId: string }) {
|
||||
const { setGuildId } = useChatbot();
|
||||
|
||||
useEffect(() => {
|
||||
setGuildId(guildId);
|
||||
}, [guildId, setGuildId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChatbotExpressionSync() {
|
||||
const ws = useWebSocket();
|
||||
const { setExpression } = useChatbot();
|
||||
useEffect(() => {
|
||||
const unsub1 = ws.on("message_created", (data: any) => {
|
||||
if (data.ai_status === "flagged" || data.ai_status === "warn") {
|
||||
setExpression("surprise");
|
||||
setTimeout(() => setExpression("idle"), 2000);
|
||||
}
|
||||
});
|
||||
|
||||
const unsub2 = ws.on("voice_active_user", () => {
|
||||
setExpression("listening");
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub1();
|
||||
unsub2();
|
||||
};
|
||||
}, [ws, setExpression]);
|
||||
|
||||
return null;
|
||||
}
|
||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||
import { CommandPalette } from "@/components/command/command-palette";
|
||||
import { AppFrame } from "@/components/shell";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [guildId, setGuildId] = useState("");
|
||||
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10_000,
|
||||
shouldRetryOnError: (err) =>
|
||||
(err as { statusCode?: number })?.statusCode !== 404,
|
||||
}}
|
||||
>
|
||||
<AmbientProvider>
|
||||
<WsProvider>
|
||||
<MediaPlayerProvider>
|
||||
<ChatbotProvider>
|
||||
<ChatbotGuildSync guildId={guildId} />
|
||||
<ChatbotExpressionSync />
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<TopNav />
|
||||
<HiddenSidebar
|
||||
guildId={guildId}
|
||||
onGuildChange={(g) => setGuildId(g ?? "")}
|
||||
/>
|
||||
|
||||
{/* Sub-nav space — filled per-page */}
|
||||
<div className="pt-11">
|
||||
<main className="p-4 md:p-6 pb-24 md:pb-6 max-w-[1600px] mx-auto">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="size-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MobileNav />
|
||||
<MiniPlayer />
|
||||
<ChatbotContainer />
|
||||
</div>
|
||||
</ChatbotProvider>
|
||||
</MediaPlayerProvider>
|
||||
<AppFrame>{children}</AppFrame>
|
||||
<Chatbot />
|
||||
<CommandPalette />
|
||||
</WsProvider>
|
||||
</SWRConfig>
|
||||
</AmbientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
import { getMediaStatus } from "@/lib/api/server";
|
||||
import { MediaView } from "./view";
|
||||
|
||||
import { MusicPlayer } from "@/components/media/music-player";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function MediaPage() {
|
||||
const ws = useWebSocket();
|
||||
|
||||
return (
|
||||
<div className="space-y-5 animate-fade-in-up">
|
||||
<MusicPlayer ws={ws} />
|
||||
</div>
|
||||
);
|
||||
export default async function MediaPage() {
|
||||
let status: import("@/lib/types").MediaState | undefined;
|
||||
try {
|
||||
status = await getMediaStatus();
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <MediaView initialStatus={status} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ListMusic,
|
||||
Play,
|
||||
Radio,
|
||||
Repeat,
|
||||
SkipForward,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||
import { ErrorState, LoadingState, SectionHeader } from "@/components/shared";
|
||||
import {
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
useMediaSkip,
|
||||
useMediaState,
|
||||
useMediaStop,
|
||||
useMediaWsSync,
|
||||
} from "@/hooks";
|
||||
import type { MediaState } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||
const ws = useWebSocket();
|
||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
||||
const queue = useMediaQueue();
|
||||
const skip = useMediaSkip();
|
||||
const stop = useMediaStop();
|
||||
const loop = useMediaLoop();
|
||||
useMediaWsSync(ws);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const playing = media?.playing ?? false;
|
||||
const current = media?.current ?? null;
|
||||
const queueList = media?.queue ?? [];
|
||||
|
||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
tone,
|
||||
playing ? 0.5 : 0.25,
|
||||
playing ? "now playing" : "media idle",
|
||||
);
|
||||
}, [tone, playing, ambient]);
|
||||
|
||||
const onPlay = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) {
|
||||
toast({ title: "Enter a media URL", tone: "vermilion" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await queue.mutateAsync({ url: u, mode: "music" });
|
||||
setUrl("");
|
||||
toast({ title: "Queued", tone: "signal" });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Queue failed",
|
||||
description: String(e),
|
||||
tone: "vermilion",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !media) return <ErrorState error={error} />;
|
||||
if (!media && isLoading) return <LoadingState label="Reading deck" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<GlassPanel glow className="relative overflow-hidden">
|
||||
<div className="scan-line absolute inset-x-0 top-0" />
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||
>
|
||||
<div className="flex size-28 items-center justify-center rounded-full bg-canvas/60">
|
||||
<ListMusic className="size-10 text-signal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="eyebrow mb-1">Now playing</div>
|
||||
<h2 className="display truncate text-2xl text-ink">
|
||||
{current?.title ?? "Nothing queued"}
|
||||
</h2>
|
||||
{current?.source && (
|
||||
<div className="mono mt-1 truncate text-xs text-ink-faint">
|
||||
{current.source}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onPlay}
|
||||
disabled={queue.isPending}
|
||||
>
|
||||
<Play className="size-4" /> Queue & play
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => skip.mutate()}
|
||||
disabled={skip.isPending}
|
||||
>
|
||||
<SkipForward className="size-4" /> Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
>
|
||||
<Square className="size-4" /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant={media?.loop ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => loop.mutate(!media?.loop)}
|
||||
aria-pressed={!!media?.loop}
|
||||
>
|
||||
<Repeat className="size-4" /> Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Paste a YouTube / music URL…"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel>
|
||||
<SectionHeader
|
||||
eyebrow="up next"
|
||||
title="Queue"
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{queueList.length} tracks
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{queueList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<Radio className="size-6 text-ink-faint" />
|
||||
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||
<div className="text-xs text-ink-faint">
|
||||
Paste a URL above to start playback.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{queueList.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
|
||||
>
|
||||
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-ink">{item.title}</div>
|
||||
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||
{item.source}
|
||||
</div>
|
||||
</div>
|
||||
<span className="pill">{item.mode ?? "music"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,348 +1,20 @@
|
||||
"use client";
|
||||
import { getConfig, getGuilds } from "@/lib/api/server";
|
||||
import { MessagesView } from "./view";
|
||||
|
||||
import { Flag, Image, Loader2, Search } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { Lightbox } from "@/components/messages/lightbox";
|
||||
import { extractFirstImage } from "@/components/messages/message-card";
|
||||
import { MessageDetailView } from "@/components/messages/message-detail-view";
|
||||
import { MessageList } from "@/components/messages/message-list";
|
||||
import { SearchOverlay } from "@/components/messages/search-overlay";
|
||||
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useImages,
|
||||
useLoadMore,
|
||||
useMessageDetail,
|
||||
useMessages,
|
||||
useMessagesHasMore,
|
||||
useMessagesWsSync,
|
||||
useReview,
|
||||
useTextChannels,
|
||||
} from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type MessagesTab = "all" | "images" | "review";
|
||||
|
||||
export default function MessagesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [guildId, setGuildId] = useState(searchParams.get("guild") || "");
|
||||
const [selectedChannel, setSelectedChannel] = useState(
|
||||
searchParams.get("channel") || "",
|
||||
);
|
||||
const [detailId, setDetailId] = useState<string | null>(
|
||||
searchParams.get("selected"),
|
||||
);
|
||||
const [tab, setTab] = useState<MessagesTab>(
|
||||
(searchParams.get("tab") as MessagesTab) || "all",
|
||||
);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [lightbox, setLightbox] = useState<{
|
||||
images: Array<{ src: string; alt?: string }>;
|
||||
index: number;
|
||||
} | null>(null);
|
||||
|
||||
const ws = useWebSocket();
|
||||
const { data: channels = [] } = useTextChannels(guildId);
|
||||
const {
|
||||
data: messages,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useMessages(guildId, selectedChannel || undefined);
|
||||
const { data: cursorData } = useMessagesHasMore(
|
||||
guildId,
|
||||
selectedChannel || undefined,
|
||||
);
|
||||
const loadMoreMut = useLoadMore();
|
||||
const { data: images } = useImages(guildId);
|
||||
const { data: reviews } = useReview(selectedChannel || undefined);
|
||||
|
||||
const {
|
||||
message: detailMessage,
|
||||
attachments: detailAttachments,
|
||||
loading: detailLoading,
|
||||
} = useMessageDetail(detailId);
|
||||
|
||||
useMessagesWsSync(ws, guildId);
|
||||
|
||||
// Sync state to URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (guildId) params.set("guild", guildId);
|
||||
if (selectedChannel) params.set("channel", selectedChannel);
|
||||
if (detailId) params.set("selected", detailId);
|
||||
if (tab !== "all") params.set("tab", tab);
|
||||
router.replace(`/messages?${params.toString()}`, { scroll: false });
|
||||
}, [guildId, selectedChannel, detailId, tab, router]);
|
||||
|
||||
// Global Cmd+K search trigger
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!cursorData?.cursor || loadMoreMut.isPending) return;
|
||||
loadMoreMut.mutate({
|
||||
guildId,
|
||||
channelId: selectedChannel || undefined,
|
||||
cursor: cursorData.cursor,
|
||||
});
|
||||
}, [cursorData, loadMoreMut, guildId, selectedChannel]);
|
||||
|
||||
const handleGuildChange = useCallback((g: string) => {
|
||||
setGuildId(g);
|
||||
setSelectedChannel("");
|
||||
setDetailId(null);
|
||||
}, []);
|
||||
|
||||
const subNavTabs = [
|
||||
{ id: "all", label: "All", icon: null },
|
||||
{ id: "images", label: "Images", icon: <Image className="size-3" /> },
|
||||
{ id: "review", label: "Review", icon: <Flag className="size-3" /> },
|
||||
];
|
||||
|
||||
const currentMessages = messages ?? [];
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MessagesPage() {
|
||||
let config: import("@/lib/types/guild").AppConfig | undefined;
|
||||
let guilds: import("@/lib/types").Guild[] | undefined;
|
||||
try {
|
||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return (
|
||||
<div className="animate-fade-in-up space-y-4">
|
||||
{/* ── Controls bar ── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<GuildSelector value={guildId} onChange={handleGuildChange} />
|
||||
{channels.length > 0 && (
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onValueChange={(v) => setSelectedChannel(v ?? "")}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-48">
|
||||
<SelectValue placeholder="All channels" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All channels</SelectItem>
|
||||
{channels.map((ch) => (
|
||||
<SelectItem key={ch.id} value={ch.id}>
|
||||
# {ch.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary/60 hover:text-text-primary glass hover:glass-elevated transition-all"
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
Search
|
||||
<span className="hidden font-mono text-[10px] text-text-secondary/30 sm:inline">
|
||||
⌘K
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Sub navigation ── */}
|
||||
<SubNav
|
||||
tabs={subNavTabs}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as MessagesTab)}
|
||||
/>
|
||||
|
||||
{/* ── Split pane ── */}
|
||||
{error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton count={6} height="h-20" />
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
{/* Left pane */}
|
||||
<div
|
||||
className={cn("space-y-2", detailId ? "w-1/2 lg:w-2/5" : "w-full")}
|
||||
>
|
||||
{tab === "all" && (
|
||||
<MessageList
|
||||
messages={currentMessages}
|
||||
selectedId={detailId}
|
||||
onSelect={setDetailId}
|
||||
hasMore={cursorData?.hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
isLoadingMore={loadMoreMut.isPending}
|
||||
/>
|
||||
)}
|
||||
{tab === "images" && (
|
||||
<ImageGrid items={images ?? []} onSelect={setDetailId} />
|
||||
)}
|
||||
{tab === "review" && (
|
||||
<ReviewList items={reviews ?? []} onSelect={setDetailId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane — message detail */}
|
||||
{detailId && (
|
||||
<div className="sticky top-16 hidden w-1/2 self-start md:block lg:w-3/5">
|
||||
{detailLoading ? (
|
||||
<GlassPanel
|
||||
dense
|
||||
className="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-text-secondary/60" />
|
||||
</GlassPanel>
|
||||
) : detailMessage ? (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailId(null)}
|
||||
className="text-xs text-text-secondary/60 hover:text-text-primary transition-colors"
|
||||
>
|
||||
← Back to list
|
||||
</button>
|
||||
<MessageDetailView
|
||||
message={detailMessage}
|
||||
attachments={detailAttachments}
|
||||
onImageClick={(index) => {
|
||||
const imgs = (detailAttachments ?? [])
|
||||
.filter((a) => a.type?.startsWith("image/"))
|
||||
.map((a) => ({
|
||||
src: a.uploaded_url || a.discord_url,
|
||||
alt: a.filename,
|
||||
}));
|
||||
if (imgs.length > 0) {
|
||||
setLightbox({ images: imgs, index });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search overlay ── */}
|
||||
<SearchOverlay
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onSelect={(id) => {
|
||||
setDetailId(id);
|
||||
setTab("all");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Lightbox ── */}
|
||||
{lightbox && (
|
||||
<Lightbox
|
||||
images={lightbox.images}
|
||||
initialIndex={lightbox.index}
|
||||
open
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline ImageGrid (glass-styled) ────────────────
|
||||
|
||||
function ImageGrid({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MessageRecord[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{items.map((item) => {
|
||||
const imgUrl = extractFirstImage(item.metadata);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.id)}
|
||||
className="glass overflow-hidden rounded-lg transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{imgUrl ? (
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt=""
|
||||
className="h-24 w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-24 w-full items-center justify-center text-xs text-text-secondary/40">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Image}
|
||||
title="No images"
|
||||
description="Messages with image attachments will show up here."
|
||||
className="col-span-3"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inline ReviewList (glass-styled) ────────────────
|
||||
|
||||
function ReviewList({
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: MessageRecord[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<GlassCard
|
||||
key={item.id}
|
||||
variant="danger"
|
||||
className="cursor-pointer p-3"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Flag className="mt-0.5 size-3.5 shrink-0 text-accent-purple" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="line-clamp-2 text-xs text-text-secondary">
|
||||
{renderMessageContent(item.content, item.metadata) || item.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Flag}
|
||||
title="No flagged messages"
|
||||
description="Messages flagged by AI moderation will appear here for review."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MessagesView
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Input,
|
||||
Skeleton,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||
import {
|
||||
useMessageDetail,
|
||||
useMessageSearch,
|
||||
useMessages,
|
||||
useMessagesWsSync,
|
||||
} from "@/hooks";
|
||||
import {
|
||||
formatBytes,
|
||||
getMessageChannelLabel,
|
||||
renderMessageContent,
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
function relTime(ts?: number | null) {
|
||||
if (!ts) return "";
|
||||
const d = Date.now() - ts;
|
||||
const m = Math.floor(d / 60000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h`;
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
function aiTone(
|
||||
s?: AiStatus | null,
|
||||
): "signal" | "amber" | "vermilion" | "neutral" {
|
||||
if (s === "clean") return "signal";
|
||||
if (s === "warn") return "amber";
|
||||
if (s === "flagged" || s === "error") return "vermilion";
|
||||
if (s === "processing" || s === "pending") return "neutral";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function MessagesView({
|
||||
initialGuilds,
|
||||
initialGuildId,
|
||||
}: {
|
||||
initialGuilds?: Guild[];
|
||||
initialGuildId?: string | null;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
initialGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||
);
|
||||
const [channelId, setChannelId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const {
|
||||
data: messages,
|
||||
isLoading,
|
||||
error,
|
||||
} = useMessages(guildId ?? "", channelId ?? undefined);
|
||||
useMessagesWsSync(ws, guildId ?? "");
|
||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||
const detail = useMessageDetail(selected);
|
||||
const ambient = useAmbient();
|
||||
|
||||
useEffect(() => {
|
||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
||||
}, [query, ambient]);
|
||||
|
||||
const searching = query.trim().length >= 2;
|
||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||
<GuildChannelPicker
|
||||
mode="text"
|
||||
guildsInitial={initialGuilds}
|
||||
guildId={guildId}
|
||||
channelId={channelId}
|
||||
onChange={(g, c) => {
|
||||
setGuildId(g);
|
||||
setChannelId(c);
|
||||
setSelected(null);
|
||||
}}
|
||||
/>
|
||||
<div className="relative ml-auto w-64">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search messages…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow={searching ? "results" : "live feed"}
|
||||
title={searching ? `“${query}”` : "Messages"}
|
||||
action={
|
||||
<span className="mono text-xs text-ink-faint">
|
||||
{list.length} shown
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{error && !messages ? (
|
||||
<ErrorState error={error} />
|
||||
) : isLoading && !messages ? (
|
||||
<LoadingState label="Capturing" />
|
||||
) : list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<MessageSquare className="size-7" />}
|
||||
title="No messages"
|
||||
description="Pick a guild to begin, or run a search."
|
||||
/>
|
||||
) : (
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{list.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(m.id)}
|
||||
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||
selected === m.id
|
||||
? "border-signal/40 bg-signal/8"
|
||||
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-ink">
|
||||
{m.username}
|
||||
</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)}
|
||||
</span>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{relTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||
{renderMessageContent(m.content, m.metadata) || (
|
||||
<span className="italic text-ink-faint">
|
||||
(empty / embed)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AiBadge
|
||||
status={m.ai_status}
|
||||
durationMs={m.ai_analysis_duration_ms}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
title="Select a message"
|
||||
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||
/>
|
||||
) : detail.loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-12" />
|
||||
</div>
|
||||
) : detail.message ? (
|
||||
<MessageDetail
|
||||
m={detail.message}
|
||||
attachments={detail.attachments}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="Not found" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiBadge({
|
||||
status,
|
||||
durationMs,
|
||||
}: {
|
||||
status?: AiStatus | null;
|
||||
durationMs?: number | null;
|
||||
}) {
|
||||
if (!status) return null;
|
||||
const tone = aiTone(status);
|
||||
const icon =
|
||||
status === "clean" ? (
|
||||
<CheckCircle2 className="size-3" />
|
||||
) : status === "flagged" ? (
|
||||
<ShieldAlert className="size-3" />
|
||||
) : status === "warn" ? (
|
||||
<AlertTriangle className="size-3" />
|
||||
) : status === "processing" || status === "pending" ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="size-3" />
|
||||
);
|
||||
const label =
|
||||
durationMs && durationMs > 0
|
||||
? `${status} · ${formatDuration(durationMs)}`
|
||||
: status;
|
||||
|
||||
return (
|
||||
<Badge tone={tone} dot={status === "processing" || status === "pending"}>
|
||||
{icon}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** Human-readable analysis duration, e.g. 850ms / 1.2s / 3.4s. */
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function MessageDetail({
|
||||
m,
|
||||
attachments,
|
||||
}: {
|
||||
m: MessageRecord;
|
||||
attachments: import("@/lib/types").AttachmentRecord[];
|
||||
}) {
|
||||
const flags = safeParseJsonArray(m.ai_moderation_flags);
|
||||
const cats = safeParseJsonArray(m.ai_categories);
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||
<div>
|
||||
<div className="font-semibold text-ink">{m.username}</div>
|
||||
<div className="mono text-[0.65rem] text-ink-faint">
|
||||
{getMessageChannelLabel(m)} · {relTime(m.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<AiBadge
|
||||
status={m.ai_status}
|
||||
durationMs={m.ai_analysis_duration_ms}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||
"(no text)"}
|
||||
</div>
|
||||
|
||||
{m.ai_analysis && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1">AI analysis</div>
|
||||
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||
{m.ai_analysis}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(flags.length > 0 || cats.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
{cats.map((c) => (
|
||||
<Badge key={c} tone="amber">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div>
|
||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{attachments.map((a) => (
|
||||
<a
|
||||
key={a.id}
|
||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<ImageIcon className="size-3.5 text-signal" />
|
||||
<span className="flex-1 truncate">{a.filename}</span>
|
||||
<span className="mono text-ink-faint">
|
||||
{formatBytes(a.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
import { getModerationActions, getModerationStats } from "@/lib/api/server";
|
||||
import { ModerationView } from "./view";
|
||||
|
||||
import { ModerationSection } from "@/components/moderation/moderation-section";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ModerationPage() {
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<ModerationSection />
|
||||
</div>
|
||||
);
|
||||
export default async function ModerationPage() {
|
||||
let stats: import("@/lib/types").ModerationStats | undefined;
|
||||
let actions: import("@/lib/types").ModerationAction[] | undefined;
|
||||
try {
|
||||
[stats, actions] = await Promise.all([
|
||||
getModerationStats(),
|
||||
getModerationActions(100),
|
||||
]);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <ModerationView initialStats={stats} initialActions={actions} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Filter,
|
||||
MessageSquareWarning,
|
||||
MicOff,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { Donut } from "@/components/charts";
|
||||
import {
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from "@/components/primitives";
|
||||
import {
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
MetricTile,
|
||||
SectionHeader,
|
||||
} from "@/components/shared";
|
||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type {
|
||||
ModerationAction,
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
|
||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||
delete_message: <Trash2 className="size-3.5" />,
|
||||
mute_user: <MicOff className="size-3.5" />,
|
||||
warn_user: <MessageSquareWarning className="size-3.5" />,
|
||||
kick_user: <UserX className="size-3.5" />,
|
||||
ban_user: <Ban className="size-3.5" />,
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<ModerationActionType, string> = {
|
||||
delete_message: "Delete",
|
||||
mute_user: "Mute",
|
||||
warn_user: "Warn",
|
||||
kick_user: "Kick",
|
||||
ban_user: "Ban",
|
||||
};
|
||||
|
||||
export function ModerationView({
|
||||
initialStats,
|
||||
initialActions,
|
||||
}: {
|
||||
initialStats?: ModerationStats;
|
||||
initialActions?: ModerationAction[];
|
||||
}) {
|
||||
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||
const { data: actions } = useModerationActions(
|
||||
statusFilter || undefined,
|
||||
typeFilter || undefined,
|
||||
!statusFilter && !typeFilter ? initialActions : undefined,
|
||||
);
|
||||
|
||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||
|
||||
const byAction = stats?.by_action ?? {};
|
||||
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
||||
value: 1,
|
||||
color:
|
||||
k === "ban_user" || k === "kick_user"
|
||||
? "var(--color-vermilion)"
|
||||
: k === "warn_user"
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-signal)",
|
||||
label: k,
|
||||
}));
|
||||
|
||||
const ambient = useAmbient();
|
||||
useEffect(() => {
|
||||
ambient.set(
|
||||
failedRate > 20 ? "vermilion" : failedRate > 5 ? "amber" : "signal",
|
||||
0.3 + Math.min(0.4, failedRate / 50),
|
||||
"moderation",
|
||||
);
|
||||
}, [failedRate, ambient]);
|
||||
|
||||
if (error && !stats) return <ErrorState error={error} />;
|
||||
if (!stats && isLoading) return <LoadingState label="Reading log" />;
|
||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
||||
|
||||
const statusOpts: SelectOption[] = [
|
||||
{ value: "", label: "All statuses" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "executed", label: "Executed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
];
|
||||
const typeOpts: SelectOption[] = [
|
||||
{ value: "", label: "All actions" },
|
||||
...Object.keys(byAction).map((k) => ({
|
||||
value: k,
|
||||
label: ACTION_LABEL[k as ModerationActionType] ?? k,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Total actions"
|
||||
value={formatNumber(stats.total)}
|
||||
tone="signal"
|
||||
icon={<ShieldAlert className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Executed"
|
||||
value={formatNumber(stats.executed)}
|
||||
tone="signal"
|
||||
icon={<CheckCircle2 className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Failed"
|
||||
value={formatNumber(stats.failed)}
|
||||
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
||||
icon={<XCircle className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Pending"
|
||||
value={formatNumber(stats?.pending)}
|
||||
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-5">
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||
<div className="flex items-center gap-5">
|
||||
<Donut
|
||||
segments={
|
||||
segments.length
|
||||
? segments
|
||||
: [
|
||||
{
|
||||
value: 1,
|
||||
color: "var(--color-ink-faint)",
|
||||
label: "none",
|
||||
},
|
||||
]
|
||||
}
|
||||
centerLabel={`${Math.round(failedRate)}%`}
|
||||
centerSub="fail rate"
|
||||
/>
|
||||
<div className="flex-1 space-y-2 text-sm">
|
||||
{Object.entries(byAction).map(([k, v]) => {
|
||||
const count = typeof v === "number" ? v : null;
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-2.5">
|
||||
<span className="text-ink-soft">
|
||||
{ACTION_ICON[k as ModerationActionType]}
|
||||
</span>
|
||||
<span className="flex-1 text-ink-soft">
|
||||
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
||||
</span>
|
||||
{count !== null && (
|
||||
<span className="mono text-ink">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(byAction).length === 0 && (
|
||||
<div className="text-xs text-ink-faint">
|
||||
No actions recorded yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="filter"
|
||||
title="Action log"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-3.5 text-ink-faint" />
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={typeOpts}
|
||||
size="sm"
|
||||
className="w-36"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={statusOpts}
|
||||
size="sm"
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{(actions ?? []).map((a) => (
|
||||
<ActionRow key={a.id} a={a} />
|
||||
))}
|
||||
{(actions ?? []).length === 0 && (
|
||||
<div className="py-10 text-center text-xs text-ink-faint">
|
||||
No matching actions.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ a }: { a: ModerationAction }) {
|
||||
const tone =
|
||||
a.status === "executed"
|
||||
? "signal"
|
||||
: a.status === "failed"
|
||||
? "vermilion"
|
||||
: "amber";
|
||||
const icon = ACTION_ICON[a.action_type] ?? (
|
||||
<AlertTriangle className="size-3.5" />
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3">
|
||||
<span
|
||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">
|
||||
{a.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge tone={tone}>{a.status}</Badge>
|
||||
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||
{a.created_at ? new Date(a.created_at).toLocaleString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{a.reason && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||
)}
|
||||
{a.content && (
|
||||
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||
{a.content}
|
||||
</div>
|
||||
)}
|
||||
{a.error && (
|
||||
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +1,16 @@
|
||||
"use client";
|
||||
import { getRecordings } from "@/lib/api/server";
|
||||
import { RecordingsView } from "./view";
|
||||
|
||||
import { Clock, Database, Mic, Users } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { SubNav } from "@/components/layout/sub-nav";
|
||||
import { RecordingCard } from "@/components/recordings/recording-card";
|
||||
import { RecordingPlayer } from "@/components/recordings/recording-player";
|
||||
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useRecordings, useRecordingsWsSync } from "@/hooks";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RecordingsTab = "library" | "stats";
|
||||
|
||||
export default function RecordingsPage() {
|
||||
const {
|
||||
data: recordings,
|
||||
isLoading,
|
||||
error,
|
||||
mutate: refetch,
|
||||
} = useRecordings();
|
||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
|
||||
const [tab, setTab] = useState<RecordingsTab>("library");
|
||||
const ws = useWebSocket();
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Live-update the library when the gateway publishes voice_recording_uploaded
|
||||
useRecordingsWsSync(ws);
|
||||
|
||||
const currentTrack =
|
||||
playingId && recordings
|
||||
? recordings.find((r: VoiceRecording) => r.id === playingId)
|
||||
: null;
|
||||
|
||||
const togglePlay = (id: string) => {
|
||||
if (playingId !== id) {
|
||||
setPlayingId(id); // RecordingPlayer picks up the new url + autoplays
|
||||
} else {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const list = recordings ?? [];
|
||||
const totalSize = list.reduce((sum, r) => sum + (r.size_bytes ?? 0), 0);
|
||||
const byUser = new Map<
|
||||
string,
|
||||
{ name: string; count: number; size: number }
|
||||
>();
|
||||
for (const rec of list) {
|
||||
const key = rec.user_id ?? rec.username;
|
||||
const cur = byUser.get(key) ?? { name: rec.username, count: 0, size: 0 };
|
||||
cur.count += 1;
|
||||
cur.size += rec.size_bytes ?? 0;
|
||||
byUser.set(key, cur);
|
||||
}
|
||||
const topUsers = [...byUser.values()]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8);
|
||||
return {
|
||||
total: list.length,
|
||||
totalSize,
|
||||
uniqueUsers: byUser.size,
|
||||
topUsers,
|
||||
};
|
||||
}, [recordings]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
<SubNav
|
||||
tabs={[
|
||||
{ id: "library", label: "Library", icon: undefined },
|
||||
{ id: "stats", label: "Stats", icon: undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onTabChange={(t) => setTab(t as RecordingsTab)}
|
||||
/>
|
||||
|
||||
{tab === "library" &&
|
||||
(error ? (
|
||||
<ErrorState message={error.message} onRetry={refetch} />
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton count={4} height="h-28" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(recordings ?? []).map((rec: VoiceRecording) => (
|
||||
<RecordingCard
|
||||
key={rec.id}
|
||||
recording={rec}
|
||||
active={playingId === rec.id}
|
||||
playing={playingId === rec.id && isPlaying}
|
||||
loading={playingId === rec.id && isLoadingAudio}
|
||||
onTogglePlay={togglePlay}
|
||||
/>
|
||||
))}
|
||||
{(recordings ?? []).length === 0 && (
|
||||
<EmptyState
|
||||
icon={Mic}
|
||||
title="No recordings yet"
|
||||
description="Voice recordings will appear here once members speak in a monitored voice channel."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tab === "stats" &&
|
||||
(isLoading ? (
|
||||
<LoadingSkeleton count={4} height="h-28" columns={3} />
|
||||
) : stats.total === 0 ? (
|
||||
<EmptyState
|
||||
icon={Clock}
|
||||
title="No recording stats yet"
|
||||
description="Recordings are captured from monitored voice channels."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<StatCard
|
||||
label="Total Recordings"
|
||||
value={stats.total}
|
||||
icon={Mic}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Size"
|
||||
value={stats.totalSize}
|
||||
icon={Database}
|
||||
formatter={(v) => formatBytes(v)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Unique Speakers"
|
||||
value={stats.uniqueUsers}
|
||||
icon={Users}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.topUsers.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-text-secondary font-medium uppercase tracking-wide">
|
||||
Top Speakers
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{stats.topUsers.map((u) => (
|
||||
<div
|
||||
key={u.name}
|
||||
className="flex items-center gap-3 rounded-lg border border-border/40 bg-card/40 px-3 py-2"
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-md bg-primary/10 font-mono text-xs text-primary">
|
||||
{u.count}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-sm text-text-primary">
|
||||
{u.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-text-secondary/50">
|
||||
{formatBytes(u.size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<RecordingPlayer
|
||||
url={currentTrack?.download_url ?? undefined}
|
||||
filename={currentTrack?.filename ?? undefined}
|
||||
playing={isPlaying}
|
||||
loading={isLoadingAudio}
|
||||
audioRef={audioRef}
|
||||
onToggle={() => togglePlay(playingId!)}
|
||||
onStateChange={(s) => {
|
||||
setIsPlaying(s.playing);
|
||||
setIsLoadingAudio(s.loading);
|
||||
}}
|
||||
onClose={() => setPlayingId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export default async function RecordingsPage() {
|
||||
let recordings:
|
||||
| import("@/lib/types/recording").PaginatedRecordings
|
||||
| undefined;
|
||||
try {
|
||||
recordings = await getRecordings(50);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
return <RecordingsView initialItems={recordings?.items} />;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user