Compare commits
178
Commits
4f9d4a5c7d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef7d005fb | ||
|
|
842610b1af | ||
|
|
fca96396b9 | ||
|
|
ccf3fa260e | ||
|
|
1accfd9390 | ||
|
|
440ec41da8 | ||
|
|
1c8c0ca081 | ||
|
|
5f42c17caa | ||
|
|
eda5c752b7 | ||
|
|
25d5097edb | ||
|
|
d3e3b4764a | ||
|
|
33a557c761 | ||
|
|
32de2819df | ||
|
|
a21d252e9b | ||
|
|
84a766db0c | ||
|
|
0581dc3485 | ||
|
|
3d6c07bd91 | ||
|
|
60ae1fb5c3 | ||
|
|
1fafebb16d | ||
|
|
a9e09c38e9 | ||
|
|
3d4236e8df | ||
|
|
16becd5340 | ||
|
|
1397380fe9 | ||
|
|
4ffc99b3fe | ||
|
|
81ce5188ea | ||
|
|
4e0c21d86c | ||
|
|
df69b3f05d | ||
|
|
eee332412f | ||
|
|
f750f39b50 | ||
|
|
f1d90b6097 | ||
|
|
7f4196124d | ||
|
|
5658726ea5 | ||
|
|
f5d5690401 | ||
|
|
0aa893ab7d | ||
|
|
6f20b0f146 | ||
|
|
80248d4b7a | ||
|
|
20e991062c | ||
|
|
b784d6d796 | ||
|
|
00e8d68ce5 | ||
|
|
2a8f6d9062 | ||
|
|
9b3134d767 | ||
|
|
36363fa3db | ||
|
|
d133cc3271 | ||
|
|
5a70a685b4 | ||
|
|
100b62800c | ||
|
|
1ae19074ee | ||
|
|
d68f6b653a | ||
|
|
29baba3a72 | ||
|
|
217ecc1aa1 | ||
|
|
0cb0b82fb1 | ||
|
|
95f2903067 | ||
|
|
d78d7a0181 | ||
|
|
ff8a9c50ae | ||
|
|
6bd6ddcdca | ||
|
|
b38616e051 | ||
|
|
479f4719ba | ||
|
|
2825250804 | ||
|
|
aa280c48b7 | ||
|
|
4cf5b87f2b | ||
|
|
0dff7770a1 | ||
|
|
e3dd6a3427 | ||
|
|
55fdcfaae3 | ||
|
|
cf1ec25c71 | ||
|
|
0ace758c79 | ||
|
|
c89288191e | ||
|
|
4655125541 | ||
|
|
ba60448d05 | ||
|
|
1d809b2c95 | ||
|
|
38bda66933 | ||
|
|
726a8e116b | ||
|
|
2fa1827f17 | ||
|
|
d8552a9fb8 | ||
|
|
a4abe3abea | ||
|
|
b67856462f | ||
|
|
30828a5534 | ||
|
|
a3e5a8c1b9 | ||
|
|
f82b5caae4 | ||
|
|
9e2b107fcd | ||
|
|
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 |
@@ -82,6 +82,19 @@ jobs:
|
|||||||
extra-conf: |
|
extra-conf: |
|
||||||
sandbox = false
|
sandbox = false
|
||||||
accept-flake-config = true
|
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
|
- name: Cache Nix
|
||||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
@@ -107,13 +120,117 @@ jobs:
|
|||||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
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
|
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
|
# 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.
|
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
|
||||||
- name: Deploy ${{ matrix.service }} to VPS
|
- name: Deploy ${{ matrix.service }} to VPS
|
||||||
run: |
|
run: |
|
||||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||||
echo "=== Copying ${{ matrix.service }}: $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 ==="
|
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'"
|
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'"
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ worktrees/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
services/frontend/frontend/dist/
|
services/frontend/frontend/dist/
|
||||||
target/
|
target/
|
||||||
|
nix/
|
||||||
# Gitea CI runner logs
|
# Gitea CI runner logs
|
||||||
.gitea/workflows/*.log
|
.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.)
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# GMW — Moderation Explainability (#1) + Semantic Search (#3) Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development to implement task-by-task.
|
||||||
|
> Hard constraint from user (2026-08-18): web is PUBLIC, read-only, for USERS not admins. Moderation MUST stay FULLY AUTOMATIC. Rules stay in CODE (no per-channel config UI).
|
||||||
|
|
||||||
|
**Goal:** Make GMW transparent (users see why a message was moderated) and searchable (users can semantic-search the message corpus), via two fully-automatic, code-driven, read-only-public features.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **#1 Explainability:** Persist the structured moderation verdict that already exists in `AnalysisResult` (`flags[]`, `categories[]`, `severity`, `confidence`, `evidence[]`) into new columns on `moderation_actions`, surface them through the existing public moderation oRPC + the existing public `moderation` dashboard view. No new behavior — only new *data* + new *read* paths.
|
||||||
|
- **#3 Semantic Search:** Add a SECOND persistent Qdrant collection (`gmw_message_archive`) keyed by message id (NOT the TTL cache). Embed each captured text message at capture time (reuse `embedText`) and upsert. Add a public `messages.semanticSearch` oRPC + a read-only search UI on the public `messages` view. Best-effort / non-blocking — embed failures never affect moderation or capture.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript (discord-gateway + backend + frontend monorepo), Drizzle ORM + Postgres (PgBouncer on imrnes), Qdrant (100.121.180.82:6333), Next.js 16 App Router + shadcn/ui, oRPC over `/trpc`. pnpm. Deploy via GitHub Actions Nix build + `systemctl restart`.
|
||||||
|
|
||||||
|
**Critical existing facts (verified in repo):**
|
||||||
|
- `AnalysisResult` shape (`src/modules/ai-moderation/ai-analysis-worker.ts:55`): `messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?`. The shared `AnalysisResult` (`src/shared/moderation-types.ts:142`) ALSO has `evidence?: string[]` and `policyVersion?: string`. **THESE ARE ALREADY COMPUTED but only logged, never persisted to `moderation_actions`.**
|
||||||
|
- `moderation_actions` schema is DEFINED TWICE with a divergence:
|
||||||
|
- `src/shared/database/schema.ts:638` → `pgModerationActionsTable` (authoritative, has `reset_nickname` in `action_type` enum).
|
||||||
|
- `src/shared/database/schema/messages.ts:25` → another `pgModerationActionsTable` (NO `reset_nickname`; gateway-local copy).
|
||||||
|
- The gateway's `ModerationActionsDb` (`src/modules/message-capture/moderationActionsDb.ts`) imports from `schema.ts` (the authoritative one). The `messages.ts` copy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm the `messages.ts` copy is dead and delete it. **Decision: add columns to `schema.ts` (authoritative) AND the `messages.ts` copy to keep `$inferInsert`/`$inferSelect` in sync (the gateway `ModerationAction` type flows from shared).** Verify with grep that `messages.ts` `pgModerationActionsTable` is not used by any `.insert()`/`.select()` at runtime before relying on it; if only re-exported, we still patch it for type-safety.
|
||||||
|
- Migration mechanism: Drizzle-managed via `drizzle/migrations/*.sql` (journal `_journal.json`) applied by `runMigrations()` → `migratePostgres`. **New tables/columns must be added with `drizzle-kit generate` to produce a numbered `.sql` + journal entry**, OR (simpler, matches `0013_rename_*.sql` manual style) write a raw idempotent `.sql` under `drizzle/migrations/` AND register it in `_journal.json`. **Preferred here: use `pnpm drizzle-kit generate` so the journal stays consistent.** The legacy `src/shared/database/migrations/001_drop_unused_ai_columns.sql` is a PRE-drizzle manual script — do NOT follow that pattern.
|
||||||
|
- **Historical lesson (MUST respect):** a prior migration (`0004_drop_unused_ai_columns.sql` = old `001_drop`) DELETED `ai_evidence`, `ai_policy_version`, `ai_moderation_raw` from `messages` with the note "written but never read". → Our new `moderation_actions` columns MUST be read (serializer + FE render). No write-only columns.
|
||||||
|
- Embedding client: `embedText(text)` / `embedTexts(texts[])` in `src/modules/ai-moderation/embeddingClient.ts`. Returns `null` if `AI_LLM_EMBEDDING_MODEL` not configured. Reuses `config.AI_LLM_BASE_URL` + `config.AI_LLM_API_KEY`. OpenAI SDK v6 → `encoding_format: "float"` REQUIRED (Nvidia rejects base64).
|
||||||
|
- Qdrant client: `src/modules/ai-moderation/qdrantClient.ts`. Has `ensureQdrantCollection(vectorSize)`, `upsertQdrantPoint(cacheKey, vector, payload)`, `searchQdrant(vector, limit, scoreThreshold)`. These are hardcoded to the cache collection name `config.QDRANT_COLLECTION ?? "gmw_text_moderation"`. **#3 needs a second collection** → generalize the client to accept a collection name param (add `ensureQdrantCollectionV2(name, size)` / `upsertQdrantPointV2(name, id, vector, payload)` / `searchQdrantV2(name, vector, limit, scoreThreshold)` OR refactor `collectionName()` to take an arg). Keep the cache path unchanged.
|
||||||
|
- Capture hook: `captureMessage()` (`src/modules/message-capture/messageCapture.ts:201`) calls `messageStore.upsertMessageForCapture(messageRecord)` then (if not backlog) `queueMessageAnalysis`. **#3 embed must happen here**, async + fire-and-forget, after successful insert.
|
||||||
|
- Public moderation view: `services/frontend/src/app/(dashboard)/moderation/view.tsx` renders `ActionRow` per action. The `ModerationAction` FE type is in `services/frontend/src/lib/types/moderation.ts` (NO new fields yet). The backend `moderationService.listActions` SQL is in `services/backend/src/modules/moderation/moderation.repository.ts:68` (raw SQL, selects fixed columns, joins `messages`).
|
||||||
|
- oRPC wiring: `services/backend/src/orpc/router.ts` → `moderationRouter` (stats, actions) and `messagesRouter` (list, byChannel, getById, review, attachments). New procedures added here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 1 — Schema: add explainability columns to `moderation_actions`
|
||||||
|
|
||||||
|
**Objective:** Persist structured verdict on moderation actions so it can be surfaced (read) later.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/shared/database/schema.ts` (authoritative `pgModerationActionsTable`, ~line 638)
|
||||||
|
- Modify: `services/discord-gateway/src/shared/database/schema/messages.ts` (`pgModerationActionsTable` copy, ~line 25) to keep type in sync
|
||||||
|
- Create: `services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql`
|
||||||
|
- Update: `services/discord-gateway/drizzle/migrations/meta/_journal.json` (add new entry)
|
||||||
|
|
||||||
|
**Step 1: Add columns to both schema definitions**
|
||||||
|
Add after `executed_at` in BOTH `pgModerationActionsTable` definitions:
|
||||||
|
```ts
|
||||||
|
// ── Explainability (structured verdict, surfaced read-only to public web) ──
|
||||||
|
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
|
||||||
|
categories: pgText("categories"), // JSON array of category strings
|
||||||
|
severity: pgText("severity", {
|
||||||
|
enum: ["none", "low", "medium", "high", "critical"],
|
||||||
|
}),
|
||||||
|
confidence: pgReal("confidence"), // 0..1
|
||||||
|
score: pgReal("score"), // 0..1 raw model score
|
||||||
|
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||||
|
policy_version: pgText("policy_version"), // rules.ts policy version string
|
||||||
|
```
|
||||||
|
Note: `flags`/`categories`/`evidence` stored as JSON-stringified TEXT (consistent with how `messages.ai_moderation_flags`/`ai_categories` are stored as TEXT elsewhere — confirm storage format in `updateMessageAIAnalysis`). Keep nullable.
|
||||||
|
|
||||||
|
**Step 2: Generate/author the migration SQL**
|
||||||
|
`0015_add_moderation_explainability.sql` (idempotent):
|
||||||
|
```sql
|
||||||
|
-- Add structured explainability columns to moderation_actions (read-only surfaced to public web).
|
||||||
|
ALTER TABLE IF EXISTS "moderation_actions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "severity" text
|
||||||
|
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||||
|
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "score" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||||
|
```
|
||||||
|
Register in `_journal.json`: append an entry with `idx: 15`, a new unique `tag` (hash), `version`, `when` = Date.now(), `tag` short, `breakpoints: false`. Use `pnpm drizzle-kit generate` if possible to get a correct tag; otherwise hand-edit the journal carefully (copy an existing entry's shape).
|
||||||
|
|
||||||
|
**Step 3: Type-check gateway**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||||
|
Expected: PASS (no new compile errors).
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/shared/database/schema.ts \
|
||||||
|
services/discord-gateway/src/shared/database/schema/messages.ts \
|
||||||
|
services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql \
|
||||||
|
services/discord-gateway/drizzle/migrations/meta/_journal.json
|
||||||
|
git commit -m "feat(db): add explainability columns to moderation_actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 2 — Persist verdict at the auto-delete + command-handler call sites
|
||||||
|
|
||||||
|
**Objective:** Populate the new columns from the already-computed `AnalysisResult` when a moderation action is logged. Fully automatic, no new behavior.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts` (`logAutoDeleteAttempt` ~line 166, and the second `createModerationAction` call ~line 234 for nickname/mute paths)
|
||||||
|
- Modify: `services/discord-gateway/src/modules/command-handler/moderation.handler.ts` (`createModerationAction` ~line 95)
|
||||||
|
- Helper (create): `services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts` — shared mapper so all 3 call sites stay DRY.
|
||||||
|
|
||||||
|
**Step 1: Create the mapper helper**
|
||||||
|
`verdictToActionFields.ts`:
|
||||||
|
```ts
|
||||||
|
import type { AnalysisResult } from "@/modules/ai-moderation/ai-analysis-worker";
|
||||||
|
import type { ModerationActionInsert } from "@/shared/index"; // or inline shape
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a computed AI verdict into the explainability columns of a moderation
|
||||||
|
* action. Null-safe: missing fields stay null (e.g. manual admin actions have
|
||||||
|
* no AnalysisResult). This is read-only structured data — it does NOT change
|
||||||
|
* any enforcement decision.
|
||||||
|
*/
|
||||||
|
export function verdictToActionFields(result?: {
|
||||||
|
flags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
severity?: string;
|
||||||
|
confidence?: number;
|
||||||
|
score?: number;
|
||||||
|
evidence?: string[];
|
||||||
|
policyVersion?: string;
|
||||||
|
}): {
|
||||||
|
flags: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
score: number | null;
|
||||||
|
evidence: string | null;
|
||||||
|
policy_version: string | null;
|
||||||
|
} {
|
||||||
|
if (!result) {
|
||||||
|
return { flags: null, categories: null, severity: null, confidence: null,
|
||||||
|
score: null, evidence: null, policy_version: null };
|
||||||
|
}
|
||||||
|
const j = (v: unknown) => (v == null ? null : JSON.stringify(v));
|
||||||
|
return {
|
||||||
|
flags: j(result.flags),
|
||||||
|
categories: j(result.categories),
|
||||||
|
severity: result.severity ?? null,
|
||||||
|
confidence: result.confidence ?? null,
|
||||||
|
score: result.score ?? null,
|
||||||
|
evidence: j(result.evidence),
|
||||||
|
policy_version: result.policyVersion ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Wire `logAutoDeleteAttempt`**
|
||||||
|
Find the `createModerationAction({...})` in `logAutoDeleteAttempt` and spread the verdict fields:
|
||||||
|
```ts
|
||||||
|
await messageStore.createModerationAction({
|
||||||
|
message_id: message.id,
|
||||||
|
user_id: message.user_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
action_type: "delete_message",
|
||||||
|
reason: result.reason,
|
||||||
|
...verdictToActionFields(result.analysisResult), // <-- pass the AnalysisResult through
|
||||||
|
executed_by: "auto-delete-manager",
|
||||||
|
status: ...,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
**IMPORTANT:** `result` here is `AutoDeleteResult` — verify it carries the `AnalysisResult` (or the verdict). If `AutoDeleteResult` does NOT carry the full `AnalysisResult`, trace where `attemptAutoDeleteFlaggedMessage` is called from and pass the `AnalysisResult` down (it is available in the analysis worker that triggered the delete). Confirm by reading `AutoDeleteResult` type + its producer. If the verdict is only available at the orchestrator level, add an optional `verdict?: AnalysisResult` field to `AutoDeleteResult` and populate it at the call site.
|
||||||
|
|
||||||
|
**Step 3: Wire the second call site in `autoDeleteManager.ts`** (the mute/nickname path ~line 234) similarly, if it has an `AnalysisResult` available; otherwise leave fields null (manual-style action).
|
||||||
|
|
||||||
|
**Step 4: Wire `moderation.handler.ts`** command path (~line 95) — pass `verdictToActionFields(verdict)` if the command handler has the `AnalysisResult` for the target message; otherwise nulls. Confirm what the handler receives.
|
||||||
|
|
||||||
|
**Step 5: Type-check + lint**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts \
|
||||||
|
services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts \
|
||||||
|
services/discord-gateway/src/modules/command-handler/moderation.handler.ts
|
||||||
|
git commit -m "feat(mods): persist structured verdict into moderation_actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 3 — Backend: surface explainability in `moderation.actions`
|
||||||
|
|
||||||
|
**Objective:** Read the new columns in the public oRPC so the frontend can render them. (Read path — satisfies the "never write-only" rule.)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/backend/src/modules/moderation/moderation.repository.ts` (`listActions` raw SQL ~line 68) — add new columns to SELECT + map.
|
||||||
|
- Modify: `services/frontend/src/lib/types/moderation.ts` (`ModerationAction` interface) — add new fields.
|
||||||
|
- Modify: `services/frontend/src/app/(dashboard)/moderation/view.tsx` (`ActionRow`) — render flags/categories badges + severity + confidence + evidence snippet.
|
||||||
|
|
||||||
|
**Step 1: Extend backend SELECT**
|
||||||
|
In `listActions`, add to the SELECT list: `a.flags, a.categories, a.severity, a.confidence, a.score, a.evidence, a.policy_version`. In the `.map(...)` add:
|
||||||
|
```ts
|
||||||
|
flags: r.flags ? safeJsonArray(String(r.flags)) : null,
|
||||||
|
categories: r.categories ? safeJsonArray(String(r.categories)) : null,
|
||||||
|
severity: r.severity ? String(r.severity) : null,
|
||||||
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
|
score: r.score != null ? Number(r.score) : null,
|
||||||
|
evidence: r.evidence ? safeJsonArray(String(r.evidence)) : null,
|
||||||
|
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||||
|
```
|
||||||
|
where `safeJsonArray(s)` = `JSON.parse(s)` wrapped in try/catch returning `[]` on failure (define a tiny local helper in the repository file).
|
||||||
|
|
||||||
|
**Step 2: Extend FE type**
|
||||||
|
In `services/frontend/src/lib/types/moderation.ts` `ModerationAction`:
|
||||||
|
```ts
|
||||||
|
flags: string[] | null;
|
||||||
|
categories: string[] | null;
|
||||||
|
severity: "none" | "low" | "medium" | "high" | "critical" | null;
|
||||||
|
confidence: number | null;
|
||||||
|
score: number | null;
|
||||||
|
evidence: string[] | null;
|
||||||
|
policy_version: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Render in `ActionRow`**
|
||||||
|
After the existing reason block, add (using existing `Badge` + `aiTone` from `@/lib/ai-status`):
|
||||||
|
```tsx
|
||||||
|
{a.severity && (
|
||||||
|
<Badge tone={aiTone(a.severity === "none" ? "clean" : a.severity)}>
|
||||||
|
{a.severity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{a.flags?.length ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{a.flags.map((f) => <Badge key={f} tone="amber">{f}</Badge>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{a.evidence?.length ? (
|
||||||
|
<div className="mt-1 text-xs text-ink-faint border-l-2 border-hairline pl-2">
|
||||||
|
“{a.evidence[0]}”
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{a.confidence != null && (
|
||||||
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
|
conf {(a.confidence * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
Keep `ActionRow` read-only. No admin controls.
|
||||||
|
|
||||||
|
**Step 4: Type-check both services**
|
||||||
|
Run: `cd services/backend && pnpm typecheck && pnpm lint` and `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/backend/src/modules/moderation/moderation.repository.ts \
|
||||||
|
services/frontend/src/lib/types/moderation.ts \
|
||||||
|
services/frontend/src/app/\(dashboard\)/moderation/view.tsx
|
||||||
|
git commit -m "feat(web): surface moderation explainability (flags/severity/evidence)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 4 — Qdrant client: support a second persistent collection
|
||||||
|
|
||||||
|
**Objective:** Generalize the Qdrant client so #3 can use a dedicated archive collection without disturbing the automod cache.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts`
|
||||||
|
|
||||||
|
**Step 1: Add collection-aware variants**
|
||||||
|
Refactor `collectionName()` to accept an optional name, and add V2 functions that take an explicit collection:
|
||||||
|
```ts
|
||||||
|
function collectionName(fallback = config.QDRANT_COLLECTION ?? "gmw_text_moderation"): string {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
export const ARCHIVE_COLLECTION = config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
|
export async function ensureQdrantCollectionV2(name: string, vectorSize: number): Promise<boolean> {
|
||||||
|
// same body as ensureQdrantCollection but uses `name` instead of collectionName()
|
||||||
|
}
|
||||||
|
export async function upsertQdrantPointV2(
|
||||||
|
name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
|
||||||
|
): Promise<boolean> { /* PUT /collections/{name}/points with wait:true */ }
|
||||||
|
export async function searchQdrantV2(
|
||||||
|
name: string, vector: number[], limit: number, scoreThreshold: number,
|
||||||
|
): Promise<QdrantSearchHit[]> { /* POST /collections/{name}/points/search */ }
|
||||||
|
```
|
||||||
|
Keep all existing `ensureQdrantCollection` / `upsertQdrantPoint` / `searchQdrant` UNCHANGED (cache path). V2 functions mirror them with the `name` param. Reuse `request()` and the existing payload/score types.
|
||||||
|
|
||||||
|
**Step 2: Type-check**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
|
||||||
|
git commit -m "feat(qdrant): add collection-aware V2 upsert/search for archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 5 — Capture-time embed + archive upsert
|
||||||
|
|
||||||
|
**Objective:** Make every (non-backlog, text) captured message searchable in the persistent archive. Non-blocking / best-effort.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` (`captureMessage` ~line 201)
|
||||||
|
- Create: `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` — wraps embed + upsert with fire-and-forget + rate-limit guard.
|
||||||
|
|
||||||
|
**Step 1: Create `archiveEmbedder.ts`**
|
||||||
|
```ts
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedText } from "@/modules/ai-moderation/embeddingClient";
|
||||||
|
import { ARCHIVE_COLLECTION, ensureQdrantCollectionV2, upsertQdrantPointV2, qdrantPointId } from "@/modules/ai-moderation/qdrantClient";
|
||||||
|
import { config } from "@/shared/config/config";
|
||||||
|
|
||||||
|
const log = createChildLogger("archive-embedder");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget: embed a captured message and upsert into the persistent
|
||||||
|
* archive collection. Failures are swallowed — searching is a nice-to-have,
|
||||||
|
* never a precondition for capture or moderation.
|
||||||
|
*/
|
||||||
|
export function archiveMessageEmbedded(message: {
|
||||||
|
id: string; content: string; username: string; channel_id: string; guild_id: string; created_at: number;
|
||||||
|
}): void {
|
||||||
|
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||||
|
if (!message.content || message.content.trim().length < 3) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const vector = await embedText(message.content);
|
||||||
|
if (!vector) return;
|
||||||
|
const ok = await ensureQdrantCollectionV2(ARCHIVE_COLLECTION, vector.length);
|
||||||
|
if (!ok) return;
|
||||||
|
await upsertQdrantPointV2(ARCHIVE_COLLECTION, qdrantPointId(`archive:${message.id}`), vector, {
|
||||||
|
text: message.content.slice(0, 4000),
|
||||||
|
flags: "", // not a verdict payload; keep shape compatible
|
||||||
|
analyzed_at: Date.now(),
|
||||||
|
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, // 5y persistent
|
||||||
|
content_hash: undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ messageId: message.id, error: err instanceof Error ? err.message : String(err) }, "archive embed skipped");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Payload type reuse: `QdrantVerdictPayload` has `text`, `flags`, `analyzed_at`, `expires_at`, `content_hash?`. For the archive we only need `text` + timestamps; set `flags: ""` (empty, ignored by search filter which keys on `expires_at`). **Acceptable:** the search path filters `expires_at >= now` — 5y window satisfies that.
|
||||||
|
|
||||||
|
**Step 2: Call from `captureMessage`**
|
||||||
|
In `captureMessage`, after `const inserted = await messageStore.upsertMessageForCapture(messageRecord); if (!inserted) return;` and BEFORE the backlog branch, add:
|
||||||
|
```ts
|
||||||
|
if (!isBacklog && messageRecord.content) {
|
||||||
|
archiveMessageEmbedded(messageRecord);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`messageRecord` is the `MessageRecord` from `buildMessageRecord`; confirm it carries `content`, `channel_id`, `guild_id`, `created_at`. It does — see `messagesCrud`/`types`.)
|
||||||
|
|
||||||
|
**Step 3: Type-check + lint**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts \
|
||||||
|
services/discord-gateway/src/modules/message-capture/messageCapture.ts
|
||||||
|
git commit -m "feat(archive): embed captured messages into persistent Qdrant archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 6 — Backend: `messages.semanticSearch` oRPC
|
||||||
|
|
||||||
|
**Objective:** Public, read-only semantic search over the message archive.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.repository.ts` — add `semanticSearch(query, limit, guildId?)`.
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.service.ts` — expose `semanticSearch`.
|
||||||
|
- Modify: `services/backend/src/orpc/router.ts` — add `messages.semanticSearch` procedure.
|
||||||
|
- Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's `embeddingClient`. **Decision:** add a minimal backend embed helper `services/backend/src/modules/messages/embed.ts` that calls the same OpenAI-compatible endpoint via `config` (reuse `config.AI_LLM_BASE_URL`/`AI_LLM_API_KEY`/`AI_LLM_EMBEDDING_MODEL` if present on the backend; if not configured, return a clear "search unavailable" error). Mirror `encoding_format: "float"`.
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.schema.ts` — add `semanticSearchQuery` zod schema (limit, guildId?, query).
|
||||||
|
|
||||||
|
**Step 1: Backend embed helper** (`embed.ts`)
|
||||||
|
```ts
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { config } from "@/shared/config/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
const log = createChildLogger("messages-embed");
|
||||||
|
let client: OpenAI | null = null;
|
||||||
|
function getClient() {
|
||||||
|
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||||
|
if (!client) client = new OpenAI({ apiKey: config.AI_LLM_API_KEY, baseURL: config.AI_LLM_BASE_URL, maxRetries: 0, timeout: 30_000 });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||||
|
const c = getClient(); if (!c) return null;
|
||||||
|
try {
|
||||||
|
const r = await c.embeddings.create({ model: config.AI_LLM_EMBEDDING_MODEL as string, input: text, encoding_format: "float" });
|
||||||
|
return r.data[0].embedding;
|
||||||
|
} catch (e) { log.warn({ error: e instanceof Error ? e.message : String(e) }, "query embed failed"); return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Repository `semanticSearch`**
|
||||||
|
```ts
|
||||||
|
async semanticSearch(queryVector: number[], limit: number, guildId?: string) {
|
||||||
|
// Search archive collection, then join messages for text + channel.
|
||||||
|
const hits = await searchQdrantV2(ARCHIVE_COLLECTION, queryVector, limit, 0.6);
|
||||||
|
const ids = hits.map(h => h.cacheKey.replace("qdrant:", "")); // point id → we stored archive:<messageId>
|
||||||
|
// decode: qdrantPointId is a uint64; we need the original message id.
|
||||||
|
// SIMPLER: store message_id inside the payload too. → update archiveEmbedder payload to include `message_id`.
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**REFINEMENT (important):** `qdrantPointId` is a hash, not reversible. So the archive payload MUST carry `message_id` (and `channel_id`, `guild_id`, `username`, `created_at`) so the backend can return full results without a reverse lookup. **Update `archiveEmbedder.ts` payload** to include those fields, and relax `QdrantVerdictPayload` (or create `QdrantArchivePayload`) to allow them. Then `semanticSearch` returns the payloads directly (already contain text + metadata) — no DB join needed, and it works even for deleted messages (archive keeps the text). Apply `guildId` filter client-side on the returned payloads.
|
||||||
|
|
||||||
|
**Step 3: Service + router**
|
||||||
|
`messages.service.ts`: `async semanticSearch(query: string, limit: number, guildId?: string)` → embed → `repository.semanticSearch`.
|
||||||
|
`orpc/router.ts` under `messagesRouter`:
|
||||||
|
```ts
|
||||||
|
semanticSearch: os
|
||||||
|
.input(z.object({ query: z.string().min(1), limit: z.coerce.number().int().positive().max(50).default(10), guildId: z.string().optional() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const results = await messagesService.semanticSearch(input.query, input.limit, input.guildId);
|
||||||
|
return { results, nextCursor: null };
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Type-check + lint (backend)**
|
||||||
|
Run: `cd services/backend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/backend/src/modules/messages/embed.ts \
|
||||||
|
services/backend/src/modules/messages/messages.repository.ts \
|
||||||
|
services/backend/src/modules/messages/messages.service.ts \
|
||||||
|
services/backend/src/modules/messages/messages.schema.ts \
|
||||||
|
services/backend/src/orpc/router.ts
|
||||||
|
git commit -m "feat(api): public semantic message search over archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 7 — Frontend: semantic search UI on `messages` view
|
||||||
|
|
||||||
|
**Objective:** Public, read-only search box + results on the existing messages dashboard.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/frontend/src/app/(dashboard)/messages/page.tsx` + `view.tsx` — add a search input (debounced) that calls a new `useMessagesSemanticSearch` hook → `messages.semanticSearch` oRPC, renders results as message cards (reuse `GlassPanel`/`Badge`/existing message row components).
|
||||||
|
- Modify: `services/frontend/src/lib/types/message.ts` — add `SemanticSearchResult` + `SemanticSearchResponse` types.
|
||||||
|
- Modify: `services/frontend/src/lib/api/client.ts` (or `server.ts`) — add `semanticSearch` fetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add a `POST /api/messages/semantic-search` or an oRPC client call consistent with existing `messages.*` calls (follow the EXISTING pattern in `src/lib/api/` — inspect how `messages.list` is called and replicate).
|
||||||
|
|
||||||
|
**Step 1: Add FE types**
|
||||||
|
```ts
|
||||||
|
export interface SemanticSearchResult {
|
||||||
|
message_id: string;
|
||||||
|
content: string;
|
||||||
|
username: string;
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string;
|
||||||
|
created_at: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: string | null; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add hook + wire view**
|
||||||
|
Follow the existing `use-moderation.ts` SWR pattern. Add `useMessagesSemanticSearch(query, guildId?)` returning `{ data, isLoading, error }`. In `messages/view.tsx`, add a search `Input` (from `@/components/primitives`) at the top, debounce ~300ms, and render results below the live list when a query is present. Reuse the message-row rendering already in that view (do not invent a new component).
|
||||||
|
|
||||||
|
**Step 3: Type-check + lint (frontend)**
|
||||||
|
Run: `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/frontend/src/app/\(dashboard\)/messages/ \
|
||||||
|
services/frontend/src/lib/types/message.ts \
|
||||||
|
services/frontend/src/lib/api/ \
|
||||||
|
services/frontend/src/hooks/
|
||||||
|
git commit -m "feat(web): public semantic message search UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 8 — Build all services + deploy + verify
|
||||||
|
|
||||||
|
1. `cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
2. `cd services/backend && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
3. `cd services/frontend && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
4. Commit any formatting fixes (biome `--unsafe` if import order), author `asepharyana`, NO Co-Authored-By.
|
||||||
|
5. `git push origin main` → watch `gh run watch` on "Build & Deploy (Nix)".
|
||||||
|
6. After deploy: verify `systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubState` reflects new timestamp; same for backend + frontend.
|
||||||
|
7. Smoke: `curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=<urlencoded json>` OR via the public web `imphnen.asepharyana.my.id` messages page → type a query → expect results (after some messages have been embedded; embeddings only run on NEW captures post-deploy, so seed a few test messages or backfill).
|
||||||
|
8. Moderation explainability: trigger/observe a flagged message → confirm `moderation_actions.flags` is populated (SQL `SELECT flags, severity FROM moderation_actions ORDER BY created_at DESC LIMIT 5;`) and the public moderation view shows badges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RISKS / TRADEOFFS / OPEN QUESTIONS
|
||||||
|
- **Embedding cost:** #3 embeds EVERY captured message → more embedding API calls. Mitigated: only text ≥3 chars, fire-and-forget, skip if model unconfigured. If cost is a concern, batch embed (reuse `embedTexts`) per capture burst — but start simple (per-message) and observe.
|
||||||
|
- **Backfill:** post-deploy, the archive is empty until new messages arrive. Optional follow-up: a one-off backfill script over existing `messages` (out of scope for this plan unless user asks).
|
||||||
|
- **Schema duplication:** the `pgModerationActionsTable` double-definition must stay in sync (Task 1 patches both). If `messages.ts` copy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk).
|
||||||
|
- **`AutoDeleteResult` verdict availability (Task 2):** requires confirming the `AnalysisResult` is reachable at the `createModerationAction` call sites. If not, we add an optional field to `AutoDeleteResult` at the orchestrator call site. This is the highest-risk integration point — verify before assuming.
|
||||||
|
- **Public exposure:** semantic search returns message text + usernames. This is INTENDED (web is public for users). No auth added. If a guild wants private, that is a future config (out of scope).
|
||||||
|
- **Qdrant payload type:** reusing `QdrantVerdictPayload` for archive is slightly awkward (carries `flags`/`expires_at` semantics). Cleaner: introduce `QdrantArchivePayload` with `message_id`, `channel_id`, `guild_id`, `username`, `content`, `created_at`, `expires_at`. **Prefer the dedicated payload type in Task 4/5** to avoid confusion.
|
||||||
|
|
||||||
|
## VERIFICATION CHECKLIST
|
||||||
|
- [ ] `moderation_actions` has 7 new columns (DB + both schema defs).
|
||||||
|
- [ ] A real auto-delete populates `flags`/`severity`/`evidence` (verified via SQL).
|
||||||
|
- [ ] Public moderation view renders badges + evidence (manual browser check on imphnen.asepharyana.my.id/moderation).
|
||||||
|
- [ ] New message capture upserts a point into `gmw_message_archive` (verify via Qdrant `/collections/gmw_message_archive/points/count`).
|
||||||
|
- [ ] `messages.semanticSearch` returns relevant results for a known phrase.
|
||||||
|
- [ ] All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# GMW — Fitur Publik Lanjutan (#2–#6) Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service
|
||||||
|
> after its changes. Deploy via push to main (CI handles Nix build + systemd).
|
||||||
|
> Hard constraint (user 2026-08-18): public read-only web, fully automatic,
|
||||||
|
> rules in code, NO admin endpoints, NO shadow mode, NO per-channel web config.
|
||||||
|
> **EXPLICITLY EXCLUDED: User Reputation / Strike History** (user: "hapus
|
||||||
|
> sepenuhnya fitur user reputation" — it was never built; do not add it).
|
||||||
|
|
||||||
|
## Existing infra to reuse (verified)
|
||||||
|
- **WS**: backend `ws/server.ts` broadcasts JSON `{type,data,timestamp}` to
|
||||||
|
frontendClients. Backend `ws/redis-bridge.ts` subscribes Redis channels
|
||||||
|
listed in `DISCORD_CHANNEL_TO_WS_EVENT` (backend `shared/redis-channels.ts`)
|
||||||
|
and re-emits as WS events. FE `src/lib/ws` auto-reconnect typed client.
|
||||||
|
- **Gateway → Redis**: `EventBroadcaster` + `RedisEventPublisher` (
|
||||||
|
`discord-gateway/src/modules/event-broadcaster`). Publish via
|
||||||
|
`eventBroadcaster.publish(EventChannels.X, payload)`.
|
||||||
|
- **Moderation data**: `moderation_actions` table (now has explainability
|
||||||
|
cols). `moderation.repository.listActions` returns rows. `ModerationAction`
|
||||||
|
FE type at `frontend/src/lib/types/moderation.ts`.
|
||||||
|
- **Messages**: `messages.list` / `getMessagesByChannel` (backend oRPC +
|
||||||
|
repository). FE `messagesApi` + `useMessages`.
|
||||||
|
- **Charts**: NO chart lib installed. Use **pure SVG/CSS** (consistent with
|
||||||
|
repo; avoid new deps).
|
||||||
|
- **CSV**: client-side Blob download, no backend.
|
||||||
|
|
||||||
|
## Task 1 — Live Moderation Feed (#2)
|
||||||
|
**Gateway**: add `MODERATION_ACTION: "discord:moderation:action"` to
|
||||||
|
`redis-channels.ts` (shared) + `EventChannels.MODERATION_ACTION` in
|
||||||
|
`eventTypes.ts`. In `moderationActionsDb.createModerationAction`, after insert,
|
||||||
|
publish `eventBroadcaster.publish(EventChannels.MODERATION_ACTION, actionRow)`.
|
||||||
|
**Backend**: add `DISCORD_MODERATION_ACTION` constant + map
|
||||||
|
`[DISCORD_MODERATION_ACTION]: "moderation_action"` in `DISCORD_CHANNEL_TO_WS_EVENT`.
|
||||||
|
**FE**: in `src/lib/ws`, subscribe to `moderation_action`; add `useLiveModeration`
|
||||||
|
hook (SWR-style with WS push, capped buffer ~50). Add `<LiveModerationFeed>`
|
||||||
|
client component on `/moderation` page (top of list, animated new-row).
|
||||||
|
Risk: gateway publish at every action (already async insert) — fire-and-forget,
|
||||||
|
wrap in try/catch. Verify WS event reaches FE via `wscat`/curl or log.
|
||||||
|
|
||||||
|
## Task 2 — Toxic Topic Trends (#3)
|
||||||
|
**Backend**: add `moderation.trends` oRPC. Query `moderation_actions` grouped
|
||||||
|
by `categories` (jsonb text[]) over last 30 days, count per category + severity
|
||||||
|
breakdown. Also `action_type` distribution. Return
|
||||||
|
`{ categories: {name,count}[], severities: {level,count}[], actions: {type,count}[] }`.
|
||||||
|
Map jsonb array in SQL (use `unnest` or parse in JS). Reuse `getDatabase`.
|
||||||
|
**FE**: `useModerationTrends` hook + `<TopicTrends>` SVG bar chart (top 10
|
||||||
|
categories) + severity donut (SVG arcs). Place on `/moderation` as a panel.
|
||||||
|
|
||||||
|
## Task 3 — Channel Timeline / Replay (#4)
|
||||||
|
Reuse existing `messages.list` (guildId) + `getMessagesByChannel`. Add a
|
||||||
|
**Timeline tab** to `/messages` that groups messages by date (client-side
|
||||||
|
bucket from `created_at`). Load-more via cursor. No new backend (existing
|
||||||
|
`messagesRouter.list` already supports guildId+limit+cursor). If needed, add
|
||||||
|
`messages.timeline` aggregation (count per day) — but keep simple: client
|
||||||
|
groups fetched rows. Verify existing endpoint returns enough history.
|
||||||
|
|
||||||
|
## Task 4 — Export CSV (#5)
|
||||||
|
**FE only**. `lib/csv.ts` `toCsv(rows, columns)` + `downloadCsv(filename, csv)`.
|
||||||
|
Add "Export CSV" button on `/moderation` (exports current actions) and
|
||||||
|
`/messages` (exports current list). Pure client-side, read-only. No backend.
|
||||||
|
|
||||||
|
## Task 5 — Activity Heatmap (#6)
|
||||||
|
**Backend**: add `messages.activity` oRPC: per-channel message count grouped by
|
||||||
|
hour-of-day (0–23) over last 14 days. Return
|
||||||
|
`{ channels: {channelId, name, byHour: number[24]}[], max }`. Use SQL
|
||||||
|
`EXTRACT(hour from ...)` + group by channel. Channel name from
|
||||||
|
`message.metadata->'channel'->>'channelName'`.
|
||||||
|
**FE**: `useMessageActivity` hook + `<ActivityHeatmap>` SVG grid (channels ×
|
||||||
|
24h, color intensity = count/max). Place on `/messages` or `/dashboard`.
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
- [ ] `pnpm typecheck && pnpm lint && pnpm build` green for gateway, backend, frontend
|
||||||
|
- [ ] Backend `/trpc/moderation/trends` returns categories/severities/actions
|
||||||
|
- [ ] Backend `/trpc/messages/activity` returns byHour grids
|
||||||
|
- [ ] WS `moderation_action` received by FE (log or visible live row)
|
||||||
|
- [ ] No admin/write endpoint added; all public read-only
|
||||||
|
- [ ] No User Reputation code anywhere (grep "reputation|strike|reputasi")
|
||||||
|
- [ ] Deploy via push; all 3 services `running`; moderation + messages pages load
|
||||||
|
|
||||||
|
## Files touched (summary)
|
||||||
|
- gateway: `shared/redis-channels.ts`, `event-broadcaster/eventTypes.ts`,
|
||||||
|
`event-broadcaster/eventBroadcaster.ts`, `message-capture/moderationActionsDb.ts`
|
||||||
|
- backend: `shared/redis-channels.ts`, `orpc/router.ts`,
|
||||||
|
`modules/moderation/moderation.service.ts` (+repository),
|
||||||
|
`modules/messages/messages.service.ts` (+repository, +schema)
|
||||||
|
- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`,
|
||||||
|
`lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`,
|
||||||
|
`app/(dashboard)/messages/view.tsx`, new components under `components/`
|
||||||
|
|
||||||
|
## Status: COMPLETE (deployed + verified)
|
||||||
|
- Commit 9b3134d: features #2–#6 (live feed, trends, timeline, CSV export, heatmap)
|
||||||
|
- Commit 2a8f6d9: user reputation feature fully removed (643 deletions, no trace in src/tests)
|
||||||
|
- Migration 0016 applied: user_reputations DROPPED (DB verified: false)
|
||||||
|
- All 3 services active (gateway + backend restarted 18:29, frontend running)
|
||||||
|
- Gateway typecheck/lint/test(117 passed); backend typecheck/lint/build; FE lint/build — all GREEN
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- moderation/stats WS returns data (32 actions) → WS adapter works
|
||||||
|
- DB: user_reputations gone; moderation_actions explainability cols present
|
||||||
|
- Live Feed: gateway publishes discord:moderation:action → backend WS (same path as guild_member_*)
|
||||||
|
- Trends/Activity: backend router procedures registered (typecheck+tsc), same WS adapter
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# GMW — Fitur Publik Lanjutan #7–#15 + Bug Fix Reputation Removal
|
||||||
|
|
||||||
|
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service after its
|
||||||
|
> changes. Deploy via push to main (CI handles Nix build + systemd). Apply any new
|
||||||
|
> drizzle migration MANUALLY (systemd does NOT run migrations).
|
||||||
|
> Hard constraint (user): public read-only web, fully automatic, rules in code,
|
||||||
|
> NO admin endpoints, NO shadow mode, NO per-user reputation aggregation.
|
||||||
|
|
||||||
|
## Bug fix discovered during planning (MUST do first)
|
||||||
|
`services/backend/src/modules/dashboard/dashboard.repository.ts` still references
|
||||||
|
`pgUserReputationsTable` (import line 8; JOINs at lines 173 + 457) — that table was
|
||||||
|
DROPPED in migration `0016`. `dashboard.listUsers` / `dashboard.userDetail` will
|
||||||
|
**crash at runtime** (undefined table). Remove the import + the `r.*` join columns
|
||||||
|
(`trust_score`, `clean_message_streak`, `total_infractions`) from both queries.
|
||||||
|
This is a regression introduced by the reputation removal commit.
|
||||||
|
|
||||||
|
## Features to implement (#7–#15)
|
||||||
|
All reuse existing infra: `moderation_actions`, `messages`, `channel_cultures`,
|
||||||
|
`term_glossary_cache`, `ai_analysis_runs`, `message_edits`, gateway cron (for #15),
|
||||||
|
WS (proven Live Feed pattern), oRPC over WS (proven), pure-SVG charts (no libs).
|
||||||
|
|
||||||
|
| # | Feature | Data source | Surface |
|
||||||
|
|---|---------|-------------|---------|
|
||||||
|
| 7 | Flagged Link / Scam Domain Reporter | regex URL from `moderation_actions.content`/`evidence` | `/moderation` |
|
||||||
|
| 8 | Top Flagged Channels | join `moderation_actions.message_id`→`messages.channel_id` | `/moderation` |
|
||||||
|
| 9 | Moderation Heatmap by Hour | `moderation_actions.created_at` hour-of-day | `/moderation` |
|
||||||
|
| 10 | Flag Category Drill-down | `moderation_actions.categories` (reuse Trends) | `/moderation` FE-only |
|
||||||
|
| 11 | Channel Culture Glossary | `channel_cultures` (exists) | new `/channels` panel |
|
||||||
|
| 12 | Term Knowledge Base | `term_glossary_cache` (exists) | new `/glossary` panel |
|
||||||
|
| 13 | Edit/Evasion Tracker | `message_edits` (exists) | `/messages` |
|
||||||
|
| 14 | Auto-mod Coverage Stats | `ai_analysis_runs` (exists) | `/moderation` metric tiles |
|
||||||
|
| 15 | Weekly Digest (auto, cron) | aggregate #7/#8/#9 → Discord via gateway cron | gateway cron + `/moderation` |
|
||||||
|
|
||||||
|
## Architecture per layer
|
||||||
|
|
||||||
|
### Backend (oRPC, `services/backend/src`)
|
||||||
|
- New repository methods (add to existing repos, follow `getTrends` SQL style):
|
||||||
|
- `moderation.repository.ts`:
|
||||||
|
- `getTopFlaggedDomains(days)` — `regexp_matches(content,'https?://([^/\s]+)')` on
|
||||||
|
`moderation_actions WHERE created_at>=since`, group by host, COUNT, order DESC LIMIT 20.
|
||||||
|
- `getTopFlaggedChannels(days)` — join `moderation_actions a` LEFT JOIN `messages m`
|
||||||
|
ON `m.id=a.message_id`, group by `m.channel_id`, COUNT, order DESC LIMIT 15.
|
||||||
|
Channel name via `m.metadata::jsonb->'channel'->>'channelName'`.
|
||||||
|
- `getHourlyModeration(days)` — `EXTRACT(HOUR FROM to_timestamp(created_at/1000))`
|
||||||
|
group by hour, COUNT, severity breakdown. (24 rows)
|
||||||
|
- `getFlaggedByCategory(days, category)` — list actions where `categories` contains
|
||||||
|
`category` (reuse `listActions` filter or new query), for drill-down #10.
|
||||||
|
- `getCoverage(days)` — from `ai_analysis_runs`: total runs, status breakdown
|
||||||
|
(clean/flagged/warn/error/pending), coverage % = (analyzed)/(captured in window).
|
||||||
|
- `dashboard.repository.ts` (or new `knowledge.repository.ts`):
|
||||||
|
- `listChannelCultures(limit, search?)` — `channel_cultures` rows (channel_id,
|
||||||
|
guild_id, channel_name from messages metadata, culture_summary, last_analyzed_at).
|
||||||
|
- `listGlossary(limit, search?)` — `term_glossary_cache` (term, definition, source_url,
|
||||||
|
resolved_at, hit_count) order by hit_count DESC.
|
||||||
|
- `messages.repository.ts`:
|
||||||
|
- `getEditHistory(limit, channelId?)` — `message_edits` join `messages` for
|
||||||
|
old_content + channel + username + edited_at, order DESC LIMIT.
|
||||||
|
- `moderation.service.ts` / `dashboard.service.ts` / `messages.service.ts`: thin wrappers.
|
||||||
|
- `orpc/router.ts`: add procedures (follow `trends` shape):
|
||||||
|
- `moderation.topDomains`, `moderation.topChannels`, `moderation.byHour`,
|
||||||
|
`moderation.byCategory` (input `{days,category}`), `moderation.coverage`.
|
||||||
|
- `dashboard.channelCultures`, `dashboard.glossary`.
|
||||||
|
- `messages.editHistory`.
|
||||||
|
|
||||||
|
### Frontend (`services/frontend/src`)
|
||||||
|
- `lib/types/moderation.ts`: add `FlaggedDomain`, `FlaggedChannel`, `HourlyModeration`,
|
||||||
|
`ModerationCoverage` interfaces.
|
||||||
|
- `lib/types/index.ts` (+ message.ts): add `ChannelCultureRow`, `GlossaryRow`, `EditHistoryRow`.
|
||||||
|
- `lib/api/moderation.ts`: add `topDomains`, `topChannels`, `byHour`, `byCategory`, `coverage`.
|
||||||
|
- `lib/api/dashboard.ts` (or messages.ts): add `channelCultures`, `glossary`, `editHistory`.
|
||||||
|
- `lib/api/server.ts`: add SSR seed fetchers (follow `getModerationStats`).
|
||||||
|
- `hooks/use-moderation.ts`: add `useTopDomains`, `useTopChannels`, `useHourlyModeration`,
|
||||||
|
`useByCategory`, `useCoverage`. `hooks/use-dashboard.ts`/`use-messages.ts`: add culture/glossary/edit hooks. `hooks/index.ts`: export all.
|
||||||
|
- New components (pure SVG/CSS, reuse `GlassPanel`/`SectionHeader`/`Badge`/`Donut`):
|
||||||
|
- `components/ScamDomains.tsx`, `components/TopChannels.tsx`, `components/ModerationHeatmap.tsx`,
|
||||||
|
`components/CoverageTiles.tsx`, `components/ChannelCultureGlossary.tsx`,
|
||||||
|
`components/TermGlossary.tsx`, `components/EditHistory.tsx`.
|
||||||
|
- Wire into `app/(dashboard)/moderation/view.tsx` (grid col-span-2/3/5 as space allows)
|
||||||
|
and `app/(dashboard)/messages/view.tsx` (EditHistory panel) and new route pages
|
||||||
|
`app/(dashboard)/channels/page.tsx` + `app/(dashboard)/glossary/page.tsx` with
|
||||||
|
matching `view.tsx` (follow existing page→view SSR pattern; check `app/(dashboard)/dashboard/page.tsx`).
|
||||||
|
- Export CSV buttons reuse `lib/csv.ts` `downloadCsv` (client-side) for domains/channels/edits.
|
||||||
|
|
||||||
|
### Gateway (#15 Weekly Digest)
|
||||||
|
- Add a cron/interval in `services/discord-gateway` (check existing scheduler pattern —
|
||||||
|
search `setInterval`/`cron` in `src`). On a 7-day cadence, query backend oRPC
|
||||||
|
(`dashboard.activity`, `moderation.trends`, `moderation.topChannels`) — OR compute
|
||||||
|
directly via a shared repository — and post a formatted summary to the monitor guild
|
||||||
|
channel (via existing `discordClient.channels.send` helper). Fully automatic, no UI.
|
||||||
|
|
||||||
|
## Files touched (summary)
|
||||||
|
- backend: `modules/moderation/{repository,service}.ts`, `modules/dashboard/{repository,service}.ts`,
|
||||||
|
`modules/messages/{repository,service}.ts`, `orpc/router.ts`, `shared/index.ts` (if new tables),
|
||||||
|
`lib/types/*` (FE)
|
||||||
|
- frontend: `lib/api/*`, `lib/types/*`, `hooks/*`, `components/*`, `app/(dashboard)/*`
|
||||||
|
- gateway: new digest scheduler + (none if reuse backend) maybe `shared/redis-channels.ts`
|
||||||
|
|
||||||
|
## Constraints / pitfalls (from gmw-ops skill)
|
||||||
|
- `created_at` is bigint epoch-MS — compare with `<`/`>`, do NOT divide by 1000 in SQL.
|
||||||
|
- Pure SVG only — frontend has ZERO chart libs.
|
||||||
|
- `Badge` Tone = signal|amber|vermilion|neutral (no "rose").
|
||||||
|
- Frontend WS import is `@/lib/ws/context`; method `on` not `subscribe`.
|
||||||
|
- Commit author `asepharyana`, no Co-Authored-By.
|
||||||
|
- Rebuild `dist/` after gateway changes; apply drizzle migrations manually.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Per service: `pnpm typecheck && pnpm lint && pnpm build` green.
|
||||||
|
- Gateway: `pnpm test` (117+ pass).
|
||||||
|
- Live: `moderation/stats` WS returns data (proves adapter); new procedures registered
|
||||||
|
(typecheck = proof). `systemctl show` new ActiveEnterTimestamp after deploy.
|
||||||
|
- DB: confirm `channel_cultures`/`term_glossary_cache`/`message_edits`/`ai_analysis_runs`
|
||||||
|
have rows before relying on them (some may be empty → components handle empty state).
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
1. Bug fix dashboard.repository (reputation JOIN) — deploy-safe.
|
||||||
|
2. Backend repositories + service + router (#7,#8,#9,#14 dashboard; #11,#12; #13).
|
||||||
|
3. FE types + api + hooks + components + wire (#7,#8,#9,#10,#11,#12,#13,#14).
|
||||||
|
4. Gateway #15 digest (if scheduler exists) — verify via log, not UI.
|
||||||
|
5. Build/lint all 3 services; commit; push; monitor CI; apply migrations; verify live.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# AI Analysis Flow — Audit & Optimization (discord-gateway)
|
||||||
|
|
||||||
|
**Goal:** Analisis alur AI analysis end-to-end, temukan bug/inconsistency yang merusak kualitas verdict, lalu perbaiki root cause-nya.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `services/discord-gateway/src/modules/ai-moderation/**`
|
||||||
|
- Tidak menyentuh chatbot backend / frontend.
|
||||||
|
|
||||||
|
## Alur saat ini (hasil tracing)
|
||||||
|
```
|
||||||
|
message capture → aiAnalyzer.queueMessageAnalysis(messageId)
|
||||||
|
→ batchScheduler.scheduleConversationAnalysis(conversationKey) [debounce 250ms, CB gate]
|
||||||
|
→ messageStore.getPendingMessagesByConversation(≤200)
|
||||||
|
→ skipAgeRestrictedMessages
|
||||||
|
→ pickBatchWithinBudget(14000 tokens, 50/msg)
|
||||||
|
→ processBatch [Piscina worker, ≤4 threads]
|
||||||
|
→ ai-analysis-worker.processBatch
|
||||||
|
→ getConversationContextBefore(20 msgs) + attachments
|
||||||
|
→ attachment-upload race guard (pending upload → skip)
|
||||||
|
→ runModerationAnalysis
|
||||||
|
→ Phase 1: exact-hash cache (PG text_analysis_cache, per channel/thread)
|
||||||
|
→ Phase 2: semantic cache (embedTexts → Qdrant batch search; PG fallback)
|
||||||
|
→ split text-only vs media
|
||||||
|
→ runTextOnlyBatch: URL fetch + wiki search + glossary (paralel)
|
||||||
|
→ dedup short messages → sub-batches (60/sub-batch)
|
||||||
|
→ vision evidence utk URL images (hoisted, 15s cap per image)
|
||||||
|
→ callModerationLLM per sub-batch (stream:true, retries 3, JSON parse + correction retry)
|
||||||
|
→ runMediaBatch: download → vision per image (cache LRU→DB→live, lock) → 1 LLM call
|
||||||
|
→ setCachedTextModeration (PG + Qdrant upsert w/ embedding)
|
||||||
|
→ normalizeResult (confidence clamp, fallback analysis)
|
||||||
|
→ updateMessagesAIAnalysisBulk → broadcast + scheduleAutoDelete
|
||||||
|
→ recovery worker tiap 10s: pending keys → re-schedule; incomplete → individual fallback queue
|
||||||
|
→ individual fallback: 1 msg = 1 worker job (context + full LLM)
|
||||||
|
→ cache prune tiap 6 jam (PG expired + Qdrant expired points)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Temuan audit (ranked)
|
||||||
|
|
||||||
|
### F1 — Cache hit menghapus status "warn" (BUG AKURASI)
|
||||||
|
`moderationOrchestrator.ts` Phase-2 semantic hit & PG-fallback memetakan status via
|
||||||
|
`parseQdrantVerdict`: storedStatus bukan "warn"/"flagged" → dipaksa "clean".
|
||||||
|
TAPI exact-hash lookup (`getCachedTextModeration`, textCacheStore.ts:288-295) lebih parah:
|
||||||
|
hanya menerima "clean"|"flagged" — **"warn" jatuh ke branch flags.length===0 ? clean : flagged**
|
||||||
|
→ warn dengan flags=["conflict_instigation"] dibaca sebagai FLAGGED.
|
||||||
|
Efek: auto-delete eligibility (butuh recommendedAction delete/escalate + severity list) salah baca;
|
||||||
|
dashboard menampilkan flagged padahal verdict asli warn. Root cause: type narrowing legacy
|
||||||
|
(`status: "clean" | "flagged"`) tidak diupdate ketika "warn" ditambahkan ke schema.
|
||||||
|
|
||||||
|
### F2 — Exact-cache key mengabaikan edit (BUG EVASION)
|
||||||
|
Key = sha256(content)+context. Pesan yang DIEDIT (`edited_content`) menghasilkan hash berbeda,
|
||||||
|
tapi verdict lama utk konten pre-edit tetap hidup; lebih penting: pesan edited="true" adalah sinyal
|
||||||
|
evasion di prompt, sedangkan cache bisa menyajikan verdict dari konten lama jika content sama.
|
||||||
|
(Minor, tapi konsistensi: `resolveIsEdited` ada di prompt, tidak ada di cache key.)
|
||||||
|
|
||||||
|
### F3 — `pickBatchWithinBudget` skip-bukan-break (LATENSI/KUALITAS)
|
||||||
|
Loop `if (usedTokens + msgTokens <= maxTokens) {push}` — pesan BESAR di tengah list dilewati
|
||||||
|
dan iterasi lanjut mencoba msg berikutnya. Efek: batch berisi "lubang" (msg pending tetap pending,
|
||||||
|
dianalisis di gelombang berikutnya = LLM call tambahan). Ini by-design tolerable, tapi ada bug halus:
|
||||||
|
pesan >budget tunggal tidak pernah masuk (scheduler sudah punya fallback slice(0,1), OK).
|
||||||
|
Keputusan: biarkan (bukan bug nyata), catat saja.
|
||||||
|
|
||||||
|
### F4 — `callModerationLLM` max_tokens 16384 hardcoded (COST)
|
||||||
|
Sub-batch 60 pesan × output ~150 token/pesan ≈ 9k token cukup; 16k aman. Biarkan.
|
||||||
|
|
||||||
|
### F5 — Dead code builder user-profile/reputation
|
||||||
|
`buildUserProfilesBlock`, `buildUserProfileRef`, `UserProfileEntry` di moderationBuilders.ts
|
||||||
|
tidak dipakai lagi sejak context minimization (hanya tests). `<user_history>` juga tak pernah
|
||||||
|
di-inject (rules masih menyebutnya — misleading bagi model). Bersihkan referensi prompt.
|
||||||
|
|
||||||
|
### F6 — rules.ts menyebut `<user_history>` yang tidak pernah ada di payload
|
||||||
|
Model diberi instruksi tentang blok yang tak pernah muncul → pemborosan token + potensi
|
||||||
|
kelakuan aneh ("menunggu" data yang tak ada). Hapus/ubah kalimat.
|
||||||
|
|
||||||
|
### F7 — system.ts "Blok Data" menyebut `<term_glossary> (SearXNG)` — STALE
|
||||||
|
Sumber sudah Wikipedia. Komentar kode & teks prompt menyebut SearXNG. Perbaiki teks (kecil).
|
||||||
|
|
||||||
|
### F8 — output.ts typo "secifik", baris tabel `-|-` rusak
|
||||||
|
Kualitas prompt: typo + markdown table broken (`||-`) di beberapa baris. Rapikan.
|
||||||
|
|
||||||
|
### F9 — llmCaller parse-error correction tail hanya di SYSTEM
|
||||||
|
Correction tail ditambahkan ke system prompt; provider caching fine, tapi preview invalid
|
||||||
|
content (800 char) ikut SYSTEM — ok. Skip.
|
||||||
|
|
||||||
|
### F10 — `getLlmSemaphore` race kecil saat config berubah di tengah flight
|
||||||
|
Non-issue praktis (config statis per proses). Skip.
|
||||||
|
|
||||||
|
## Keputusan perbaikan (yang dieksekusi sekarang)
|
||||||
|
1. **F1 (utama):** normalisasi status di SATU tempat — `normalizeStoredStatus()` di
|
||||||
|
textCacheStore.ts yang menerima clean/warn/flagged; pakai di getCachedTextModeration
|
||||||
|
DAN parseQdrantVerdict; perluas return types ke union penuh. Orchestrator tinggal pakai.
|
||||||
|
2. **F6+F7+F8:** bersihkan stale references di prompts (user_history, SearXNG, typo).
|
||||||
|
3. **F5:** hapus dead builders + test-nya (biome/tsc yang jaga).
|
||||||
|
4. Regression test untuk F1 (vitest): warn tersimpan → warn terbaca (exact + qdrant path).
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts (F1)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts (type only)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts (F6)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/system.ts (F7)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/output.ts (F8)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts (F5)
|
||||||
|
- services/discord-gateway/tests/contextEnrichment.test.ts (F5 test cleanup + F1 regression test baru)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
```
|
||||||
|
cd services/discord-gateway
|
||||||
|
npx tsc --noEmit
|
||||||
|
npx biome check --diagnostic-level=error .
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
Semua harus hijau sebelum commit. Deploy via GHA (push main) — user konfirmasi belakangan.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Optimisasi "non-issue" AI analysis pipeline
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
Dua item yang sebelumnya dinyatakan non-issue, kini dioptimalkan + 1 bug ordering
|
||||||
|
yang ditemukan saat menelusuri:
|
||||||
|
|
||||||
|
1. **pickBatchWithinBudget: skip → break.** Pesan diurutkan `created_at ASC`
|
||||||
|
oleh DB. Setelah budget habis, pesan berikutnya pasti lebih besar/lebih kecil
|
||||||
|
arbitrer — skip-then-take menghasilkan batch non-kontigu (ada gap analisis
|
||||||
|
di tengah timeline). Ubah jadi stop at first overflow (break) supaya prefix
|
||||||
|
kronologis utuh; sisanya otomatis diambil gelombang berikutnya
|
||||||
|
(`shouldScheduleNext` sudah selalu true setelah sukses).
|
||||||
|
2. **max_tokens dinamis.** Hard-coded 16384 di llmCaller.ts → parameter
|
||||||
|
opsional `maxTokens?`; default tetap 16384. Caller text/media batch pass
|
||||||
|
nilai berbasis ukuran prompt (tiktoken) dengan floor/ceiling.
|
||||||
|
3. **Bug ordering UPDATE..RETURNING (bonus).** messagesAnalysis.ts
|
||||||
|
`getPendingMessagesByConversation`: SELECT ids di-order `created_at ASC`
|
||||||
|
tapi UPDATE...RETURNING tanpa ORDER BY → urutan rows balik tidak
|
||||||
|
terjamin. Konsumen pakai messages[0] sebagai anchor konteks
|
||||||
|
(beforeCreatedAt) dan pickBatchWithinBudget asumsi urutan. Fix: re-sort in
|
||||||
|
JS by created_at (stable) sebelum return.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- src/modules/ai-moderation/batchProcessor.ts — break bukan skip; test baru.
|
||||||
|
- src/modules/ai-moderation/llmCaller.ts — param maxTokens.
|
||||||
|
- src/modules/ai-moderation/textBatchProcessor.ts / mediaBatchProcessor.ts —
|
||||||
|
hitung token prompt & pass maxTokens.
|
||||||
|
- src/modules/message-capture/messagesAnalysis.ts — sort hasil RETURNING.
|
||||||
|
- tests/batchBudget.test.ts — baru.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
cd services/discord-gateway && bun run typecheck && bun run lint && bun run test
|
||||||
|
lalu commit+push, watch GHA, restart service via deploy pipeline.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
|
||||||
|
|
||||||
|
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
|
||||||
|
|
||||||
|
## Masalah (audit)
|
||||||
|
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
|
||||||
|
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
|
||||||
|
play bareng (overlap).
|
||||||
|
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
|
||||||
|
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
|
||||||
|
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
|
||||||
|
dimount → dead code, user tidak lihat status musik di halaman lain.
|
||||||
|
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
|
||||||
|
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
|
||||||
|
|
||||||
|
## Desain
|
||||||
|
|
||||||
|
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
|
||||||
|
Custom player menggantikan `<audio controls>`:
|
||||||
|
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
|
||||||
|
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
|
||||||
|
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
|
||||||
|
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
|
||||||
|
clip otomatis pause yang lain.
|
||||||
|
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
|
||||||
|
|
||||||
|
### B. Recordings view — pasang player baru
|
||||||
|
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
|
||||||
|
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
|
||||||
|
|
||||||
|
### C. Media view polish
|
||||||
|
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
|
||||||
|
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
|
||||||
|
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
|
||||||
|
- Volume read-only tetap.
|
||||||
|
|
||||||
|
### D. MiniPlayer global
|
||||||
|
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
|
||||||
|
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
|
||||||
|
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
|
||||||
|
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
|
||||||
|
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
|
||||||
|
|
||||||
|
### E. Voice UI
|
||||||
|
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
|
||||||
|
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
|
||||||
|
- Listen: volume slider (input range) wired ke `listen.setVolume`.
|
||||||
|
- Mic volume slider wired ke `mic.setVolume`.
|
||||||
|
|
||||||
|
## File touched
|
||||||
|
| File | Aksi |
|
||||||
|
|---|---|
|
||||||
|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
|
||||||
|
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
|
||||||
|
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
|
||||||
|
| services/frontend/src/components/media/mini-player.tsx | new |
|
||||||
|
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
|
||||||
|
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
|
||||||
|
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
|
||||||
|
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
|
||||||
|
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
|
||||||
|
|
||||||
|
## Verifikasi
|
||||||
|
1. `pnpm lint` (biome) + `pnpm build` clean.
|
||||||
|
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
|
||||||
|
3. Commit (tanpa trailer) → push → `gh run watch` → live check
|
||||||
|
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Spec: Optimasi AI Analysis GMW — Naikkan Cache Hit Tanpa Kehilangan Akurasi
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Repo: `~/GMW` (branch `main`) · Service: `services/discord-gateway`
|
||||||
|
|
||||||
|
## Latar & Evidence (audit 2026-08-24)
|
||||||
|
|
||||||
|
State produksi:
|
||||||
|
- Qdrant `gmw_text_moderation`: **1.550 poin, status green** (vectors size 2048, Cosine).
|
||||||
|
- PG `text_analysis_cache`: 1.634 row `user_moderation`, 277 `vision_llm`; **sum(hit_count) = 0** →
|
||||||
|
hit-rate tidak pernah terukur.
|
||||||
|
- Embedding aktif (`AI_LLM_EMBEDDING_MODEL` set, Nemotron-embed, dim 2048), `AI_LLM_EMBEDDING_MIN_SIMILARITY`
|
||||||
|
tidak diset di BWS → default **0.97** (sangat konservatif).
|
||||||
|
- Messages: 9.375 total; 643 status `error` (banyak retry), 49 pending.
|
||||||
|
|
||||||
|
Temuan audit alur (`moderationOrchestrator.ts` → `textCacheStore.ts` → `qdrantClient.ts`,
|
||||||
|
`textBatchProcessor.ts`, `urlFetcher.ts`, `wikipediaClient.ts`, `visionAnalyzer.ts`):
|
||||||
|
|
||||||
|
| # | Temuan | Dampak |
|
||||||
|
|---|--------|--------|
|
||||||
|
| F1 | Exact-hash cache key menyertakan context (channel/thread) → teks sama di channel lain selalu miss | Killer hit-rate #1 |
|
||||||
|
| F2 | Semantic tier TIDAK memfilter context (Qdrant payload tak punya context) — sudah global tapi hanya aman krn sim 0.97 ketat | Inkonsisten dgn exact tier |
|
||||||
|
| F3 | Phase-1 lookup loop `await getCachedTextModeration(key)` per pesan → N round-trip PgBouncer per batch (60 msg = 60 query serial) | Latensi + beban DB |
|
||||||
|
| F4 | Verdict actionable (flagged/warn) dan clean sama-sama boleh di-serve semantic; toleransi akurasi beda | Risiko akurasi |
|
||||||
|
| F5 | `hit_count` tidak pernah di-increment oleh reader manapun | Hit-rate tak terukur |
|
||||||
|
| F6 | `wikipediaSearch()` (blok `<web_searches>`) tanpa cache — re-fetch tiap batch utk query sama | Latensi + spam ke WP |
|
||||||
|
| F7 | `fetchUrlSafely()` tanpa cache — link sama di batch berikutnya di-download lagi penuh | Latensi + bandwidth |
|
||||||
|
| F8 | Vision cache key dari data-URL base64 hasil resize → attachment sama via jalur berbeda (URL vs embed) = key beda → re-download + re-vision | Duplikasi kerja vision |
|
||||||
|
|
||||||
|
Non-goals: mengubah pipeline enforcement (auto-mute/ban trust-store writes), mengubah prompt
|
||||||
|
kebijakan moderasi, mengubah model/embedding provider.
|
||||||
|
|
||||||
|
## Desain
|
||||||
|
|
||||||
|
Semua perubahan degrade gracefully — cache gagal → perilaku lama (LLM). Akurasi dilindungi
|
||||||
|
asimetris: **hemat boleh untuk verdict non-actionable, konservatif untuk yang memicu aksi.**
|
||||||
|
|
||||||
|
### D1 — Cache metrics (F5)
|
||||||
|
- `textCacheStore.getCachedTextModeration()`: saat hit valid, increment `hit_count`
|
||||||
|
(`UPDATE ... SET hit_count = hit_count + 1`) fire-and-forget (`.catch(()=>{})`), jangan blokir return.
|
||||||
|
- Log info periodik ringkas di orchestrator sudah ada ("User moderation cache applied") — cukup.
|
||||||
|
|
||||||
|
### D2 — Batched exact-cache lookup (F3)
|
||||||
|
- Fungsi baru `getCachedTextModerations(keys: string[]): Promise<Map<string, StoredModerationVerdict>>`
|
||||||
|
di `textCacheStore.ts`: **satu** `SELECT ... WHERE text = ANY($1)` (chunk 200 key/query),
|
||||||
|
parse + `normalizeStoredStatus` per row (reuse helper existing).
|
||||||
|
- Orchestrator fase-1: kumpulkan semua key unik → satu call batched → distribusi hasil.
|
||||||
|
- Semantik identik dengan loop lama (row expired/error-artifact tetap miss); hanya jumlah round-trip
|
||||||
|
yang turun N→1.
|
||||||
|
|
||||||
|
### D3 — Global exact reuse untuk verdict non-actionable (F1)
|
||||||
|
- Key scoped-context TETAP ditulis (kompatibel, invalidasi moderator tetap presisi).
|
||||||
|
- Reader tambahan: kalau key `<ctx>:<hash>` miss, coba key legacy global `text_mod:<hash>` (bare).
|
||||||
|
- Guard akurasi (WAJIB semua terpenuhi):
|
||||||
|
- `status === "clean"` DAN `flags.length === 0`;
|
||||||
|
- `confidence >= AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE` (default 0.85);
|
||||||
|
- `recommendedAction === "none"`;
|
||||||
|
- umur entry ≤ `AI_CACHE_GLOBAL_REUSE_MAX_AGE_H` (default 72h) — cek `analyzed_at`.
|
||||||
|
- Flag baru `policyVersion: "cached-global-clean-2026-08"` supaya terlacak di dashboard/log.
|
||||||
|
- Verdict flagged/warn TETAP context-scoped (tidak pernah lintas channel).
|
||||||
|
|
||||||
|
### D4 — Semantic dua-band similarity (F2+F4)
|
||||||
|
- Config baru: `AI_LLM_EMBEDDING_MIN_SIMILARITY_ACTIONABLE` default **0.97** (perilaku lama),
|
||||||
|
`AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN` default **0.92**, keduanya coerce number 0..1.
|
||||||
|
- Satu Qdrant batch search pakai threshold RENDAH (0.92). Per hit, klasifikasi ulang:
|
||||||
|
- verdict non-actionable (clean, no flags, action=none): terima jika `score >= CLEAN_BAND`;
|
||||||
|
- verdict actionable (warn/flagged atau flags ada / action != none): terima hanya jika
|
||||||
|
`score >= ACTIONABLE_BAND` (0.97 — persis gate lama);
|
||||||
|
- di antara dua band → buang hit, pesan lanjut ke LLM (fail-open ke akurasi).
|
||||||
|
- Legacy PG fallback path: filter serupa di `findSimilarTextModeration` via parameter band.
|
||||||
|
|
||||||
|
### D5 — Cache Wikipedia search (F6)
|
||||||
|
- `wikipediaClient.wikipediaSearch(query)`: cek `cacheGet(makeCacheKey("wikisearch", q))` dulu;
|
||||||
|
miss → fetch (timeout existing) → sukses & hasil non-kosong → `cacheSet(..., TTL 6h)`.
|
||||||
|
Hasil kosong TIDAK di-cache (biar retry nanti). Redis down → langsung fetch (no-op cache).
|
||||||
|
|
||||||
|
### D6 — Cache URL text fetch (F7)
|
||||||
|
- `urlFetcher.fetchUrlSafely(url)`: wrapper async memoize in-process LRU (max 500, TTL 30 menit)
|
||||||
|
untuk `type === "text"` saja (image tetap selalu fresh-download karena dipakai sbg bukti vision
|
||||||
|
+ buffer besar; error tidak di-cache).
|
||||||
|
- Import `LRUCache` dari `lru-cache` (sudah dep gateway).
|
||||||
|
|
||||||
|
### D7 — Unified vision cache key (F8)
|
||||||
|
- `makeImageCacheKey(imageUrl)` di `textCacheStore.ts`: sebelum hash, strip query Discord CDN
|
||||||
|
(`?ex=&is=&hm=` signed tokens, `format/width/height/size`) — regex `(\?[^#]*)$` dibuang bila host
|
||||||
|
CDN discord (`cdn.discordapp.com`, `media.discordapp.net`, `images-ext-*.discordapp.net`);
|
||||||
|
URL non-Discord: hash full URL seperti sekarang.
|
||||||
|
- Efek: attachment sama yang lolos lewat jalur embed vs inline vs re-fetch dgn token beda → SATU
|
||||||
|
entry cache → skip download+vision kedua kali. Data-URL base64 tetap di-hash apa adanya.
|
||||||
|
|
||||||
|
## File yang disentuh
|
||||||
|
|
||||||
|
1. `src/shared/config/index.ts` — 3 config baru (D3×2, D4×2 — total 4 nilai, 3 baris zod + deskripsi).
|
||||||
|
2. `src/modules/ai-moderation/textCacheStore.ts` — hit_count inc (D1), batched getter (D2),
|
||||||
|
global-reuse guard helper (D3), image-key normalize (D7).
|
||||||
|
3. `src/modules/ai-moderation/moderationOrchestrator.ts` — pakai batched getter (D2),
|
||||||
|
global bare-key fallback (D3), dua-band semantic accept (D4).
|
||||||
|
4. `src/modules/ai-moderation/qdrantClient.ts` — `searchQdrantBatch` menerima threshold rendah
|
||||||
|
(sudah parametrik — mungkin tanpa perubahan; verifikasi).
|
||||||
|
5. `src/modules/ai-moderation/wikipediaClient.ts` — cache layer (D5).
|
||||||
|
6. `src/modules/ai-moderation/urlFetcher.ts` — LRU text-fetch memoize (D6).
|
||||||
|
|
||||||
|
## Schema/type changes
|
||||||
|
|
||||||
|
- Tidak ada migrasi DB (kolom `hit_count`, `analyzed_at`, `expires_at` sudah ada).
|
||||||
|
- Tidak ada perubahan kontrak WS/oRPC/frontend.
|
||||||
|
- Type baru: none public; internal `StoredModerationVerdict` dipakai ulang.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Unit tests baru (`tests/`):
|
||||||
|
- `cacheBatchLookup.test.ts`: batched getter — hit/miss/expired/error-artifact mapping,
|
||||||
|
chunking >200 keys (mock executeAll), hit_count increment called.
|
||||||
|
- `globalReuseGuard.test.ts`: guard menerima clean+conf≥0.85+action none+umur ≤72h;
|
||||||
|
menolak flagged/warn/conf rendah/action≠none/stale.
|
||||||
|
- `semanticBands.test.ts`: clean @0.93 diterima, flagged @0.93 ditolak, flagged @0.98 diterima.
|
||||||
|
- `imageKeyNormalize.test.ts`: URL Discord dgn/ex token → key sama; non-Discord beda query → beda.
|
||||||
|
2. Gate service: `pnpm typecheck && pnpm exec biome check --diagnostic-level=error . && pnpm exec vitest run`.
|
||||||
|
3. Deploy via GHA (`git push origin main`) → watch `Build & Deploy (Nix)` → verifikasi
|
||||||
|
`systemctl show gmw-discord-gateway -p ActiveEnterTimestamp` baru.
|
||||||
|
4. Runtime probe pasca-deploy: journalctl level 30 normal; beberapa jam kemudian
|
||||||
|
`SELECT sum(hit_count) FROM text_analysis_cache WHERE source='user_moderation'` > 0 membuktikan
|
||||||
|
metrics jalan; log "User moderation cache applied" menunjukkan hits>0 pada traffic ramai.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Semua fitur behind config defaults yang mempertahankan perilaku lama pada nilai konservatif;
|
||||||
|
rollback = redeploy commit sebelumnya (tanpa migrasi DB, tanpa state eksternal).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Spec: Perbaiki Delay Attachment 162s→<20s (GMW AI Analysis)
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Repo `~/GMW` · Service discord-gateway
|
||||||
|
|
||||||
|
## Evidence (audit produksi)
|
||||||
|
|
||||||
|
Klaster pesan attachment delay ~330–400 detik. Trace pesan `1541417073245290638` (.gif):
|
||||||
|
19:01:08 dibuat → 19:01:09 batch incomplete → fan-out individual → **guard upload-pending
|
||||||
|
mengembalikan `results:[]`** → diperalakukan sukses (`complete ... (undefined)`) → row
|
||||||
|
tertahan `ai_status='processing'` **tanpa penanggung jawab** → 19:06:12 cleanup mengembalikan
|
||||||
|
ke `pending` (tepat 300s) → baru dianalisis. Plus vision gagal 3× utk GIF besar
|
||||||
|
("Stream ended before producing a non-ping SSE event") → degradasi teks.
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
- **A (fatal)**: `individualFallbackProcessor.processIndividualFallback` memperlakukan
|
||||||
|
`ok:true + results:[]` sebagai sukses. Race-guard upload di `ai-analysis-worker.processIndividual`
|
||||||
|
sengaja balik `results:[]` (desain lama) → pesan yatim `processing` sampai cleanup 300s.
|
||||||
|
- **B**: `llmVision` hanya mencoba `stream:true`; kegagalan SSE truncation pada gambar besar
|
||||||
|
= 3 retry sia-sia (semua jalur sama) → bukti media hilang.
|
||||||
|
- **C**: safety-net cleanup 300s terlalu lambat sbg satu-satunya pemulih `processing`.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
1. **F1 — sinyal eksplisit upload-pending**: `IndividualOkResponse` + field opsional
|
||||||
|
`uploadPending?: boolean`. Worker set `uploadPending:true` saat race guard kena.
|
||||||
|
2. **F2 — processor menangani 3 kondisi** via helper murni baru
|
||||||
|
`classifyIndividualWorkerResult(result): "success" | "upload_pending" | "incomplete" | "error"`
|
||||||
|
(modul baru `fallbackResultClassifier.ts`, zero-dep agar mudah dites):
|
||||||
|
- `upload_pending` → tulis ulang row ke `pending` (pola sama dgn revert apiFailed di
|
||||||
|
batchProcessor) + broadcast + **re-schedule analisis percakapan segera**
|
||||||
|
(dynamic import batchScheduler, pola anti-siklus yg sudah ada) → retry dalam ~250ms
|
||||||
|
begitu upload beres. Bukan error, tidak naikkan CB counter.
|
||||||
|
- `incomplete` (flags analysis_incomplete) → perilaku lama (exhausted path).
|
||||||
|
- `error` / `results kosong tanpa penjelasan` → throw transien (retry oleh recovery),
|
||||||
|
BUKAN sukses palsu. Log "(undefined)" hilang.
|
||||||
|
3. **F3 — vision non-stream fallback**: di `llmVision`, jika error match
|
||||||
|
`/Stream ended before producing a non-ping SSE|stream ended/i` → coba SEKALI lagi dengan
|
||||||
|
`stream:false` (router agregasi penuh; timeout tetap 60s). Konversi hard-fail jadi sukses.
|
||||||
|
4. **F4 — turunkan safety net**: default `revertStuckProcessingMessages` 300000 → 120000 ms.
|
||||||
|
|
||||||
|
## File disentuh
|
||||||
|
|
||||||
|
- `src/modules/ai-moderation/fallbackResultClassifier.ts` (BARU, pure)
|
||||||
|
- `src/modules/ai-moderation/ai-analysis-worker.ts` (tipe + set flag uploadPending)
|
||||||
|
- `src/modules/ai-moderation/individualFallbackProcessor.ts` (konsumsi classifier + reschedule)
|
||||||
|
- `src/modules/ai-moderation/llmClient.ts` (fallback non-stream di llmVision)
|
||||||
|
- `src/modules/message-capture/messagesCleanup.ts` (default 120s)
|
||||||
|
|
||||||
|
## Verifikasi
|
||||||
|
|
||||||
|
- Test baru `tests/fallbackResultClassifier.test.ts` (4 klasifikasi + edge kosong).
|
||||||
|
- Gate: tsc --noEmit, biome error-level, vitest run semua hijau.
|
||||||
|
- Deploy GHA sukses; pasca-deploy: pesan attachment baru p50 < 20s
|
||||||
|
(`SELECT percentile_cont(0.5) ... WHERE metadata attachments>0 AND created_at > deploy`),
|
||||||
|
tidak ada lagi "complete ... (undefined)".
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# GMW FE Rombak — "Constellation Ops" Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
> Setiap task = edit → lint → build/test → smoke → commit (author `asepharyana`, tanpa Co-Authored-By).
|
||||||
|
|
||||||
|
**Goal:** Rombak total FE GMW dari template dashboard klasik menjadi **Constellation Node Graph** — graf node interaktif (three.js force layout) di mana channel/event/moderasi adalah node bintang dan navigasi adalah terbang antar node — untuk semua 9 route sekaligus.
|
||||||
|
|
||||||
|
**Architecture:** Hapus total shell template (TopBar + NavRail + MobileNav + main scroll container). Ganti dengan satu `ConstellationStage`: canvas full-bleed fixed di belakang seluruh app yang me-render force-directed graph per-route (scene), plus overlay HTML mengambang (tanpa card box standar) untuk konten detail. Router tetap Next App Router; perpindahan route = kamera "fly-to" ke scene baru. Data tetap lewat pola SSR seed (`page.tsx` server fetch → `view.tsx` client + SWR fallbackData) — tidak ada endpoint baru yang diarankan.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 (token oklch existing) · three r180 (+ `@types/three`) · d3-force (+ `@types/d3-force`) untuk simulasi layout deterministik · motion v12 (transisi antar-scene) · biome (0 error **dan** 0 warning) · bun:test untuk modul murni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keputusan Terkunci (2026-08-24, user MythEclipse)
|
||||||
|
|
||||||
|
| Keputusan | Pilihan user |
|
||||||
|
|---|---|
|
||||||
|
| Cakupan | **Semua 9 route sekali jalan** (dashboard, voice, media, recordings, moderation, messages, analysis, channels, glossary) |
|
||||||
|
| Arah struktural | **Constellation Node Graph** — navigasi = terbang antar node |
|
||||||
|
| Tema | **Pertahankan toggle light/dark** (jangan rebuild sistem tema — harden saja) |
|
||||||
|
|
||||||
|
## Kondisi Saat Ini (fakta repo, 2026-08-24)
|
||||||
|
|
||||||
|
- Repo: `/home/code/GMW/services/frontend` (monorepo GMW, deploy Nix-first via GHA "Build & Deploy (Nix)" ke `imphnen.asepharyana.my.id`; nginx gmw-proxy :4009 → Next standalone :4017).
|
||||||
|
- Shell aktif: `src/components/shell/ambient-app.tsx` → `AppFrame` = NavRail + TopBar + `<main>` scroll + MobileNav + MiniPlayer, dipakai SEMUA route via `(dashboard)/layout.tsx`. AmbientCanvas (233 l) masih ada sebagai background di belakang chrome template.
|
||||||
|
- 9 route di `src/app/(dashboard)/`: dashboard (368 l), voice (311), media (293), moderation (424), messages (611), analysis (196), recordings (201), channels (23), glossary (15).
|
||||||
|
- `next.config.ts`: `output: "standalone"` + `trailingSlash: true` → **nav via `router.push`/Link bisa no-op; pakai `<a href>` polos** (pitfall terverifikasi).
|
||||||
|
- Token desain ada di `globals.css` `@theme`: `--color-canvas/-2`, `--color-ink/-soft/-faint`, `--color-signal(+glow)`, amber, vermilion, hairline; font Bricolage Grotesque/JetBrains Mono; `.light` class override + next-themes sudah jalan — JANGAN dibangun ulang.
|
||||||
|
- Sudah ada & dipertahankan: CommandPalette (⌘K), Chatbot, MiniPlayer, primitives (GlassPanel/GlassCard/Badge/…), shared states+skeletons, `lib/format.ts`, `lib/ai-status.ts`, WS typed events (`message_created/updated/deleted/analyzed/snapshot*`, `voice_state`, dll).
|
||||||
|
- Tooling lokal: `pnpm lint|build|format`, `pnpm exec biome check --write --unsafe .` buat import order; bun 1.3.14 tersedia untuk unit test modul murni.
|
||||||
|
- Smoke test SELALU di port non-prod (mis. 4024), cek `ss -ltnp | grep <port>` dulu; JANGAN pernah bind 4017.
|
||||||
|
|
||||||
|
## Struktur Target
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/shell/
|
||||||
|
constellation-frame.tsx # pengganti AppFrame: tanpa topbar/rail/bottom-bar
|
||||||
|
constellation-stage.tsx # canvas three.js full-bleed + hit-test + kamera
|
||||||
|
route-scenes.ts # map pathname → scene config (nodes/edges/focus)
|
||||||
|
floating-chrome.tsx # brand mark + status dot + route switcher minim + palette hint
|
||||||
|
(hapus setelah migrasi): topbar.tsx, nav-rail.tsx, mobile-nav.tsx, ambient-app.tsx*
|
||||||
|
src/lib/constellation/
|
||||||
|
graph.ts # tipe GraphNode/GraphEdge + builder dari data API (murni)
|
||||||
|
layout.ts # wrapper d3-force deterministik (seeded) + fallback radial (murni)
|
||||||
|
camera.ts # interpolasi kamera fly-to (murni, testable)
|
||||||
|
*.test.ts # bun:test utk modul murni di atas
|
||||||
|
```
|
||||||
|
|
||||||
|
*ambient-canvas/context dievaluasi: bisa di-refactor jadi bagian dari stage atau dihapus.
|
||||||
|
|
||||||
|
### Scene per route (bahasa visual satu kesatuan)
|
||||||
|
|
||||||
|
| Route | Scene |
|
||||||
|
|---|---|
|
||||||
|
| `/dashboard/` | Guild = bintang pusat; channel = node orbit; event WS = pulse edge; metric cluster floating (tanpa box) |
|
||||||
|
| `/channels/` | Semua channel sebagai konstelasi; klik channel = fly-to + panel detail mengambang |
|
||||||
|
| `/moderation/` | Pesan flagged = node merah (vermilion) mengelilingi hub AI-verdict; LiveModerationFeed jadi ribbon ticker |
|
||||||
|
| `/messages/` | Stream pesan = sabuk orbital; node baru masuk lewat animasi dari tepi |
|
||||||
|
| `/voice/` | Stage node di tengah; speaker aktif = node mengorbit dengan glow intensitas |
|
||||||
|
| `/media/` | Galeri spiral node (thumbnail via div bg-image); klik = MiniPlayer handoff |
|
||||||
|
| `/recordings/` | Variasi spiral media, timeline density sebagai cincin |
|
||||||
|
| `/analysis/` | Hub analitik; chart existing dirender sebagai panel translusen mengambang ter-anchor ke node |
|
||||||
|
| `/glossary/` | Term KB = cincin satelit; search memfilter node real-time |
|
||||||
|
|
||||||
|
### Checklist anti-default (wajib lolos sebelum commit akhir)
|
||||||
|
|
||||||
|
- [ ] Tidak ada top bar / nav rail / side panel / bottom prompt bar
|
||||||
|
- [ ] Tidak ada card grid standar sebagai struktur utama
|
||||||
|
- [ ] Layout beda SPASIAL dari versi lama (bukan ganti warna)
|
||||||
|
- [ ] Verifikasi visual sungguhan: browser_navigate + browser_vision → "bukan template dashboard"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 0 — Fondasi
|
||||||
|
|
||||||
|
#### Task 1: Dependencies + scaffold direktori
|
||||||
|
**Files:** `package.json`, create `src/lib/constellation/`
|
||||||
|
1. `cd /home/code/GMW/services/frontend && pnpm add three @types/three d3-force @types/d3-force && pnpm add -d @types/bun`
|
||||||
|
2. Buat 4 file kosong bertipe sesuai struktur target (ekspor placeholder bertipe agar tsc lolos).
|
||||||
|
3. Run: `pnpm exec tsc --noEmit` → expected PASS.
|
||||||
|
4. Commit: `chore(frontend): scaffold constellation lib + deps`.
|
||||||
|
|
||||||
|
> Catatan: jika `@types/bun` bentrok dengan tsconfig Next (duplikat globals), fallback: exclude `"**/*.test.ts"` dari `tsconfig.json` dan jalankan test hanya via `bun test` (tanpa typecheck). Putuskan saat eksekusi, dokumentasikan di commit body.
|
||||||
|
|
||||||
|
#### Task 2 (TDD): Modul murni graph + layout + camera
|
||||||
|
**Files:** `src/lib/constellation/{graph,layout,camera}.ts` + `*.test.ts`
|
||||||
|
1. **Tulis test dulu** (bun:test):
|
||||||
|
- `graph.test.ts`: builder `dashboardToGraph()` menghasilkan node guild pusat + N channel + edges benar dari fixture `/api/dashboard/stats|channels` shape (lihat AGENTS.md; nama channel di `message.metadata.channelName`).
|
||||||
|
- `layout.test.ts`: `computeLayout(nodes, edges, {seed:42})` DETERMINISTIK (dua pemanggilan = posisi identik), semua posisi finite, radius dalam bound; `radialLayout()` fallback untuk reduced-motion.
|
||||||
|
- `camera.test.ts`: `flyTo(from,to,t)` easing monotonic, t=0→from, t=1→to.
|
||||||
|
2. Run: `bun test src/lib/constellation` → expected FAIL (belum diimplementasi).
|
||||||
|
3. Implementasi minimal sampai PASS.
|
||||||
|
4. Run: `bun test src/lib/constellation && pnpm exec tsc --noEmit` → PASS.
|
||||||
|
5. Commit: `feat(frontend): constellation graph/layout/camera pure modules (tested)`.
|
||||||
|
|
||||||
|
### Phase 1 — Shell Baru
|
||||||
|
|
||||||
|
#### Task 3: `ConstellationStage` (canvas renderer)
|
||||||
|
**Files:** `src/components/shell/constellation-stage.tsx`
|
||||||
|
- `<canvas>` fixed inset-0 `-z-10`; three.js orthographic 2D-ish render node+edge (sprite/glow shader sederhana — jangan over-engineer; titik + garis + glow cukup).
|
||||||
|
- Interaksi: drag = pan, wheel/pinch = zoom, hover = highlight node + tooltip title, click node ber-`href` = navigasi via `<a>` semantics (window.location assign — hindari router.push no-op).
|
||||||
|
- DPR-aware resize; pause render loop saat `document.hidden`; `prefers-reduced-motion` → render statis tanpa animasi.
|
||||||
|
- Sampling warna dari CSS var (`getComputedStyle`) + `MutationObserver` pada `documentElement.classList` agar ikut toggle dark/light.
|
||||||
|
- Verify: `pnpm lint && pnpm build` PASS → commit `feat(frontend): constellation stage canvas renderer`.
|
||||||
|
|
||||||
|
#### Task 4: `route-scenes.ts` + `ConstellationFrame`
|
||||||
|
**Files:** `src/components/shell/{route-scenes,constellation-frame,floating-chrome}.tsx`
|
||||||
|
- `route-scenes.ts`: `usePathname()` → config scene (builder mana, focus node, overlay slots).
|
||||||
|
- `ConstellationFrame`: render `ConstellationStage` + `children` (overlay HTML absolute, BUKAN flex column bertingkat) + `floating-chrome` (brand mark kiri-atas dgn status dot WS, switcher route minimal kanan-bawah, hint ⌘K). MiniPlayer + Chatbot + CommandPalette tetap termounting.
|
||||||
|
- Switcher & semua nav internal pakai `<a href="/<route>/">` polos (trailingSlash!). Verifikasi klik di browser nanti, bukan cuma baca kode.
|
||||||
|
- Wire `(dashboard)/layout.tsx`: ganti `AppFrame` → `ConstellationFrame` (provider Ambient/WS tetap).
|
||||||
|
- Smoke: `ss -ltnp | grep 4024` kosong → `PORT=4024 pnpm start` → curl semua 9 route = 200 → kill server.
|
||||||
|
- Commit: `feat(frontend): replace classic shell with constellation frame`.
|
||||||
|
|
||||||
|
#### Task 5: Hapus chrome lama + bersih-bersih
|
||||||
|
**Files:** hapus `shell/topbar.tsx`, `shell/nav-rail.tsx`, `shell/mobile-nav.tsx`, update `shell/index.ts`; eval `ambient-*` (refactor jadi layer stage ATAU hapus).
|
||||||
|
- `grep -rn "TopBar\|NavRail\|MobileNav\|AppFrame" src/` → 0 referensi tersisa.
|
||||||
|
- `pnpm exec biome check --write --unsafe .` → import order rapi.
|
||||||
|
- Commit: `refactor(frontend): remove classic dashboard chrome`.
|
||||||
|
|
||||||
|
### Phase 2 — Migrasi 9 Route (batch, tiap batch ship hijau)
|
||||||
|
|
||||||
|
Urutan sengaja dari yang paling graph-native ke paling padat konten:
|
||||||
|
|
||||||
|
#### Task 6: `/dashboard/` + `/channels/`
|
||||||
|
- Dashboard view: guild-star + channel orbit + WS pulse; metric cluster floating (reuse MetricTile tapi tanpa card-box — ubah jadi teks besar + hairline).
|
||||||
|
- Channels: konstelasi channel, klik = fly-to + panel detail (data dari hook `use-channels`/server fetch existing — jangan karang endpoint).
|
||||||
|
- Lint+build+smoke 2 route → commit `feat(frontend): dashboard & channels constellation scenes`.
|
||||||
|
|
||||||
|
#### Task 7: `/glossary/` + `/analysis/`
|
||||||
|
- Glossary: cincin satelit term + search filter node (client-side filter pada node label).
|
||||||
|
- Analysis: hub node + panel chart translusen mengambang (reuse komponen charts/* existing, anchor posisi ke node).
|
||||||
|
- Commit: `feat(frontend): glossary & analysis scenes`.
|
||||||
|
|
||||||
|
#### Task 8: `/messages/` + `/moderation/`
|
||||||
|
- Messages: sabuk orbital stream (reuse sortMessages/logika WS `use-messages.ts` — jangan duplikasi); pesan baru animate-in dari tepi.
|
||||||
|
- Moderation: flagged nodes vermilion + hub verdict; LiveModerationFeed jadi ticker ribbon bawah (bukan panel).
|
||||||
|
- Commit: `feat(frontend): messages & moderation scenes`.
|
||||||
|
|
||||||
|
#### Task 9: `/voice/` + `/media/` + `/recordings/`
|
||||||
|
- Voice: speaker aktif orbit dgn glow (seed dari snapshot `activeSpeakers` server-authoritative — pertahankan perilaku useSpeakers).
|
||||||
|
- Media/Recordings: spiral galeri; thumbnail pakai div background-image (hindari `noImgElement`; kalau terpaksa `<img>` → `// biome-ignore lint/performance/noImgElement: <alasan>`); MiniPlayer handoff tidak boleh regress.
|
||||||
|
- Commit: `feat(frontend): voice, media & recordings scenes`.
|
||||||
|
|
||||||
|
### Phase 3 — Polish, A11y, Tema
|
||||||
|
|
||||||
|
#### Task 10: Motion & micro-interaction
|
||||||
|
- Fly-to antar scene pakai `motion` v12 (interpolasi kamera dari `lib/constellation/camera.ts`).
|
||||||
|
- Overlay: stagger masuk (pattern `animate-stagger` + `staggerDelay(i)` existing); tambah semua `animate-*` baru ke kill-list `prefers-reduced-motion` di `globals.css`.
|
||||||
|
- Commit: `feat(frontend): scene transitions + motion polish`.
|
||||||
|
|
||||||
|
#### Task 11: A11y & fallback semantik
|
||||||
|
- Setiap scene punya fallback semantik: `<nav aria-label="Routes">` visually-hidden berisi link semua route + `<ul>` node (label + href) yang ter-sync dgn graph state.
|
||||||
|
- Keyboard: Tab ke daftar route; panah memindahkan fokus antar node; Enter aktivasi; `focus-visible` ring pakai `--color-ring`.
|
||||||
|
- Kontras ink-on-canvas dicek (WCAG AA utk teks overlay).
|
||||||
|
- Hooks-after-early-return pitfall: compute flags dulu, guard di dalam useEffect.
|
||||||
|
- Commit: `feat(frontend): a11y semantic fallback for constellation`.
|
||||||
|
|
||||||
|
#### Task 12: Hardening tema light/dark
|
||||||
|
- Pastikan canvas sampling var `.light` benar saat toggle (MutationObserver dari Task 3); harden `.light`: `color-scheme: light`, scrollbar/selection, glow opacity turun.
|
||||||
|
- JANGAN menyentuh sistem next-themes.
|
||||||
|
- Commit: `fix(frontend): light theme parity for constellation stage`.
|
||||||
|
|
||||||
|
### Phase 4 — Gerbang Ship (non-negotiable)
|
||||||
|
|
||||||
|
#### Task 13: Verifikasi penuh + deploy
|
||||||
|
1. `pnpm lint` → **Found 0 errors. Found 0 warnings** (warning pun harus bersih).
|
||||||
|
2. `bun test src/lib/constellation` → all PASS; `pnpm exec tsc --noEmit` PASS; `pnpm build` sukses.
|
||||||
|
3. Smoke: port 4024 bersih → start → curl 9 route (dgn trailing slash) semua **200** → kill server.
|
||||||
|
4. `git status` audit: hanya file scope yang ter-commit (`git checkout --` file liar hasil auto-format).
|
||||||
|
5. Push `origin/main` → `gh run watch` "Build & Deploy (Nix)" → hijau.
|
||||||
|
6. Live: curl `https://imphnen.asepharyana.my.id/<route>/` × 9 → 200.
|
||||||
|
7. **Verifikasi visual sungguhan**: browser_navigate ke live URL → browser_vision: "Apakah ini masih template dashboard?" — jawaban HARUS bukan; screenshot dikirim ke user (`MEDIA:<path>`).
|
||||||
|
8. Checklist anti-default dicentang satu per satu di PR/commit message akhir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Likely to Change
|
||||||
|
|
||||||
|
```
|
||||||
|
package.json, pnpm-lock.yaml
|
||||||
|
next.config.ts # hanya jika perlu (tidak diharapkan)
|
||||||
|
src/app/(dashboard)/layout.tsx # wire frame baru
|
||||||
|
src/app/(dashboard)/{9 route}/page.tsx|view.tsx # scene migration
|
||||||
|
src/components/shell/** # frame baru + hapus lama
|
||||||
|
src/lib/constellation/** # baru (murni + test)
|
||||||
|
src/app/globals.css # tokens tambahan + reduced-motion kill list
|
||||||
|
biome.json # hanya jika rule intentional baru perlu "off"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Tradeoffs
|
||||||
|
|
||||||
|
| Risiko | Mitigasi |
|
||||||
|
|---|---|
|
||||||
|
| Perf GPU di device lemah (full-page WebGL) | Node count kecil (<200/route), pause saat tab hidden, fallback radial statis utk reduced-motion; jika tetap berat → downgrade ke Canvas2D renderer dgn API sama |
|
||||||
|
| router.push no-op (standalone+trailingSlash) | Semua nav pakai `<a href>`; verifikasi klik nyata di browser |
|
||||||
|
| Konten padat (messages 611 l) tak muat di metafora graf | Prinsip: graf = struktur/navigasi; DETAIL tetap lewat overlay panel mengambang — konten tidak dikorbankan, hanya wadahnya yang berubah |
|
||||||
|
| Bun test + Next tsconfig konflik types | Exclude `*.test.ts` dari tsconfig (keputusan Task 1) |
|
||||||
|
| Regress fitur existing (theme toggle, palette, chatbot, mini-player, WS sync) | Dipertahankan apa adanya; smoke + klik verifikasi per phase; sortMessages & snapshot voice TIDAK disentuh logikanya |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Tidak ada blocker. Satu preferensi opsional: apakah brand mark perlu jam/uptime seperti TopBar lama — default: tidak (minimal). Keputusan bisa diambil saat review Task 4.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# GMW FE — Monokrom Hitam-Putih + Sidebar Ala Menu Game + Ringan di Mobile
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Basis: `eda5c75` (shell usable hasil revert)
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
1. Tema **monokrom murni** (hitam-putih, tanpa warna) di dark & light.
|
||||||
|
2. Sidebar (desktop NavRail + mobile dock) beranimasi **ala menu game** — corner
|
||||||
|
brackets, sweep, stagger masuk, marker segitiga.
|
||||||
|
3. **Ringan di mobile**: matikan WebGL ambient di layar kecil, kurangi biaya
|
||||||
|
blur/backdrop, animasi transform/opacity saja.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Tidak menyentuh backend, endpoint, hooks/data-flow, struktur route.
|
||||||
|
- Tidak menambah dependensi baru (CSS murni untuk semua animasi).
|
||||||
|
|
||||||
|
## File yang disentuh
|
||||||
|
| File | Perubahan |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/globals.css` | Token mono (dark+light): signal/amber/vermilion → skala putih-abu; `.glass` blur adaptif; kelas baru `.game-nav-item` (bracket ::before/::after, sweep, stagger via `--i`), `.game-frame` (panel sudut terpotong + garis tergambar), keyframes `sweep-x`, `draw-line`, `nav-in`; media query `<md`: blur 18→8px, hambat animasi berat |
|
||||||
|
| `src/components/shell/nav-rail.tsx` | Item pakai `.game-nav-item` + `style={{'--i': n}}`; marker aktif jadi segitiga ▸ putih; hapus box-shadow glow besar (ganti sweep) |
|
||||||
|
| `src/components/shell/mobile-nav.tsx` | Dock mono: tab aktif = bar atas putih + sweep sekali; target sentuh ≥44px; hapus glow blob |
|
||||||
|
| `src/components/shell/topbar.tsx` | Aksen mono + `.game-frame` pada container (cek markup dulu) |
|
||||||
|
| `src/components/ambient/ambient-canvas.tsx` | Early-return WebGL bila `(pointer: coarse)` / lebar <768 / `saveData` / core ≤4; fallback statik CSS tetap |
|
||||||
|
| `src/components/ambient/status/signal tone` (`SIGNAL_RGB`) | Semua tone jadi grayscale (putih; intensitas beda per tone) |
|
||||||
|
| `src/app/(dashboard)/dashboard/view.tsx` | Hero + kartu metrik pakai `.game-frame`/cut-corner sebagai showcase |
|
||||||
|
|
||||||
|
## Keputusan desain
|
||||||
|
- **Full monokrom termasuk danger**: flag/moderation tidak lagi merah —
|
||||||
|
ditandai badge putih-di-atlas-hitam inversi + pulse. Kalau user kangen merah,
|
||||||
|
tinggal isi ulang `--color-vermilion`.
|
||||||
|
- Semua animasi hanya `transform`/`opacity` (compositor-friendly), hormati
|
||||||
|
`prefers-reduced-motion` (sudah ada kill-switch global).
|
||||||
|
|
||||||
|
## Verifikasi (gerbang)
|
||||||
|
1. `tsc --noEmit` bersih; biome 0 error 0 warning.
|
||||||
|
2. `pnpm build` sukses; smoke lokal 4024 → 9 route 200.
|
||||||
|
3. Push → GHA "Build & Deploy (Nix)" hijau → live 9×200.
|
||||||
|
4. Visual check live: desktop (rail game-menu terlihat) + cek rule mobile
|
||||||
|
(media query & gate kode) — screenshot disimpan.
|
||||||
+9
-3
@@ -35,12 +35,15 @@
|
|||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off",
|
"noUnknownAtRules": "off",
|
||||||
"useIterableCallbackReturn": "off",
|
"useIterableCallbackReturn": "off",
|
||||||
"noArrayIndexKey": "warn"
|
"noArrayIndexKey": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"useSemanticElements": "off",
|
"useSemanticElements": "off",
|
||||||
"useButtonType": "off",
|
"useButtonType": "off",
|
||||||
"noAutofocus": "off"
|
"noAutofocus": "off",
|
||||||
|
"useMediaCaption": "off",
|
||||||
|
"noStaticElementInteractions": "off"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "warn"
|
"noImgElement": "warn"
|
||||||
@@ -50,7 +53,10 @@
|
|||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noInvalidUseBeforeDeclaration": "off",
|
"noInvalidUseBeforeDeclaration": "off",
|
||||||
"noUnusedFunctionParameters": "warn"
|
"noUnusedFunctionParameters": "warn",
|
||||||
|
"noUnusedVariables": "warn",
|
||||||
|
"noUnusedImports": "warn",
|
||||||
|
"noUnusedPrivateClassMembers": "warn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
|
|||||||
@@ -48,8 +48,8 @@
|
|||||||
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
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
|
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
|
||||||
# pnpm uses node-gyp for native addons — provide build tools
|
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||||
export npm_config_build_from_source=true
|
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||||
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
||||||
|
|
||||||
@@ -59,6 +59,36 @@
|
|||||||
pnpm rebuild 2>&1 || true
|
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 ----
|
||||||
backend = pkgs.stdenv.mkDerivation {
|
backend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-backend";
|
pname = "gmw-backend";
|
||||||
@@ -71,33 +101,10 @@
|
|||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Compiling TypeScript ==="
|
echo "=== Compiling TypeScript ==="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
let count = 0;
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name.endsWith('.js')) {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
|
||||||
const n = c.replace(pat, (m, p1) => {
|
|
||||||
const target = path.join('dist', p1) + '.js';
|
|
||||||
const rel = path.relative(path.dirname(p), target);
|
|
||||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
|
||||||
});
|
|
||||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('dist');
|
|
||||||
console.log('Fixed ' + count + ' files');
|
|
||||||
"
|
|
||||||
echo "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'';
|
'' + pruneProd;
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/lib/gmw-backend
|
mkdir -p $out/lib/gmw-backend
|
||||||
@@ -132,7 +139,7 @@ WRAPPER
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
pkgs.git # for any FetchContent-based deps during native builds
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -145,60 +152,37 @@ WRAPPER
|
|||||||
# do NOT let stdenv run its own cmake configure phase on the source.
|
# do NOT let stdenv run its own cmake configure phase on the source.
|
||||||
dontUseCmakeConfigure = true;
|
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 + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Building native voice deps ==="
|
echo "=== Building native voice deps ==="
|
||||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||||
# from the wrong cwd — build each native dep explicitly with its own
|
# from the wrong cwd — build each native dep explicitly with its own
|
||||||
# install script. Each failure is tolerated (|| true); the packages
|
# install script. Each failure is tolerated (|| true); the packages
|
||||||
# that matter (opus, datachannel, node-av) are verified at runtime.
|
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
|
||||||
for pkg in \
|
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
|
||||||
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
|
# instead of compiling C++ from source. With build_from_source unset
|
||||||
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
|
# (above), `pnpm rebuild` runs the package's own install script which
|
||||||
node_modules/.pnpm/zeromq@*/node_modules/zeromq
|
# fetches the matching prebuilt; it only falls back to a source build
|
||||||
do
|
# if the download fails. This keeps voice working without a per-build
|
||||||
if [ -d "$pkg" ]; then
|
# native compile.
|
||||||
echo "--- native build: $pkg ---"
|
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
|
||||||
(cd "$pkg" && npm run install 2>&1 || true)
|
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||||
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
|
echo "=== Compiling TypeScript ===="
|
||||||
# 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 ==="
|
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
let count = 0;
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name.endsWith('.js')) {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
|
||||||
const n = c.replace(pat, (m, p1) => {
|
|
||||||
const target = path.join('dist', p1) + '.js';
|
|
||||||
const rel = path.relative(path.dirname(p), target);
|
|
||||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
|
||||||
});
|
|
||||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('dist');
|
|
||||||
console.log('Fixed ' + count + ' files');
|
|
||||||
"
|
|
||||||
echo "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'';
|
'' + pruneProd;
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/lib/gmw-discord-gateway
|
mkdir -p $out/lib/gmw-discord-gateway
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ http {
|
|||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Backend oRPC (structured data RPCs over WebSocket + HTTP POST)
|
||||||
|
# Browser reaches this via partysocket (wss://…/trpc); SSR/RSC uses
|
||||||
|
# the fetch RPCLink (POST /trpc). Same path, same backend handler:
|
||||||
|
# oRPC's RPCHandler (HTTP) + ORPCWebSocketServer (WS) on :4001. ──
|
||||||
|
location ^~ /trpc {
|
||||||
|
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
# Upgrade headers required for the WebSocket transport; harmless for POST.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
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_buffering off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
# ── Next.js build assets — immutable, edge/shareable ───────────
|
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||||
location ^~ /_next/static/ {
|
location ^~ /_next/static/ {
|
||||||
proxy_pass http://gmw_next$uri$is_args$args;
|
proxy_pass http://gmw_next$uri$is_args$args;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Drop materi_documents table (feature removed)
|
||||||
|
-- Run: PGPASSWORD=<pw> psql -h <host> -U <user> -d <db> -f scripts/drop-materi-documents.sql
|
||||||
|
-- Reverses scripts/add-materi-documents.sql which was deleted with the feature.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_materi_search;
|
||||||
|
DROP INDEX IF EXISTS idx_materi_guild;
|
||||||
|
DROP INDEX IF EXISTS idx_materi_owner;
|
||||||
|
DROP INDEX IF EXISTS idx_materi_category;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS public.materi_documents;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
|
"@orpc/server": "1.15.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -31,10 +32,10 @@
|
|||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^25.9.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
// Rewrite import specifiers in the compiled dist/ so the output runs under
|
||||||
|
// plain `node dist/index.js` (native ESM, no bundler / no tsx).
|
||||||
|
//
|
||||||
|
// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE
|
||||||
|
// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves
|
||||||
|
// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects
|
||||||
|
// extensionless relative specifiers and knows nothing about the `@/` alias, so
|
||||||
|
// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script
|
||||||
|
// fixes both:
|
||||||
|
// 1. `@/foo` -> relative path to dist/foo.js
|
||||||
|
// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js)
|
||||||
|
// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare
|
||||||
|
// package specifiers are left untouched (idempotent).
|
||||||
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join, relative, dirname } from "node:path";
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
function walk(dir) {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) walk(p);
|
||||||
|
else if (e.name.endsWith(".js")) {
|
||||||
|
const c = readFileSync(p, "utf8");
|
||||||
|
const pat = /from\s+['"]([^'"]+)['"]/g;
|
||||||
|
const n = c.replace(pat, (m, spec) => {
|
||||||
|
if (spec.startsWith("@/")) {
|
||||||
|
// Source may already carry an extension (e.g. "@/shared/config/index.js");
|
||||||
|
// only append ".js" when the specifier has none — otherwise we'd
|
||||||
|
// produce "index.js.js".
|
||||||
|
const core = spec.slice(2);
|
||||||
|
let target;
|
||||||
|
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
|
||||||
|
target = join("dist", core);
|
||||||
|
} else {
|
||||||
|
target = join("dist", core) + ".js";
|
||||||
|
}
|
||||||
|
let rel = relative(dirname(p), target);
|
||||||
|
if (!rel.startsWith(".")) rel = "./" + rel;
|
||||||
|
return `from "${rel}"`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(spec.startsWith("./") || spec.startsWith("../")) &&
|
||||||
|
!/\.(js|json|node|mjs|cjs)$/.test(spec)
|
||||||
|
) {
|
||||||
|
return `from "${spec}.js"`;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
if (n !== c) {
|
||||||
|
writeFileSync(p, n);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("dist");
|
||||||
|
console.log(`Fixed ${count} import specifiers in dist/`);
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { onError } from "@orpc/server";
|
||||||
|
import { RPCHandler } from "@orpc/server/node";
|
||||||
import express, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
@@ -6,20 +8,17 @@ import express, {
|
|||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
|
||||||
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
|
||||||
import { createConfigRouter } from "../modules/config/index.js";
|
|
||||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
|
||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createMediaRouter } from "../modules/media/index.js";
|
import { appRouter } from "../orpc/router";
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
|
||||||
import { createModerationRouter } from "../modules/moderation/index.js";
|
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
|
||||||
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
|
||||||
import { createVoiceRouter } from "../modules/voice/index.js";
|
|
||||||
import { errorHandler } from "../shared/middlewares/index.js";
|
import { errorHandler } from "../shared/middlewares/index.js";
|
||||||
|
|
||||||
// Auth removed — dashboard is public
|
// Auth removed — dashboard is public.
|
||||||
|
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
|
||||||
|
// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO
|
||||||
|
// transports sharing the /trpc path:
|
||||||
|
// - WebSocket (browser live RPCs) — see orpc/ws.ts
|
||||||
|
// - HTTP POST (server-side / RSC fetch) — handled below
|
||||||
|
// Only infra endpoints (health, prometheus metrics) remain plain HTTP.
|
||||||
|
|
||||||
const logger = createChildLogger("http.app");
|
const logger = createChildLogger("http.app");
|
||||||
|
|
||||||
@@ -33,7 +32,7 @@ export function createHttpApp(): Express {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Body parsing
|
// Body parsing (still needed for any JSON POST; oRPC is WS/HTTP-based)
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
@@ -59,24 +58,38 @@ export function createHttpApp(): Express {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// All routes are public
|
// Infra-only HTTP endpoints
|
||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
app.use("/api", createConfigRouter());
|
|
||||||
app.use("/api", createDashboardRouter());
|
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
|
||||||
app.use("/api", createMessagesRouter());
|
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
|
||||||
app.use("/api", createAnalysisRouter());
|
// response itself; if no procedure matched we fall through to the 404 below.
|
||||||
app.use("/api", createChatbotRouter());
|
const orpcHandler = new RPCHandler(appRouter, {
|
||||||
app.use("/api", createRecordingsRouter());
|
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
|
||||||
app.use("/api", createUiStateRouter());
|
});
|
||||||
app.use("/api", createMediaRouter());
|
|
||||||
app.use("/api", createVoiceRouter());
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
app.use("/api", createModerationRouter());
|
if (!req.path.startsWith("/trpc")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orpcHandler
|
||||||
|
.handle(req, res, { prefix: "/trpc", context: {} })
|
||||||
|
.then(({ matched }) => {
|
||||||
|
if (!matched) next();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.error({ err }, "oRPC HTTP handler failed");
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: "NOT_FOUND",
|
error: "NOT_FOUND",
|
||||||
message: "Endpoint not found",
|
message:
|
||||||
|
"Endpoint not found — data APIs are served over /trpc (WebSocket/HTTP)",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { createORPCWebSocketServer } from "../orpc/ws.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { initializeDatabase } from "../shared/database/index.js";
|
import { initializeDatabase } from "../shared/database/index.js";
|
||||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||||
@@ -16,8 +17,9 @@ export async function startHttpServer(): Promise<Server> {
|
|||||||
|
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
// Attach WebSocket server to the same HTTP server
|
// Attach WebSocket servers to the same HTTP server
|
||||||
createWebSocketServer(server);
|
createWebSocketServer(server); // /ws — voice PCM + gateway events
|
||||||
|
createORPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||||
|
|
||||||
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
||||||
await startRedisBridge();
|
await startRedisBridge();
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { analysisService } from "./analysis.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("analysis.routes");
|
|
||||||
|
|
||||||
export function createAnalysisRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/analysis/search
|
|
||||||
router.get(
|
|
||||||
"/analysis/search",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const q = (req.query.q as string) || "";
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
|
|
||||||
logger.debug({ q, channelId, limit }, "Analysis search requested");
|
|
||||||
const result = await analysisService.search({ q, channelId, limit });
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createAnalysisRouter } from "./analysis.routes.js";
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { chatbotService } from "./chatbot.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.controller");
|
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
|
|
||||||
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
|
|
||||||
* auth middleware userId takes precedence when present.
|
|
||||||
*/
|
|
||||||
function resolveUserId(req: Request): string {
|
|
||||||
const authId = (req as AuthenticatedRequest).userId;
|
|
||||||
if (authId) return authId;
|
|
||||||
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
|
|
||||||
return header || "anonymous";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleChatbotChat = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { message, context } = req.body as {
|
|
||||||
message: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!message || typeof message !== "string") {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "INVALID_INPUT",
|
|
||||||
message: "Message is required and must be a string",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user ID from X-User-Id header (no-login device uuid) or auth
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{ userId, messageLength: message.length, context },
|
|
||||||
"Received chatbot chat message",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process message & generate response
|
|
||||||
const response = await chatbotService.processMessage(
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Save conversation to database
|
|
||||||
await chatbotService.saveConversation({
|
|
||||||
userId,
|
|
||||||
userMessage: message,
|
|
||||||
botResponse: response,
|
|
||||||
context,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info({ userId }, "Chatbot chat processed successfully");
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
response,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
|
||||||
|
|
||||||
const history = await chatbotService.getChatHistory(userId, limit);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
history,
|
|
||||||
total: history.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clearChatbotHistory = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = resolveUserId(req);
|
|
||||||
|
|
||||||
await chatbotService.clearChatHistory(userId);
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
message: "Chat history cleared successfully",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
|
import { pgChatbotMessagesTable } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.repository");
|
const logger = createChildLogger("chatbot.repository");
|
||||||
@@ -31,13 +31,6 @@ export interface ChatbotHistoryRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInsights {
|
|
||||||
total_messages: number;
|
|
||||||
active_users: number;
|
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatbotRepository {
|
export class ChatbotRepository {
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -83,56 +76,6 @@ export class ChatbotRepository {
|
|||||||
"Chat history cleared",
|
"Chat history cleared",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerInsights(
|
|
||||||
guildId?: string,
|
|
||||||
channelId?: string,
|
|
||||||
): Promise<ServerInsights> {
|
|
||||||
try {
|
|
||||||
const db = getDatabase();
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
|
||||||
}
|
|
||||||
if (channelId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [result] = await db
|
|
||||||
.select({
|
|
||||||
total_messages: sql<number>`COUNT(*)::int`,
|
|
||||||
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
|
||||||
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
|
||||||
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
|
||||||
})
|
|
||||||
.from(pgMessagesTable)
|
|
||||||
.where(where);
|
|
||||||
|
|
||||||
const insights = result ?? {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug({ guildId, channelId, insights }, "Server insights fetched");
|
|
||||||
return insights;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{ error, guildId, channelId },
|
|
||||||
"Failed to load server insights",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatbotRepository = new ChatbotRepository();
|
export const chatbotRepository = new ChatbotRepository();
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import express, { type Router } from "express";
|
|
||||||
import { validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
clearChatbotHistory,
|
|
||||||
getChatbotHistory,
|
|
||||||
handleChatbotChat,
|
|
||||||
} from "./chatbot.controller.js";
|
|
||||||
import { chatRequestSchema } from "./chatbot.schema.js";
|
|
||||||
|
|
||||||
export function createChatbotRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
|
|
||||||
router.get("/chat/history", getChatbotHistory);
|
|
||||||
router.delete("/chat/history", clearChatbotHistory);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ import type {
|
|||||||
SaveConversationInput,
|
SaveConversationInput,
|
||||||
} from "./chatbot.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { chatbotRepository } from "./chatbot.repository.js";
|
import { chatbotRepository } from "./chatbot.repository.js";
|
||||||
import { executeTool, tools } from "./chatbot.tools.js";
|
import { tools } from "./chatbot.toolDefs.js";
|
||||||
|
import { executeTool } from "./chatbot.tools.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.service");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
@@ -17,22 +18,26 @@ class ChatbotService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, messageLength: message.length },
|
{ userId, messageLength: message.length, context },
|
||||||
"processMessage called",
|
"processMessage called",
|
||||||
);
|
);
|
||||||
const recentContext = await this.getRecentConversationContext(userId);
|
const recentContext = await this.getRecentConversationContext(userId);
|
||||||
const serverInsights = await chatbotRepository.getServerInsights(
|
// Scope the agent to the server/channel the user is chatting in. We no
|
||||||
context?.guildId,
|
// longer bake server stats into the prompt — the model must pull current
|
||||||
context?.channelId,
|
// data via tools (see buildSystemPrompt), so it always answers from live
|
||||||
);
|
// numbers instead of a stale snapshot.
|
||||||
|
const scope = {
|
||||||
|
guildId: context?.guildId,
|
||||||
|
channelId: context?.channelId,
|
||||||
|
};
|
||||||
|
|
||||||
// Build LLM messages
|
const systemPrompt = this.buildSystemPrompt(scope);
|
||||||
const systemPrompt = this.buildSystemPrompt(serverInsights);
|
|
||||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
const conversationHistory = this.buildHistoryMessages(recentContext);
|
||||||
const llmResponse = await this.callLLM(
|
const llmResponse = await this.callLLM(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
message,
|
message,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
|
|
||||||
return llmResponse;
|
return llmResponse;
|
||||||
@@ -66,27 +71,29 @@ class ChatbotService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(insights: {
|
private buildSystemPrompt(scope: {
|
||||||
total_messages: number;
|
guildId?: string;
|
||||||
active_users: number;
|
channelId?: string;
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}): string {
|
}): string {
|
||||||
return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
|
const scopeLine = scope.guildId
|
||||||
|
? `- Scope: kamu menjawab soal server/guild id="${scope.guildId}"${scope.channelId ? `, channel id="${scope.channelId}"` : ""}.`
|
||||||
|
: "- Scope: tidak ada guild spesifik — jawab umum soal server ini.";
|
||||||
|
return `Kamu adalah chatbot Discord Watcher — temen ngobrol yang tau keadaan server, dan kamu PUNYA AKSES ke data server lewat tools.
|
||||||
|
|
||||||
Data server saat ini:
|
${scopeLine}
|
||||||
- Pesan: ${insights.total_messages}
|
|
||||||
- User aktif: ${insights.active_users}
|
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||||
- Flagged: ${insights.flagged}
|
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
||||||
- Warning: ${insights.warned}
|
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
|
||||||
|
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
|
||||||
|
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
||||||
|
|
||||||
Gaya ngobrol:
|
Gaya ngobrol:
|
||||||
- Santai, hangat, kayak ngobrol sama temen
|
- Santai, hangat, kayak ngobrol sama temen
|
||||||
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
||||||
- Sesekali pake emoji wajar aja, ga berlebihan
|
- Sesekali pake emoji wajar aja, ga berlebihan
|
||||||
- Kalo ditanya sesuatu yang kamu tau dari data server, jawab pake data itu
|
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||||
- Kalo ga tau atau ga nyambung, bilang aja terus tanya balik biar ngobrolnya jalan
|
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
|
||||||
- Jangan sebut "rule", "instruksi", "prompt" atau apapun soal cara kamu berpikir
|
|
||||||
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ Gaya ngobrol:
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
history: Array<{ role: "user" | "assistant"; content: string }>,
|
history: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
|
scope: { guildId?: string; channelId?: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = config.AI_LLM_API_KEY;
|
const apiKey = config.AI_LLM_API_KEY;
|
||||||
const baseUrl = config.AI_LLM_BASE_URL;
|
const baseUrl = config.AI_LLM_BASE_URL;
|
||||||
@@ -150,7 +158,13 @@ Gaya ngobrol:
|
|||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
temperature: 0.4,
|
temperature: 0.4,
|
||||||
stream: true,
|
// Non-streaming: request a single complete response. 9router may
|
||||||
|
// still emit SSE even with stream:false, so the parser below
|
||||||
|
// handle both raw-JSON and SSE bodies.
|
||||||
|
stream: false,
|
||||||
|
// Disable extended thinking / reasoning tokens so the bot answers
|
||||||
|
// directly (ignored by non-reasoning models).
|
||||||
|
reasoning_effort: "none",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -158,14 +172,16 @@ Gaya ngobrol:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 45_000,
|
timeout: 45_000,
|
||||||
// 9router returns SSE even without stream:true; force stream:true
|
|
||||||
// in the body and read the raw SSE text.
|
|
||||||
responseType: "text",
|
responseType: "text",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse SSE `data:` lines → content + tool_calls.
|
// Parse the body into content + tool_calls. 9router may return either
|
||||||
const { content, toolCalls } = this.parseSse(response.data as string);
|
// a single JSON object (stream:false honored) or SSE text (stream
|
||||||
|
// implied) — parseResponse handles both.
|
||||||
|
const { content, toolCalls } = this.parseResponse(
|
||||||
|
response.data as string,
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{
|
{
|
||||||
@@ -190,9 +206,19 @@ Gaya ngobrol:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// Auto-scope: if the model omitted guildId/channelId, fill them
|
||||||
|
// from the request scope so tools query the right server without
|
||||||
|
// the model having to guess IDs.
|
||||||
|
const scopedArgs = { ...tc.args };
|
||||||
|
if (scope.guildId && scopedArgs.guildId == null) {
|
||||||
|
scopedArgs.guildId = scope.guildId;
|
||||||
|
}
|
||||||
|
if (scope.channelId && scopedArgs.channelId == null) {
|
||||||
|
scopedArgs.channelId = scope.channelId;
|
||||||
|
}
|
||||||
let result = "";
|
let result = "";
|
||||||
try {
|
try {
|
||||||
result = await executeTool(tc.name, tc.args);
|
result = await executeTool(tc.name, scopedArgs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = `Tool error: ${(e as Error).message}`;
|
result = `Tool error: ${(e as Error).message}`;
|
||||||
}
|
}
|
||||||
@@ -224,6 +250,60 @@ Gaya ngobrol:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an LLM HTTP body into content + tool_calls. Handles both shapes
|
||||||
|
* 9router can return: a single JSON object (stream:false honored) or SSE
|
||||||
|
* text (stream implied). For SSE we delegate to parseSse.
|
||||||
|
*/
|
||||||
|
private parseResponse(body: string): {
|
||||||
|
content: string;
|
||||||
|
toolCalls: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
} {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
// Non-streaming response: a single JSON object.
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(trimmed) as {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: {
|
||||||
|
content?: string | null;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
function?: { name?: string; arguments?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
delta?: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const msg = json.choices?.[0]?.message;
|
||||||
|
// If the router returned SSE-style shape under `choices[].delta`
|
||||||
|
// (rare), fall through to the SSE parser.
|
||||||
|
if (msg) {
|
||||||
|
const content = msg.content ?? "";
|
||||||
|
const toolCalls = (msg.tool_calls ?? []).map((tc, i) => {
|
||||||
|
const id = tc.id || `tool_${i}_${Date.now()}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: tc.function?.name ?? "",
|
||||||
|
arguments: tc.function?.arguments ?? "",
|
||||||
|
args: this.safeJsonParse(tc.function?.arguments ?? ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { content: content.trim(), toolCalls };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON after all — treat as SSE below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.parseSse(body);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an SSE stream body into accumulated content + any tool_calls.
|
* Parse an SSE stream body into accumulated content + any tool_calls.
|
||||||
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Static tool *definitions* for the chatbot LLM (OpenAI function-calling
|
||||||
|
* format). Kept separate from the executor (chatbot.tools.ts) so the schema
|
||||||
|
* the model depends on can be imported without pulling in the database /
|
||||||
|
* config layer.
|
||||||
|
*
|
||||||
|
* The chatbot is a server-watcher agent: it can answer about ANY server
|
||||||
|
* situation — activity, moderation queue, specific users, channels, voice
|
||||||
|
* recordings, AI correction history, and trends over time — by calling these
|
||||||
|
* tools, which the executor implements against real tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolDef {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: ToolDef[] = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_server_stats",
|
||||||
|
description:
|
||||||
|
"Ambil statistik ringkas server/guild: total pesan, user aktif, jumlah pesan flagged, warn, dan clean. Panggil untuk jawab pertanyaan umum soal kondisi server. guildId/channelId otomatis ter-isi dari scope; kosongkan untuk semua data.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_channels",
|
||||||
|
description:
|
||||||
|
"Ambil daftar channel paling aktif (jumlah pesan terbanyak). Panggil untuk 'channel mana paling ramai' atau aktivitas per-channel.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah channel teratas (default 5, max 10).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_recent_activity",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru di server: siapa, di channel mana, jam berapa, isinya. Panggil untuk 'lagi ngapain' / aktivitas terbaru.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah pesan terakhir (default 5, max 20).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_flagged",
|
||||||
|
description:
|
||||||
|
"Ambil pesan dengan ai_status flagged (beserta alasan, severity, analysis). Panggil untuk bahas pesan bermasalah / kerjaan moderator.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 5)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_messages",
|
||||||
|
description:
|
||||||
|
"Cari pesan berdasarkan kata kunci di isi pesan (case-insensitive, LIKE). Untuk 'ada yang bahas X gak?' / temukan topik tertentu. Hindari kata terlalu umum.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Kata kunci pencarian (wajib).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah hasil (default 5)." },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_messages",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru dari satu user tertentu (user_id), opsional di-scope ke guild/channel. Untuk 'chat si A gimana akhir-akhir ini?' — butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 10)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_profile",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan profil AI dari seorang user (pola perilaku, gaya bicara) dari tabel user_profiles. Untuk 'siapa si A?' / konteks perilaku. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_reputation",
|
||||||
|
description:
|
||||||
|
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_channel_culture",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan norma/slang channel dari tabel channel_cultures (AI-generated). Untuk 'norma channel ini gimana?' / konteks sebelum nge-flag. Butuh channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
channelId: { type: "string", description: "ID channel (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["channelId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_detail",
|
||||||
|
description:
|
||||||
|
"Ambil 1 pesan lengkap beserta hasil analisis AI-nya (status, flags, score, severity, kategori, analysis, recommended action). Untuk jelasin keputusan moderasi pada pesan tertentu. Butuh message_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
messageId: { type: "string", description: "ID pesan (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["messageId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_reviews",
|
||||||
|
description:
|
||||||
|
"Ambil antrean review moderasi manual (message_reviews) berdasarkan status: pending/approved/rejected/escalated. Untuk 'ada review moderasi pending?' / cek kerjaan human moderator. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Status review: pending / approved / rejected / escalated (opsional, default semua).",
|
||||||
|
},
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_voice_recordings",
|
||||||
|
description:
|
||||||
|
"Ambil rekaman suara terbaru (voice_recordings): user, channel, transkripsi, status upload. Untuk 'ada rekaman suara terbaru?' / cek transkripsi. Bisa di-scope ke user_id atau channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "Filter user (opsional)." },
|
||||||
|
channelId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter channel (opsional).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_moderation_timeline",
|
||||||
|
description:
|
||||||
|
"Ambil tren harian: per hari, jumlah total pesan vs flagged vs warn vs clean. Untuk 'minggu ini pelanggaran naik?' / lihat tren moderasi. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
days: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah hari ke belakang (default 14, max 60).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_corrections",
|
||||||
|
description:
|
||||||
|
"Ambil riwayat koreksi false-positive AI (corrected_moderations): pesan yang awalnya di-flag tapi dikoreksi manusia, beserta alasannya. Untuk 'AI pernah salah nge-flag apa aja?' / audit akurasi moderasi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,116 +1,25 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import {
|
||||||
|
pgChannelCulturesTable,
|
||||||
|
pgCorrectedModerationsTable,
|
||||||
|
pgMessageReviewsTable,
|
||||||
|
pgMessagesTable,
|
||||||
|
pgUserProfilesTable,
|
||||||
|
pgUserReputationsTable,
|
||||||
|
pgVoiceRecordingsTable,
|
||||||
|
} from "../../shared/index.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools the chatbot LLM can call. Definitions describe the schema to the
|
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
||||||
* model; the executor implements each one against the real database.
|
* live in chatbot.toolDefs.ts (no DB import); this file implements each one
|
||||||
* This turns the chatbot from "blind stats guesser" into an agent that
|
* against the real database.
|
||||||
* pulls real, current server data on demand.
|
*
|
||||||
|
* All queries use parameterized drizzle operators (eq/like/and) — never string
|
||||||
|
* interpolation into raw SQL — so model-supplied arguments cannot inject SQL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ToolResult = string;
|
export type ToolResult = string;
|
||||||
|
|
||||||
/** JSON schema for a tool definition (OpenAI function-calling format). */
|
|
||||||
export interface ToolDef {
|
|
||||||
type: "function";
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: {
|
|
||||||
type: "object";
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tools: ToolDef[] = [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_server_stats",
|
|
||||||
description:
|
|
||||||
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID guild/server (opsional). Kosongkan = semua data.",
|
|
||||||
},
|
|
||||||
channelId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID channel (opsional).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_channels",
|
|
||||||
description:
|
|
||||||
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah channel teratas (default 5, max 10).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_recent_activity",
|
|
||||||
description:
|
|
||||||
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan terakhir (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_flagged",
|
|
||||||
description:
|
|
||||||
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Executes a tool call against the real DB and returns a readable result. */
|
/** Executes a tool call against the real DB and returns a readable result. */
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -122,9 +31,11 @@ export async function executeTool(
|
|||||||
typeof args.channelId === "string" && args.channelId
|
typeof args.channelId === "string" && args.channelId
|
||||||
? args.channelId
|
? args.channelId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const userId =
|
||||||
|
typeof args.userId === "string" && args.userId ? args.userId : undefined;
|
||||||
const limitRaw =
|
const limitRaw =
|
||||||
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
||||||
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
|
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
@@ -133,9 +44,48 @@ export async function executeTool(
|
|||||||
case "get_top_channels":
|
case "get_top_channels":
|
||||||
return await topChannels(guildId, limit);
|
return await topChannels(guildId, limit);
|
||||||
case "get_recent_activity":
|
case "get_recent_activity":
|
||||||
return await recentActivity(guildId, limit);
|
return await recentActivity(guildId, channelId, limit);
|
||||||
case "get_top_flagged":
|
case "get_top_flagged":
|
||||||
return await topFlagged(guildId, limit);
|
return await topFlagged(guildId, channelId, limit);
|
||||||
|
case "search_messages":
|
||||||
|
return await searchMessages(
|
||||||
|
String(args.query ?? ""),
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_user_messages":
|
||||||
|
return await userMessages(userId, guildId, channelId, limit);
|
||||||
|
case "get_user_profile":
|
||||||
|
return await userProfile(userId, guildId);
|
||||||
|
case "get_user_reputation":
|
||||||
|
return await userReputation(userId, guildId);
|
||||||
|
case "get_channel_culture":
|
||||||
|
return await channelCulture(
|
||||||
|
typeof args.channelId === "string" ? args.channelId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_detail":
|
||||||
|
return await messageDetail(
|
||||||
|
typeof args.messageId === "string" ? args.messageId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_reviews":
|
||||||
|
return await messageReviews(
|
||||||
|
guildId,
|
||||||
|
typeof args.status === "string" ? args.status : undefined,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_voice_recordings":
|
||||||
|
return await voiceRecordings(userId, channelId, guildId, limit);
|
||||||
|
case "get_moderation_timeline":
|
||||||
|
return await moderationTimeline(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
typeof args.days === "number"
|
||||||
|
? Math.min(Math.max(1, args.days), 60)
|
||||||
|
: 14,
|
||||||
|
);
|
||||||
|
case "get_corrections":
|
||||||
|
return await corrections(guildId, limit);
|
||||||
default:
|
default:
|
||||||
return `Unknown tool: ${name}`;
|
return `Unknown tool: ${name}`;
|
||||||
}
|
}
|
||||||
@@ -145,6 +95,23 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function scopeMessages(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
): ReturnType<typeof and> | undefined {
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
return conds.length ? and(...conds) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape LIKE wildcards so user input can't break the pattern. */
|
||||||
|
function likePattern(q: string): string {
|
||||||
|
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tool executors ──────────────────────────────────────────
|
// ── Tool executors ──────────────────────────────────────────
|
||||||
|
|
||||||
async function serverStats(
|
async function serverStats(
|
||||||
@@ -152,81 +119,330 @@ async function serverStats(
|
|||||||
channelId?: string,
|
channelId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const [result] = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
if (channelId) conditions.push(`channel_id = '${channelId}'`);
|
total_messages: sql<number>`COUNT(*)::int`,
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(scopeMessages(guildId, channelId));
|
||||||
|
|
||||||
const result = await db.execute(
|
const r = result ?? {
|
||||||
sql.raw(
|
total_messages: 0,
|
||||||
`SELECT COUNT(*)::int AS total_messages,
|
active_users: 0,
|
||||||
COUNT(DISTINCT user_id)::int AS active_users,
|
flagged: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
warned: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
clean: 0,
|
||||||
FROM messages ${cond}`,
|
};
|
||||||
),
|
return JSON.stringify(r);
|
||||||
);
|
|
||||||
const rows =
|
|
||||||
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
|
|
||||||
const r = rows[0] ?? {};
|
|
||||||
return JSON.stringify({
|
|
||||||
total_messages: r.total_messages ?? 0,
|
|
||||||
active_users: r.active_users ?? 0,
|
|
||||||
flagged: r.flagged ?? 0,
|
|
||||||
warned: r.warned ?? 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
count: sql<number>`COUNT(*)::int`,
|
||||||
const result = await db.execute(
|
})
|
||||||
sql.raw(
|
.from(pgMessagesTable)
|
||||||
`SELECT channel_id,
|
.where(scopeMessages(guildId))
|
||||||
COUNT(*)::int AS count
|
.groupBy(pgMessagesTable.channel_id)
|
||||||
FROM messages ${cond}
|
.orderBy(desc(sql`COUNT(*)`))
|
||||||
GROUP BY channel_id
|
.limit(limit);
|
||||||
ORDER BY count DESC
|
return JSON.stringify(rows);
|
||||||
LIMIT ${limit}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
|
|
||||||
return JSON.stringify(rows.slice(0, limit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
|
async function recentActivity(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
user_id: pgMessagesTable.user_id,
|
||||||
sql.raw(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
`SELECT username, content, channel_id, created_at
|
content: pgMessagesTable.content,
|
||||||
FROM messages ${cond}
|
created_at: pgMessagesTable.created_at,
|
||||||
ORDER BY created_at DESC
|
ai_status: pgMessagesTable.ai_status,
|
||||||
LIMIT ${limit}`,
|
})
|
||||||
),
|
.from(pgMessagesTable)
|
||||||
);
|
.where(scopeMessages(guildId, channelId))
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
|
async function topFlagged(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions = ["ai_status IN ('flagged', 'warn')"];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = `WHERE ${conditions.join(" AND ")}`;
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
sql.raw(
|
content: pgMessagesTable.content,
|
||||||
`SELECT username, content, channel_id, ai_status, created_at
|
ai_status: pgMessagesTable.ai_status,
|
||||||
FROM messages ${cond}
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
ORDER BY created_at DESC
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
LIMIT ${limit}`,
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
),
|
created_at: pgMessagesTable.created_at,
|
||||||
);
|
})
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
eq(pgMessagesTable.ai_status, "flagged"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMessages(
|
||||||
|
query: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!query.trim()) return JSON.stringify({ error: "query kosong" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
like(pgMessagesTable.content, `%${likePattern(query)}%`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userMessages(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgMessagesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userProfile(userId?: string, guildId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserProfilesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserProfilesTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserProfilesTable.user_id,
|
||||||
|
guild_id: pgUserProfilesTable.guild_id,
|
||||||
|
profile_summary: pgUserProfilesTable.profile_summary,
|
||||||
|
last_analyzed_at: pgUserProfilesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgUserProfilesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "profil tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userReputation(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserReputationsTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserReputationsTable.user_id,
|
||||||
|
guild_id: pgUserReputationsTable.guild_id,
|
||||||
|
trust_score: pgUserReputationsTable.trust_score,
|
||||||
|
clean_message_streak: pgUserReputationsTable.clean_message_streak,
|
||||||
|
total_infractions: pgUserReputationsTable.total_infractions,
|
||||||
|
last_infraction_at: pgUserReputationsTable.last_infraction_at,
|
||||||
|
})
|
||||||
|
.from(pgUserReputationsTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function channelCulture(channelId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!channelId) return JSON.stringify({ error: "channelId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
channel_id: pgChannelCulturesTable.channel_id,
|
||||||
|
culture_summary: pgChannelCulturesTable.culture_summary,
|
||||||
|
last_analyzed_at: pgChannelCulturesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgChannelCulturesTable)
|
||||||
|
.where(eq(pgChannelCulturesTable.channel_id, channelId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "culture tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageDetail(messageId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!messageId) return JSON.stringify({ error: "messageId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
guild_id: pgMessagesTable.guild_id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
user_id: pgMessagesTable.user_id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
|
ai_moderation_score: pgMessagesTable.ai_moderation_score,
|
||||||
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
|
ai_categories: pgMessagesTable.ai_categories,
|
||||||
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
|
ai_recommended_action: pgMessagesTable.ai_recommended_action,
|
||||||
|
ai_confidence: pgMessagesTable.ai_confidence,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(eq(pgMessagesTable.id, messageId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "pesan tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageReviews(
|
||||||
|
guildId?: string,
|
||||||
|
status?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessageReviewsTable.guild_id, guildId));
|
||||||
|
if (status) conds.push(eq(pgMessageReviewsTable.status, status as never));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessageReviewsTable.id,
|
||||||
|
message_id: pgMessageReviewsTable.message_id,
|
||||||
|
reviewer_id: pgMessageReviewsTable.reviewer_id,
|
||||||
|
status: pgMessageReviewsTable.status,
|
||||||
|
notes: pgMessageReviewsTable.notes,
|
||||||
|
created_at: pgMessageReviewsTable.created_at,
|
||||||
|
reviewed_at: pgMessageReviewsTable.reviewed_at,
|
||||||
|
})
|
||||||
|
.from(pgMessageReviewsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgMessageReviewsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voiceRecordings(
|
||||||
|
userId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (userId) conds.push(eq(pgVoiceRecordingsTable.user_id, userId));
|
||||||
|
if (channelId) conds.push(eq(pgVoiceRecordingsTable.channel_id, channelId));
|
||||||
|
if (guildId) conds.push(eq(pgVoiceRecordingsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgVoiceRecordingsTable.id,
|
||||||
|
username: pgVoiceRecordingsTable.username,
|
||||||
|
channel_name: pgVoiceRecordingsTable.channel_name,
|
||||||
|
filename: pgVoiceRecordingsTable.filename,
|
||||||
|
size_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||||
|
upload_status: pgVoiceRecordingsTable.upload_status,
|
||||||
|
transcription: pgVoiceRecordingsTable.transcription,
|
||||||
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgVoiceRecordingsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgVoiceRecordingsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moderationTimeline(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
days = 14,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const day = sql<string>`to_char(to_timestamp(${pgMessagesTable.created_at} / 1000), 'YYYY-MM-DD')`;
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
day,
|
||||||
|
total: sql<number>`COUNT(*)::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
// only the last N days
|
||||||
|
sql`${pgMessagesTable.created_at} >= extract(epoch FROM now() - (${days} || ' days')::interval) * 1000`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(day)
|
||||||
|
.orderBy(day);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function corrections(_guildId?: string, limit = 10): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgCorrectedModerationsTable.id,
|
||||||
|
message_id: pgCorrectedModerationsTable.message_id,
|
||||||
|
original_flags: pgCorrectedModerationsTable.original_flags,
|
||||||
|
corrected_flags: pgCorrectedModerationsTable.corrected_flags,
|
||||||
|
correction_notes: pgCorrectedModerationsTable.correction_notes,
|
||||||
|
content_snippet: pgCorrectedModerationsTable.content_snippet,
|
||||||
|
created_at: pgCorrectedModerationsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgCorrectedModerationsTable)
|
||||||
|
.orderBy(desc(pgCorrectedModerationsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { createChatbotRouter } from "./chatbot.routes.js";
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { config } from "../../shared/config/index.js";
|
|
||||||
|
|
||||||
export function createConfigRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/config
|
|
||||||
router.get("/config", (_req, res) => {
|
|
||||||
res.json({
|
|
||||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
|
||||||
webserverPort: config.WEBSERVER_PORT,
|
|
||||||
nodeEnv: config.NODE_ENV,
|
|
||||||
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
|
||||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
|
||||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
|
||||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
|
||||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
|
||||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
|
||||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
|
||||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
|
||||||
logLevel: config.LOG_LEVEL,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createConfigRouter } from "./config.routes.js";
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
pgUserProfilesTable,
|
pgUserProfilesTable,
|
||||||
pgUserReputationsTable,
|
|
||||||
pgVoiceRecordingsTable,
|
pgVoiceRecordingsTable,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||||
@@ -156,8 +155,7 @@ export class DashboardRepository {
|
|||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.last_message_at,
|
m.last_message_at
|
||||||
r.trust_score
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -170,7 +168,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY m.last_message_at DESC NULLS LAST
|
ORDER BY m.last_message_at DESC NULLS LAST
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
@@ -186,10 +183,6 @@ export class DashboardRepository {
|
|||||||
total_messages: Number(r.total_messages),
|
total_messages: Number(r.total_messages),
|
||||||
flagged_count: Number(r.flagged_count),
|
flagged_count: Number(r.flagged_count),
|
||||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||||
trust_score:
|
|
||||||
r.trust_score !== null && r.trust_score !== undefined
|
|
||||||
? Number(r.trust_score)
|
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||||
@@ -437,10 +430,7 @@ export class DashboardRepository {
|
|||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.clean_count,
|
m.clean_count,
|
||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
p.last_analyzed_at,
|
p.last_analyzed_at
|
||||||
r.trust_score,
|
|
||||||
r.clean_message_streak,
|
|
||||||
r.total_infractions
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -454,7 +444,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||||
@@ -481,13 +470,6 @@ export class DashboardRepository {
|
|||||||
last_analyzed_at: row.last_analyzed_at
|
last_analyzed_at: row.last_analyzed_at
|
||||||
? Number(row.last_analyzed_at)
|
? Number(row.last_analyzed_at)
|
||||||
: null,
|
: null,
|
||||||
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
|
|
||||||
clean_message_streak:
|
|
||||||
row.clean_message_streak != null
|
|
||||||
? Number(row.clean_message_streak)
|
|
||||||
: null,
|
|
||||||
total_infractions:
|
|
||||||
row.total_infractions != null ? Number(row.total_infractions) : null,
|
|
||||||
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
content: String(r.content),
|
content: String(r.content),
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { dashboardService } from "./dashboard.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("dashboard.routes");
|
|
||||||
|
|
||||||
export function createDashboardRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/dashboard/stats — aggregated server statistics
|
|
||||||
router.get(
|
|
||||||
"/dashboard/stats",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching dashboard stats");
|
|
||||||
const stats = await dashboardService.getStats();
|
|
||||||
res.json(stats);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/activity?days=14 — message volume over time
|
|
||||||
router.get(
|
|
||||||
"/dashboard/activity",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
|
||||||
const activity = await dashboardService.getActivity(days);
|
|
||||||
res.json(activity);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users — paginated user list with profiles
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const cursor =
|
|
||||||
typeof req.query.cursor === "string" ? req.query.cursor : undefined;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listUsers({
|
|
||||||
limit,
|
|
||||||
cursor,
|
|
||||||
search,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/users/:userId — single user detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/users/:userId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const userId = String(req.params.userId);
|
|
||||||
const detail = await dashboardService.getUserDetail(userId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels — paginated channel list with culture summaries
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const search =
|
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
|
||||||
const guildId =
|
|
||||||
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listChannels({
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
guildId,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/channels/:channelId — single channel detail
|
|
||||||
router.get(
|
|
||||||
"/dashboard/channels/:channelId",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const channelId = String(req.params.channelId);
|
|
||||||
const detail = await dashboardService.getChannelDetail(channelId);
|
|
||||||
res.json(detail);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactions — top reacted messages
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactions",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactions = await dashboardService.getTopReactions(limit);
|
|
||||||
res.json(reactions);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/dashboard/reactors — top users by reactions given
|
|
||||||
router.get(
|
|
||||||
"/dashboard/reactors",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const reactors = await dashboardService.getTopReactors(limit);
|
|
||||||
res.json(reactors);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createDashboardRouter } from "./dashboard.routes.js";
|
|
||||||
@@ -67,9 +67,9 @@ export const moderationErrors = new Counter({
|
|||||||
labelNames: ["type"] as const,
|
labelNames: ["type"] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searxngCalls = new Counter({
|
export const webSearchCalls = new Counter({
|
||||||
name: "moderation_searxng_calls_total",
|
name: "moderation_websearch_calls_total",
|
||||||
help: "SearXNG search calls",
|
help: "Wikipedia web-search calls",
|
||||||
labelNames: ["status"] as const,
|
labelNames: ["status"] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
|
export interface ChannelCultureRow {
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string | null;
|
||||||
|
channel_name: string | null;
|
||||||
|
culture_summary: string | null;
|
||||||
|
last_analyzed_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlossaryRow {
|
||||||
|
term: string;
|
||||||
|
definition: string;
|
||||||
|
source_url: string;
|
||||||
|
resolved_at: number;
|
||||||
|
hit_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditHistoryRow {
|
||||||
|
id: string;
|
||||||
|
message_id: string;
|
||||||
|
old_content: string;
|
||||||
|
edited_at: number;
|
||||||
|
channel_id: string | null;
|
||||||
|
channel_name: string | null;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeRepository {
|
||||||
|
/** Public read-only channel culture glossary (AI-generated norms/slang). */
|
||||||
|
async listChannelCultures(limit = 50, search?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(c.channel_id ILIKE '%${search.replace(/'/g, "''")}%' OR c.culture_summary ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
c.channel_id,
|
||||||
|
c.guild_id,
|
||||||
|
COALESCE(NULLIF((
|
||||||
|
SELECT (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||||
|
FROM messages WHERE channel_id = c.channel_id AND metadata IS NOT NULL
|
||||||
|
LIMIT 1
|
||||||
|
), ''), c.channel_id) AS channel_name,
|
||||||
|
c.culture_summary,
|
||||||
|
c.last_analyzed_at
|
||||||
|
FROM channel_cultures c
|
||||||
|
${where}
|
||||||
|
ORDER BY c.last_analyzed_at DESC NULLS LAST
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channel_id: String(r.channel_id),
|
||||||
|
guild_id: r.guild_id ? String(r.guild_id) : null,
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
culture_summary: r.culture_summary ? String(r.culture_summary) : null,
|
||||||
|
last_analyzed_at: r.last_analyzed_at ? Number(r.last_analyzed_at) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public read-only term knowledge base (resolved via Wikipedia/SearXNG). */
|
||||||
|
async listGlossary(limit = 50, search?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(term ILIKE '%${search.replace(/'/g, "''")}%' OR definition ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT term, definition, source_url, resolved_at, hit_count
|
||||||
|
FROM term_glossary_cache
|
||||||
|
${where}
|
||||||
|
ORDER BY hit_count DESC, resolved_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
term: String(r.term),
|
||||||
|
definition: String(r.definition ?? ""),
|
||||||
|
source_url: r.source_url ? String(r.source_url) : "",
|
||||||
|
resolved_at: r.resolved_at ? Number(r.resolved_at) : 0,
|
||||||
|
hit_count: Number(r.hit_count ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeRepository = new KnowledgeRepository();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { knowledgeRepository } from "./knowledge.repository.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("knowledge.service");
|
||||||
|
|
||||||
|
export class KnowledgeService {
|
||||||
|
async listChannelCultures(limit = 50, search?: string) {
|
||||||
|
logger.debug({ limit, search }, "Listing channel cultures");
|
||||||
|
return knowledgeRepository.listChannelCultures(limit, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGlossary(limit = 50, search?: string) {
|
||||||
|
logger.debug({ limit, search }, "Listing glossary terms");
|
||||||
|
return knowledgeRepository.listGlossary(limit, search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeService = new KnowledgeService();
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMediaRouter } from "./media.routes.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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 { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
|
|
||||||
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("media.routes");
|
|
||||||
|
|
||||||
export function createMediaRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/media/status
|
|
||||||
router.get(
|
|
||||||
"/media/status",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media status requested");
|
|
||||||
const status = await getStatus();
|
|
||||||
res.json(status);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/queue
|
|
||||||
router.post(
|
|
||||||
"/media/queue",
|
|
||||||
validateBody(mediaQueueSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { source, mode } = req.body as {
|
|
||||||
source: string;
|
|
||||||
mode: "music" | "screen";
|
|
||||||
};
|
|
||||||
logger.debug({ source, mode }, "Media queue requested");
|
|
||||||
const state = await queue(source, mode);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/skip
|
|
||||||
router.post(
|
|
||||||
"/media/skip",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media skip requested");
|
|
||||||
const state = await skip();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/stop
|
|
||||||
router.post(
|
|
||||||
"/media/stop",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Media stop requested");
|
|
||||||
const state = await stop();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/media/loop
|
|
||||||
router.post(
|
|
||||||
"/media/loop",
|
|
||||||
validateBody(mediaLoopSchema),
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const { loop } = req.body as { loop: boolean };
|
|
||||||
logger.debug({ loop }, "Media loop requested");
|
|
||||||
const state = await setLoop(loop);
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { config } from "@/shared/config/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages-embed");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed a search query with the configured OpenAI-compatible embedding model.
|
||||||
|
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
|
||||||
|
* when embeddings are not configured (search unavailable).
|
||||||
|
*
|
||||||
|
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
|
||||||
|
*/
|
||||||
|
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||||
|
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.AI_LLM_EMBEDDING_MODEL,
|
||||||
|
input: text,
|
||||||
|
encoding_format: "float",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn({ status: res.status }, "query embed HTTP error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data?: Array<{ embedding?: number[] }>;
|
||||||
|
};
|
||||||
|
return json.data?.[0]?.embedding ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"query embed failed",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMessagesRouter } from "./messages.routes.js";
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { messageQuerySchema } from "./messages.schema.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.controller");
|
|
||||||
|
|
||||||
export const handleListMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ query }, "Handling list messages request");
|
|
||||||
const result = await messagesService.listMessages(query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessagesByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get messages by channel");
|
|
||||||
const result = await messagesService.getMessagesByChannel(channelId, query);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetMessageById = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.id) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: id" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = req.params.id as string;
|
|
||||||
logger.debug({ id }, "Handling get message by ID");
|
|
||||||
const result = await messagesService.getMessageById(id);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetImageMessages = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.query.guildId as string | undefined;
|
|
||||||
if (!guildId) {
|
|
||||||
res.status(400).json({ error: "Missing query parameter: guildId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
logger.debug({ guildId, limit }, "Handling get image messages");
|
|
||||||
const result = await messagesService.getImageMessages(guildId, limit);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleGetAttachmentsByChannel = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.params.channelId) {
|
|
||||||
res.status(400).json({ error: "Missing route parameter: channelId" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channelId = req.params.channelId as string;
|
|
||||||
const query = messageQuerySchema.parse(req.query);
|
|
||||||
logger.debug({ channelId, query }, "Handling get attachments by channel");
|
|
||||||
const result = await messagesService.getAttachmentsByChannel(
|
|
||||||
channelId,
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
res.json(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
@@ -150,12 +149,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe)
|
// Exclude spam threads (NULL-safe)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -174,6 +172,71 @@ export class MessagesRepository {
|
|||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that yields messages ONE AT A TIME for WS streaming.
|
||||||
|
* Each `.next()` runs its own bounded DB query (limit+1) advancing on the
|
||||||
|
* `created_at` cursor, so memory stays flat and the caller can emit one WS
|
||||||
|
* frame per message (no 50-row batch). Stops when a page returns < limit.
|
||||||
|
*/
|
||||||
|
async *streamMany(
|
||||||
|
query: MessageQuery,
|
||||||
|
pageSize = 50,
|
||||||
|
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
|
||||||
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
|
if (query.guildId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
||||||
|
}
|
||||||
|
if (query.channelId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
||||||
|
}
|
||||||
|
if (query.userId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||||
|
}
|
||||||
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
|
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;
|
||||||
|
let cursor: string | undefined = query.cursor;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const pageConditions = where ? [where] : [];
|
||||||
|
if (cursor) {
|
||||||
|
pageConditions.push(lt(pgMessagesTable.created_at, Number(cursor)));
|
||||||
|
}
|
||||||
|
const pageWhere =
|
||||||
|
pageConditions.length > 0 ? and(...pageConditions) : undefined;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(pageWhere)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(pageSize + 1);
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const hasMore = rows.length > pageSize;
|
||||||
|
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
||||||
|
|
||||||
|
for (const r of pageRows) {
|
||||||
|
yield mapMessageRow(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) return;
|
||||||
|
cursor = String(rows[pageSize - 1].created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: MessageCreate) {
|
async create(data: MessageCreate) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -316,12 +379,13 @@ export class MessagesRepository {
|
|||||||
like(pgAttachmentsTable.type, "image/%"),
|
like(pgAttachmentsTable.type, "image/%"),
|
||||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||||
...(EXCLUDED_THREAD_IDS.length > 0
|
...(EXCLUDED_THREAD_IDS.length > 0
|
||||||
? [
|
? (() => {
|
||||||
or(
|
const excludeThreads = or(
|
||||||
isNull(pgAttachmentsTable.thread_id),
|
isNull(pgAttachmentsTable.thread_id),
|
||||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
)!,
|
);
|
||||||
]
|
return excludeThreads ? [excludeThreads] : [];
|
||||||
|
})()
|
||||||
: []),
|
: []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -395,6 +459,69 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
return { data: trimmed, nextCursor };
|
return { data: trimmed, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-hour message volume for the last `days` days, grouped by channel.
|
||||||
|
* Powers the public Activity Heatmap (read-only, no write scope).
|
||||||
|
* Returns a flat list of { channel_id, hour (0-23), count } buckets.
|
||||||
|
*/
|
||||||
|
async getActivity(days = 30) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT channel_id,
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS c
|
||||||
|
FROM messages
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY channel_id, hour
|
||||||
|
ORDER BY channel_id, hour
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: String(r.channel_id ?? "unknown"),
|
||||||
|
hour: Number(r.hour ?? 0),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent message edits across the server (evasion-signal tracker).
|
||||||
|
* Public, read-only. Joins message_edits → messages for context.
|
||||||
|
*/
|
||||||
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const where = channelId
|
||||||
|
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
||||||
|
: "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.message_id,
|
||||||
|
e.old_content,
|
||||||
|
e.edited_at,
|
||||||
|
m.channel_id,
|
||||||
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
|
m.username
|
||||||
|
FROM message_edits e
|
||||||
|
JOIN messages m ON m.id = e.message_id
|
||||||
|
${where}
|
||||||
|
ORDER BY e.edited_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
message_id: String(r.message_id),
|
||||||
|
old_content: r.old_content ? String(r.old_content) : "",
|
||||||
|
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||||
|
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesRepository = new MessagesRepository();
|
export const messagesRepository = new MessagesRepository();
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
handleGetAttachmentsByChannel,
|
|
||||||
handleGetImageMessages,
|
|
||||||
handleGetMessageById,
|
|
||||||
handleGetMessagesByChannel,
|
|
||||||
handleListMessages,
|
|
||||||
} from "./messages.controller.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("messages.routes");
|
|
||||||
|
|
||||||
export function createMessagesRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/messages/images - Get messages with image attachments
|
|
||||||
// MUST be registered BEFORE /messages/:channelId so "images" is not
|
|
||||||
// captured as a channelId param.
|
|
||||||
router.get("/messages/images", handleGetImageMessages);
|
|
||||||
|
|
||||||
// GET /api/messages - List messages
|
|
||||||
router.get("/messages", handleListMessages);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId - Get messages by channel
|
|
||||||
router.get("/messages/:channelId", handleGetMessagesByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/:channelId/attachments - Get attachments by channel
|
|
||||||
router.get("/messages/:channelId/attachments", handleGetAttachmentsByChannel);
|
|
||||||
|
|
||||||
// GET /api/messages/detail/:id - Get single message by ID
|
|
||||||
// (uses /detail/ prefix to avoid collision with :channelId route above)
|
|
||||||
router.get("/messages/detail/:id", handleGetMessageById);
|
|
||||||
|
|
||||||
// GET /api/review - Get flagged/warned messages for review
|
|
||||||
router.get(
|
|
||||||
"/review",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
|
||||||
const channelId = (req.query.channelId as string) || undefined;
|
|
||||||
|
|
||||||
const rows = await messagesService.getReviewMessages(channelId, limit);
|
|
||||||
logger.debug({ limit, channelId }, "Review query executed");
|
|
||||||
res.json({ results: rows, limit, cursor: null });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
|
|||||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||||
|
|
||||||
|
export const semanticSearchSchema = z.object({
|
||||||
|
query: z.string().min(1).max(500),
|
||||||
|
limit: z.coerce.number().int().positive().max(50).default(10),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SemanticSearchQuery = z.infer<typeof semanticSearchSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedQuery } from "./embed.js";
|
||||||
import { messagesRepository } from "./messages.repository.js";
|
import { messagesRepository } from "./messages.repository.js";
|
||||||
import type { MessageQuery } from "./messages.schema.js";
|
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
|
||||||
|
import { searchArchive } from "./qdrant.js";
|
||||||
|
|
||||||
const logger = createChildLogger("messages.service");
|
const logger = createChildLogger("messages.service");
|
||||||
|
|
||||||
@@ -15,6 +17,14 @@ export class MessagesService {
|
|||||||
return messagesRepository.findMany(query);
|
return messagesRepository.findMany(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream messages one at a time (no 50-row batch). The WS handler iterates
|
||||||
|
* this generator and emits one `message_snapshot` frame per message.
|
||||||
|
*/
|
||||||
|
streamMessages(query: MessageQuery, pageSize = 50) {
|
||||||
|
return messagesRepository.streamMany(query, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new ValidationError("channelId is required");
|
throw new ValidationError("channelId is required");
|
||||||
@@ -70,6 +80,49 @@ export class MessagesService {
|
|||||||
logger.debug({ channelId, limit }, "Getting review messages");
|
logger.debug({ channelId, limit }, "Getting review messages");
|
||||||
return messagesRepository.getReviewMessages(channelId, limit);
|
return messagesRepository.getReviewMessages(channelId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, read-only semantic search over the persistent message archive.
|
||||||
|
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
|
||||||
|
* if embeddings/Qdrant are unavailable, returns an empty result set.
|
||||||
|
*/
|
||||||
|
async semanticSearch(
|
||||||
|
input: SemanticSearchQuery,
|
||||||
|
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
|
||||||
|
const vector = await embedQuery(input.query);
|
||||||
|
if (!vector) {
|
||||||
|
logger.debug(
|
||||||
|
{ query: input.query },
|
||||||
|
"semantic search skipped: no embedder",
|
||||||
|
);
|
||||||
|
return { results: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
const hits = await searchArchive(vector, input.limit, 0.6);
|
||||||
|
const results = hits.map((h) => mapSearchHit(h));
|
||||||
|
return { results, nextCursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActivity(days = 30) {
|
||||||
|
return messagesRepository.getActivity(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
|
logger.debug({ limit, channelId }, "Getting recent message edits");
|
||||||
|
return messagesRepository.getRecentEdits(limit, channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||||
|
function mapSearchHit(hit: {
|
||||||
|
score: number;
|
||||||
|
payload: { text: string; content_hash?: string; analyzed_at: number };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
message_id: hit.payload.content_hash ?? null,
|
||||||
|
content: hit.payload.text,
|
||||||
|
score: hit.score,
|
||||||
|
created_at: hit.payload.analyzed_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesService = new MessagesService();
|
export const messagesService = new MessagesService();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { config } from "@/shared/config/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages-qdrant");
|
||||||
|
|
||||||
|
export interface ArchiveHit {
|
||||||
|
score: number;
|
||||||
|
payload: {
|
||||||
|
text: string;
|
||||||
|
content_hash?: string;
|
||||||
|
analyzed_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ARCHIVE_COLLECTION =
|
||||||
|
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search the archive collection for the nearest vectors to `vector`. */
|
||||||
|
export async function searchArchive(
|
||||||
|
vector: number[],
|
||||||
|
limit: number,
|
||||||
|
scoreThreshold: number,
|
||||||
|
): Promise<ArchiveHit[]> {
|
||||||
|
if (!config.QDRANT_URL) return [];
|
||||||
|
try {
|
||||||
|
const json = (await request(
|
||||||
|
"POST",
|
||||||
|
`/collections/${ARCHIVE_COLLECTION}/points/search`,
|
||||||
|
{
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
score_threshold: scoreThreshold,
|
||||||
|
with_payload: true,
|
||||||
|
},
|
||||||
|
)) as {
|
||||||
|
result?: Array<{
|
||||||
|
score?: number;
|
||||||
|
payload?: ArchiveHit["payload"];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return (json.result ?? [])
|
||||||
|
.filter((h) => h.payload?.text)
|
||||||
|
.map((h) => ({
|
||||||
|
score: h.score ?? 0,
|
||||||
|
payload: h.payload as ArchiveHit["payload"],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"archive search failed",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createModerationRouter } from "./moderation.routes.js";
|
|
||||||
@@ -17,6 +17,20 @@ const ACTION_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
const STATUSES = ["pending", "executed", "failed"] as const;
|
const STATUSES = ["pending", "executed", "failed"] as const;
|
||||||
|
|
||||||
|
/** Parse a JSON-stringified array column (e.g. flags/categories/evidence).
|
||||||
|
* Returns null on empty/malformed input so the FE can treat it as "no data". */
|
||||||
|
function parseJsonArray(value: unknown): string[] | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const str = typeof value === "string" ? value : String(value);
|
||||||
|
if (str.length === 0) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(str);
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ModerationRepository {
|
export class ModerationRepository {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -103,6 +117,13 @@ export class ModerationRepository {
|
|||||||
a.error,
|
a.error,
|
||||||
a.created_at,
|
a.created_at,
|
||||||
a.executed_at,
|
a.executed_at,
|
||||||
|
a.flags,
|
||||||
|
a.categories,
|
||||||
|
a.severity,
|
||||||
|
a.confidence,
|
||||||
|
a.score,
|
||||||
|
a.evidence,
|
||||||
|
a.policy_version,
|
||||||
m.username,
|
m.username,
|
||||||
LEFT(m.content, 300) AS content
|
LEFT(m.content, 300) AS content
|
||||||
FROM moderation_actions a
|
FROM moderation_actions a
|
||||||
@@ -126,6 +147,13 @@ export class ModerationRepository {
|
|||||||
error: r.error ? String(r.error) : null,
|
error: r.error ? String(r.error) : null,
|
||||||
created_at: r.created_at ? Number(r.created_at) : null,
|
created_at: r.created_at ? Number(r.created_at) : null,
|
||||||
executed_at: r.executed_at ? Number(r.executed_at) : null,
|
executed_at: r.executed_at ? Number(r.executed_at) : null,
|
||||||
|
flags: parseJsonArray(r.flags),
|
||||||
|
categories: parseJsonArray(r.categories),
|
||||||
|
severity: r.severity ? String(r.severity) : null,
|
||||||
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
|
score: r.score != null ? Number(r.score) : null,
|
||||||
|
evidence: parseJsonArray(r.evidence),
|
||||||
|
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||||
username: r.username ? String(r.username) : null,
|
username: r.username ? String(r.username) : null,
|
||||||
content: r.content ? String(r.content) : null,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
@@ -136,6 +164,220 @@ export class ModerationRepository {
|
|||||||
|
|
||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate moderation trends over the last `days` days.
|
||||||
|
* - category counts (from the jsonb/text[] `categories` column, unnested)
|
||||||
|
* - severity distribution
|
||||||
|
* - action_type distribution
|
||||||
|
* Read-only; powers the public Toxic Topic Trends panel.
|
||||||
|
*/
|
||||||
|
async getTrends(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const cats = await db.execute(sql`
|
||||||
|
SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions a
|
||||||
|
WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != ''
|
||||||
|
GROUP BY cat
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT 15
|
||||||
|
`);
|
||||||
|
const catRows = (cats.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const sev = await db.execute(sql`
|
||||||
|
SELECT severity, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since} AND severity IS NOT NULL
|
||||||
|
GROUP BY severity
|
||||||
|
`);
|
||||||
|
const sevRows = (sev.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const act = await db.execute(sql`
|
||||||
|
SELECT action_type, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY action_type
|
||||||
|
ORDER BY c DESC
|
||||||
|
`);
|
||||||
|
const actRows = (act.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: catRows.map((r) => ({
|
||||||
|
name: String(r.cat),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
severities: sevRows.map((r) => ({
|
||||||
|
level: String(r.severity),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
actions: actRows.map((r) => ({
|
||||||
|
type: String(r.action_type),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top flagged domains over the last `days` days.
|
||||||
|
* Extracts the host from any URL in `content`/`reason`/`evidence` and ranks
|
||||||
|
* by how often it appears in moderation actions. Powers the Scam Domain panel.
|
||||||
|
*/
|
||||||
|
async getTopFlaggedDomains(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT host, COUNT(*)::int AS c
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT a.id,
|
||||||
|
(regexp_matches(COALESCE(a.content,'') || ' ' || COALESCE(a.reason,'') || ' ' || COALESCE(a.evidence,''), 'https?://([^/\s?#]+)', 'g'))[1] AS host
|
||||||
|
FROM moderation_actions a
|
||||||
|
WHERE a.created_at >= ${since}
|
||||||
|
AND (a.content IS NOT NULL OR a.reason IS NOT NULL OR a.evidence IS NOT NULL)
|
||||||
|
) sub
|
||||||
|
WHERE host IS NOT NULL
|
||||||
|
GROUP BY host
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
domain: String(r.host).toLowerCase(),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top flagged channels over the last `days` days.
|
||||||
|
* Joins moderation_actions → messages to attribute each action to a channel.
|
||||||
|
* Powers the Top Flagged Channels panel.
|
||||||
|
*/
|
||||||
|
async getTopFlaggedChannels(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
m.channel_id,
|
||||||
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
|
COUNT(*)::int AS flagged_count
|
||||||
|
FROM moderation_actions a
|
||||||
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
|
WHERE a.created_at >= ${since} AND m.channel_id IS NOT NULL
|
||||||
|
GROUP BY m.channel_id, (m.metadata::jsonb -> 'channel' ->> 'channelName')
|
||||||
|
ORDER BY flagged_count DESC
|
||||||
|
LIMIT 15
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channel_id: String(r.channel_id),
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
flagged_count: Number(r.flagged_count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour-of-day distribution of moderation actions over the last `days` days.
|
||||||
|
* 24 rows (hour 0..23), with total + flagged-by-severity counts.
|
||||||
|
* Powers the Moderation Heatmap by Hour panel.
|
||||||
|
*/
|
||||||
|
async getHourlyModeration(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS total
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
const byHour = new Map<number, number>();
|
||||||
|
for (const r of rows) byHour.set(Number(r.hour), Number(r.total));
|
||||||
|
return Array.from({ length: 24 }, (_, h) => ({
|
||||||
|
hour: h,
|
||||||
|
total: byHour.get(h) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation actions filtered to a single category (drill-down).
|
||||||
|
* Powers the Flag Category Drill-down panel.
|
||||||
|
*/
|
||||||
|
async getByCategory(days: number, category: string, limit = 50) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
||||||
|
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
||||||
|
m.username, LEFT(m.content, 300) AS content
|
||||||
|
FROM moderation_actions a
|
||||||
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
|
WHERE a.created_at >= ${since}
|
||||||
|
AND a.categories IS NOT NULL
|
||||||
|
AND a.categories::jsonb @> ${JSON.stringify([category])}::jsonb
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: String(r.id ?? ""),
|
||||||
|
message_id: r.message_id ? String(r.message_id) : null,
|
||||||
|
user_id: r.user_id ? String(r.user_id) : null,
|
||||||
|
guild_id: String(r.guild_id ?? ""),
|
||||||
|
action_type: String(r.action_type ?? "unknown"),
|
||||||
|
reason: r.reason ? String(r.reason) : null,
|
||||||
|
status: String(r.status ?? "unknown"),
|
||||||
|
created_at: r.created_at ? Number(r.created_at) : null,
|
||||||
|
severity: r.severity ? String(r.severity) : null,
|
||||||
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
|
score: r.score != null ? Number(r.score) : null,
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
content: r.content ? String(r.content) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-moderation coverage over the last `days` days.
|
||||||
|
* Run completion rate from ai_analysis_runs — what fraction of analysis runs
|
||||||
|
* completed (vs failed/pending). Public "how much is automated" trust metric.
|
||||||
|
*/
|
||||||
|
async getCoverage(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT status, COUNT(*)::int AS c
|
||||||
|
FROM ai_analysis_runs
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY status
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = String(r.status);
|
||||||
|
const c = Number(r.c ?? 0);
|
||||||
|
counts[s] = c;
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
const completed = counts.completed ?? 0;
|
||||||
|
const failed = counts.failed ?? 0;
|
||||||
|
const pending = (counts.pending ?? 0) + (counts.processing ?? 0);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
pending,
|
||||||
|
coverage_rate:
|
||||||
|
total > 0 ? Number(((completed / total) * 100).toFixed(1)) : 0,
|
||||||
|
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const moderationRepository = new ModerationRepository();
|
export const moderationRepository = new ModerationRepository();
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { moderationService } from "./moderation.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("moderation.routes");
|
|
||||||
|
|
||||||
export function createModerationRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/moderation/stats — moderation action summary
|
|
||||||
router.get(
|
|
||||||
"/moderation/stats",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
const stats = await moderationService.getStats();
|
|
||||||
res.json(stats);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/moderation/actions — paginated moderation action log
|
|
||||||
router.get(
|
|
||||||
"/moderation/actions",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
const status = req.query.status as string | undefined;
|
|
||||||
const actionType = req.query.actionType as string | undefined;
|
|
||||||
const cursor = req.query.cursor as string | undefined;
|
|
||||||
|
|
||||||
const result = await moderationService.listActions({
|
|
||||||
limit,
|
|
||||||
status,
|
|
||||||
actionType,
|
|
||||||
cursor: cursor ? Number(cursor) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug({ count: result.data.length }, "Moderation actions listed");
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -8,10 +8,33 @@ const logger = createChildLogger("moderation.service");
|
|||||||
|
|
||||||
export class ModerationService {
|
export class ModerationService {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
logger.debug("Fetching moderation stats");
|
|
||||||
return moderationRepository.getStats();
|
return moderationRepository.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTrends(days = 30) {
|
||||||
|
return moderationRepository.getTrends(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopFlaggedDomains(days = 30) {
|
||||||
|
return moderationRepository.getTopFlaggedDomains(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopFlaggedChannels(days = 30) {
|
||||||
|
return moderationRepository.getTopFlaggedChannels(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHourlyModeration(days = 30) {
|
||||||
|
return moderationRepository.getHourlyModeration(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByCategory(days = 30, category: string) {
|
||||||
|
return moderationRepository.getByCategory(days, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCoverage(days = 30) {
|
||||||
|
return moderationRepository.getCoverage(days);
|
||||||
|
}
|
||||||
|
|
||||||
async listActions(query: ListModerationQuery) {
|
async listActions(query: ListModerationQuery) {
|
||||||
logger.debug({ query }, "Listing moderation actions");
|
logger.debug({ query }, "Listing moderation actions");
|
||||||
return moderationRepository.listActions(query);
|
return moderationRepository.listActions(query);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { createRecordingsRouter } from "./recordings.routes.js";
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { recordingsService } from "./recordings.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("recordings.routes");
|
|
||||||
|
|
||||||
export function createRecordingsRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/recordings
|
|
||||||
router.get(
|
|
||||||
"/recordings",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const limit = Number(req.query.limit) || 50;
|
|
||||||
const channelId = req.query.channelId as string | undefined;
|
|
||||||
const userId = req.query.userId as string | undefined;
|
|
||||||
const cursor = req.query.cursor as string | undefined;
|
|
||||||
logger.debug({ limit, channelId, userId, cursor }, "Fetching recordings");
|
|
||||||
const result = await recordingsService.getRecent(limit, {
|
|
||||||
channelId,
|
|
||||||
userId,
|
|
||||||
cursor,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// DELETE /api/recordings/:id
|
|
||||||
router.delete(
|
|
||||||
"/recordings/:id",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const id = req.params.id as string;
|
|
||||||
await recordingsService.deleteById(id);
|
|
||||||
res.json({ ok: true });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createUiStateRouter } from "./ui-state.routes.js";
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { Request, Response, Router } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { uiStateService } from "./ui-state.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("ui-state.routes");
|
|
||||||
|
|
||||||
export function createUiStateRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// GET /api/ui-state
|
|
||||||
router.get(
|
|
||||||
"/ui-state",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching UI state");
|
|
||||||
const state = await uiStateService.getState();
|
|
||||||
res.json(state);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/ui-state
|
|
||||||
router.post(
|
|
||||||
"/ui-state",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const updates = req.body as Record<string, unknown>;
|
|
||||||
logger.debug({ keys: Object.keys(updates) }, "Updating UI state");
|
|
||||||
const result = await uiStateService.updateState(updates);
|
|
||||||
res.json(result);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createVoiceRouter } from "./voice.routes.js";
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { Request, Response } from "express";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
|
||||||
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
|
||||||
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
|
|
||||||
import {
|
|
||||||
connectVoice,
|
|
||||||
disconnectVoice,
|
|
||||||
getVoiceStatus,
|
|
||||||
} from "./voice.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice.controller");
|
|
||||||
|
|
||||||
export const handleGetVoiceStatus = asyncHandler(
|
|
||||||
async (_req: Request, res: Response) => {
|
|
||||||
const status = await getVoiceStatus();
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleConnectVoice = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { guildId, channelId } = req.body as ConnectVoiceInput;
|
|
||||||
logger.debug({ guildId, channelId }, "Connecting to voice channel");
|
|
||||||
const status = await connectVoice(guildId, channelId);
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleDisconnectVoice = asyncHandler(
|
|
||||||
async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Disconnecting from voice");
|
|
||||||
const status = await disconnectVoice();
|
|
||||||
res.json(status);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const handleVoiceCommand = asyncHandler(
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { command } = req.body as VoiceCommandInput;
|
|
||||||
logger.debug({ command }, "Publishing voice command");
|
|
||||||
await publishCommandNoReply(command);
|
|
||||||
res.json({ success: true, command });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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 {
|
|
||||||
handleConnectVoice,
|
|
||||||
handleDisconnectVoice,
|
|
||||||
handleGetVoiceStatus,
|
|
||||||
handleVoiceCommand,
|
|
||||||
} from "./voice.controller.js";
|
|
||||||
import { connectVoiceSchema, voiceCommandSchema } from "./voice.schema.js";
|
|
||||||
import {
|
|
||||||
getGuilds,
|
|
||||||
getTextChannels,
|
|
||||||
getVoiceChannels,
|
|
||||||
} from "./voice.service.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice.routes");
|
|
||||||
|
|
||||||
export function createVoiceRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// ── Guilds ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// GET /api/guilds
|
|
||||||
router.get(
|
|
||||||
"/guilds",
|
|
||||||
asyncHandler(async (_req: Request, res: Response) => {
|
|
||||||
logger.debug("Fetching guilds");
|
|
||||||
const guilds = await getGuilds();
|
|
||||||
res.json(guilds);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/channels
|
|
||||||
router.get(
|
|
||||||
"/guilds/:guildId/channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.params.guildId as string;
|
|
||||||
logger.debug({ guildId }, "Fetching text channels");
|
|
||||||
const channels = await getTextChannels(guildId);
|
|
||||||
res.json(channels);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// GET /api/guilds/:guildId/voice-channels
|
|
||||||
router.get(
|
|
||||||
"/guilds/:guildId/voice-channels",
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
const guildId = req.params.guildId as string;
|
|
||||||
logger.debug({ guildId }, "Fetching voice channels");
|
|
||||||
const channels = await getVoiceChannels(guildId);
|
|
||||||
res.json(channels);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Voice connection ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// GET /api/voice/status
|
|
||||||
router.get("/voice/status", handleGetVoiceStatus);
|
|
||||||
|
|
||||||
// POST /api/voice/connect
|
|
||||||
router.post(
|
|
||||||
"/voice/connect",
|
|
||||||
validateBody(connectVoiceSchema),
|
|
||||||
handleConnectVoice,
|
|
||||||
);
|
|
||||||
|
|
||||||
// POST /api/voice/disconnect
|
|
||||||
router.post("/voice/disconnect", handleDisconnectVoice);
|
|
||||||
|
|
||||||
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
|
|
||||||
router.post(
|
|
||||||
"/voice/command",
|
|
||||||
validateBody(voiceCommandSchema),
|
|
||||||
handleVoiceCommand,
|
|
||||||
);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { os } from "@orpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { analysisService } from "../modules/analysis/analysis.service";
|
||||||
|
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||||
|
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||||
|
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||||
|
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||||
|
import {
|
||||||
|
mediaLoopSchema,
|
||||||
|
mediaQueueSchema,
|
||||||
|
} from "../modules/media/media.schema";
|
||||||
|
import {
|
||||||
|
getStatus,
|
||||||
|
queue,
|
||||||
|
setLoop,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
} from "../modules/media/media.service";
|
||||||
|
import {
|
||||||
|
messageQuerySchema,
|
||||||
|
semanticSearchSchema,
|
||||||
|
} from "../modules/messages/messages.schema";
|
||||||
|
import { messagesService } from "../modules/messages/messages.service";
|
||||||
|
import { moderationService } from "../modules/moderation/moderation.service";
|
||||||
|
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||||
|
import { uiStateService } from "../modules/ui-state/ui-state.service";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../modules/voice/voice.service";
|
||||||
|
import { config } from "../shared/config/index";
|
||||||
|
import { publishCommandNoReply } from "../shared/redis/index";
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────────
|
||||||
|
const dashboardRouter = {
|
||||||
|
stats: os.handler(() => dashboardService.getStats()),
|
||||||
|
activity: os
|
||||||
|
.input(
|
||||||
|
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => dashboardService.getActivity(input.days)),
|
||||||
|
users: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
dashboardService.listUsers({
|
||||||
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
|
search: input.search,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
userDetail: os
|
||||||
|
.input(z.object({ userId: z.string() }))
|
||||||
|
.handler(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||||
|
channels: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
search: z.string().optional(),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
dashboardService.listChannels({
|
||||||
|
limit: input.limit,
|
||||||
|
search: input.search,
|
||||||
|
guildId: input.guildId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
channelDetail: os
|
||||||
|
.input(z.object({ channelId: z.string() }))
|
||||||
|
.handler(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||||
|
reactions: os
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.handler(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||||
|
reactors: os
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.handler(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────
|
||||||
|
const messagesRouter = {
|
||||||
|
list: os
|
||||||
|
.input(messageQuerySchema)
|
||||||
|
.handler(({ input }) => messagesService.listMessages(input)),
|
||||||
|
byChannel: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getMessagesByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
detail: os
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.handler(({ input }) => messagesService.getMessageById(input.id)),
|
||||||
|
images: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
guildId: z.string(),
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getImageMessages(input.guildId, input.limit),
|
||||||
|
),
|
||||||
|
attachmentsByChannel: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
review: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const rows = await messagesService.getReviewMessages(
|
||||||
|
input.channelId,
|
||||||
|
input.limit,
|
||||||
|
);
|
||||||
|
return { results: rows, limit: input.limit, cursor: null };
|
||||||
|
}),
|
||||||
|
// Public, read-only semantic search over the message archive.
|
||||||
|
semanticSearch: os
|
||||||
|
.input(semanticSearchSchema)
|
||||||
|
.handler(({ input }) => messagesService.semanticSearch(input)),
|
||||||
|
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||||
|
activity: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => messagesService.getActivity(input.days)),
|
||||||
|
// Public, read-only recent message edits (evasion tracker).
|
||||||
|
editHistory: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getRecentEdits(input.limit, input.channelId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Moderation ───────────────────────────────────────────────────
|
||||||
|
const moderationRouter = {
|
||||||
|
stats: os.handler(() => moderationService.getStats()),
|
||||||
|
actions: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
status: z.string().optional(),
|
||||||
|
actionType: z.string().optional(),
|
||||||
|
cursor: z.coerce.number().int().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.listActions({
|
||||||
|
limit: input.limit,
|
||||||
|
status: input.status,
|
||||||
|
actionType: input.actionType,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
trends: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getTrends(input.days)),
|
||||||
|
// Flagged link / scam domain ranking (public Scam Domain panel).
|
||||||
|
topDomains: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getTopFlaggedDomains(input.days)),
|
||||||
|
// Top flagged channels (join moderation_actions → messages).
|
||||||
|
topChannels: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.getTopFlaggedChannels(input.days),
|
||||||
|
),
|
||||||
|
// Hour-of-day moderation distribution (heatmap by hour).
|
||||||
|
byHour: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getHourlyModeration(input.days)),
|
||||||
|
// Flag category drill-down (list actions for one category).
|
||||||
|
byCategory: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
category: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.getByCategory(input.days, input.category),
|
||||||
|
),
|
||||||
|
// Auto-moderation coverage (analysis run completion rate).
|
||||||
|
coverage: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Media ────────────────────────────────────────────────────────
|
||||||
|
const mediaRouter = {
|
||||||
|
status: os.handler(() => getStatus()),
|
||||||
|
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
|
||||||
|
await queue(input.source, input.mode);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
skip: os.handler(async () => {
|
||||||
|
await skip();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
stop: os.handler(async () => {
|
||||||
|
await stop();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
|
||||||
|
await setLoop(input.loop);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Voice ─────────────────────────────────────────────────────────
|
||||||
|
const voiceRouter = {
|
||||||
|
guilds: os.handler(() => getGuilds()),
|
||||||
|
textChannels: os
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.handler(({ input }) => getTextChannels(input.guildId)),
|
||||||
|
voiceChannels: os
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.handler(({ input }) => getVoiceChannels(input.guildId)),
|
||||||
|
status: os.handler(() => getVoiceStatus()),
|
||||||
|
connect: os
|
||||||
|
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await connectVoice(input.guildId, input.channelId);
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
disconnect: os.handler(async () => {
|
||||||
|
await disconnectVoice();
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
command: os
|
||||||
|
.input(z.object({ command: z.string().min(1) }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await publishCommandNoReply(input.command);
|
||||||
|
return { success: true, command: input.command };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Recordings ───────────────────────────────────────────────────
|
||||||
|
const recordingsRouter = {
|
||||||
|
list: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
recordingsService.getRecent(input.limit, {
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
||||||
|
await recordingsService.deleteById(input.id);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
|
const analysisRouter = {
|
||||||
|
search: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().default(""),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
analysisService.search({
|
||||||
|
q: input.q,
|
||||||
|
channelId: input.channelId,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Chatbot ───────────────────────────────────────────────────────
|
||||||
|
const chatbotRouter = {
|
||||||
|
chat: os
|
||||||
|
.input(
|
||||||
|
chatRequestSchema.extend({
|
||||||
|
// Per-device actor id; the old REST layer used an X-User-Id header.
|
||||||
|
// Anonymous sessions use a stable "anonymous" id.
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const response = await chatbotService.processMessage(
|
||||||
|
input.message,
|
||||||
|
input.context,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await chatbotService.saveConversation({
|
||||||
|
userId,
|
||||||
|
userMessage: input.message,
|
||||||
|
botResponse: response,
|
||||||
|
context: input.context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return { response, timestamp: new Date().toISOString() };
|
||||||
|
}),
|
||||||
|
history: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const history = await chatbotService.getChatHistory(userId, input.limit);
|
||||||
|
return { history, total: history.length };
|
||||||
|
}),
|
||||||
|
clearHistory: os
|
||||||
|
.input(z.object({ userId: z.string().optional() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
await chatbotService.clearChatHistory(userId);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Knowledge (public read-only culture glossary + term KB) ───────
|
||||||
|
const knowledgeRouter = {
|
||||||
|
channelCultures: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
knowledgeService.listChannelCultures(input.limit, input.search),
|
||||||
|
),
|
||||||
|
glossary: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
knowledgeService.listGlossary(input.limit, input.search),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const configRouter = {
|
||||||
|
get: os.handler(() => ({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
webserverPort: config.WEBSERVER_PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
||||||
|
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
|
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
|
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
|
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
||||||
|
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||||
|
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||||
|
voiceGuildId: config.VOICE_GUILD_ID || null,
|
||||||
|
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||||
|
logLevel: config.LOG_LEVEL,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UI State ──────────────────────────────────────────────────────
|
||||||
|
const uiStateRouter = {
|
||||||
|
get: os.handler(() => uiStateService.getState()),
|
||||||
|
update: os
|
||||||
|
.input(z.record(z.string(), z.unknown()))
|
||||||
|
.handler(({ input }) => uiStateService.updateState(input)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Root router ───────────────────────────────────────────────────
|
||||||
|
export const appRouter = {
|
||||||
|
dashboard: dashboardRouter,
|
||||||
|
messages: messagesRouter,
|
||||||
|
moderation: moderationRouter,
|
||||||
|
media: mediaRouter,
|
||||||
|
voice: voiceRouter,
|
||||||
|
recordings: recordingsRouter,
|
||||||
|
analysis: analysisRouter,
|
||||||
|
chatbot: chatbotRouter,
|
||||||
|
config: configRouter,
|
||||||
|
uiState: uiStateRouter,
|
||||||
|
knowledge: knowledgeRouter,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
|
import { onError } from "@orpc/server";
|
||||||
|
import { RPCHandler } from "@orpc/server/ws";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { appRouter } from "./router";
|
||||||
|
|
||||||
|
const logger = createChildLogger("orpc.ws");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the oRPC WebSocket handler to the shared HTTP server, on a path
|
||||||
|
* SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs
|
||||||
|
* (dashboard, messages, moderation, media, voice control, recordings,
|
||||||
|
* analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the
|
||||||
|
* `/ws` socket is left untouched for Discord PCM audio + gateway events.
|
||||||
|
*
|
||||||
|
* We use `noServer` + a manual `upgrade` router (instead of
|
||||||
|
* `new WebSocketServer({ server, path: "/trpc" })`) because two `ws` servers
|
||||||
|
* mounted with the `server` option on the SAME http.Server both register
|
||||||
|
* `upgrade` listeners, and `ws`'s path-guarded listener can reject (400) the
|
||||||
|
* other server's path. Routing the upgrade ourselves by URL keeps `/trpc`
|
||||||
|
* and `/ws` fully isolated.
|
||||||
|
*/
|
||||||
|
export function createORPCWebSocketServer(server: Server): WebSocketServer {
|
||||||
|
const handler = new RPCHandler(appRouter, {
|
||||||
|
interceptors: [
|
||||||
|
onError((error) => logger.error({ error }, "oRPC WS error")),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||||||
|
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handler.upgrade(ws, { context: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ path: "/trpc" }, "oRPC WebSocket server attached");
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
@@ -134,6 +134,7 @@ export const configSchema = z
|
|||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.default("https://9router.asepharyana.my.id/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||||
|
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
||||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -208,6 +209,11 @@ export const configSchema = z
|
|||||||
.default("https://api.openai.com/v1"),
|
.default("https://api.openai.com/v1"),
|
||||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||||
|
|
||||||
|
// ── Qdrant (message archive for semantic search) ──────────────────
|
||||||
|
QDRANT_URL: z.string().optional(),
|
||||||
|
QDRANT_API_KEY: z.string().optional(),
|
||||||
|
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
|
||||||
|
|
||||||
// ── Auto Delete ─────────────────────────────────────────────────────
|
// ── Auto Delete ─────────────────────────────────────────────────────
|
||||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
|||||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||||
}),
|
}),
|
||||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
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"),
|
ai_error: pgText("ai_error"),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface MessageRecord {
|
|||||||
ai_confidence?: number | null;
|
ai_confidence?: number | null;
|
||||||
ai_recommended_action?: AIRecommendedAction | null;
|
ai_recommended_action?: AIRecommendedAction | null;
|
||||||
ai_analyzed_at?: number | null;
|
ai_analyzed_at?: number | null;
|
||||||
|
ai_analysis_duration_ms?: number | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated";
|
|||||||
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
||||||
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
||||||
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
||||||
|
export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Command channels (backend -> discord-gateway)
|
// Command channels (backend -> discord-gateway)
|
||||||
@@ -126,4 +127,5 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
|
|||||||
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
||||||
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
||||||
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
||||||
|
[DISCORD_MODERATION_ACTION]: "moderation_action",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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_confidence: number | null;
|
||||||
ai_recommended_action: string | null;
|
ai_recommended_action: string | null;
|
||||||
ai_analyzed_at: number | null;
|
ai_analyzed_at: number | null;
|
||||||
|
ai_analysis_duration_ms: number | null;
|
||||||
ai_error: string | null;
|
ai_error: string | null;
|
||||||
is_reply: boolean | null;
|
is_reply: boolean | null;
|
||||||
is_forward: 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_confidence: (row.ai_confidence as number | null) ?? null,
|
||||||
ai_recommended_action: (row.ai_recommended_action as string | 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_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,
|
ai_error: (row.ai_error as string | null) ?? null,
|
||||||
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
||||||
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Server } from "node:http";
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import { messagesService } from "../modules/messages/messages.service.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
@@ -96,9 +98,20 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
const frontendClients = new Set<WebSocket>();
|
const frontendClients = new Set<WebSocket>();
|
||||||
const gatewayClients = new Set<WebSocket>();
|
const gatewayClients = new Set<WebSocket>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
|
|
||||||
|
// Manual upgrade routing: without this, two `ws` servers bound to the same
|
||||||
|
// http.Server via the `server` option both register `upgrade` listeners and
|
||||||
|
// the path-guarded one destructively rejects the other's path (400). We own
|
||||||
|
// the upgrade event and dispatch by URL instead.
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/ws")) return;
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Map-based dispatcher for JSON WebSocket message types
|
// Map-based dispatcher for JSON WebSocket message types
|
||||||
const jsonHandlers = new Map<string, MessageHandler>();
|
const jsonHandlers = new Map<string, MessageHandler>();
|
||||||
|
|
||||||
@@ -128,6 +141,73 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stream historical messages one-by-one over WS (no 50-row batch).
|
||||||
|
// The frontend requests it once per channel switch; the backend emits one
|
||||||
|
// `message_snapshot` frame per message so the UI renders progressively.
|
||||||
|
jsonHandlers.set("stream_messages", async (ws, message) => {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const payload = (message.payload ?? {}) as {
|
||||||
|
guildId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
const guildId = payload.guildId;
|
||||||
|
const channelId = payload.channelId;
|
||||||
|
if (!guildId && !channelId) {
|
||||||
|
logger.warn({ payload }, "stream_messages requires guildId or channelId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 50; // internal DB page size; still emitted one frame at a time
|
||||||
|
const maxFrames = Math.min(payload.limit ?? 200, 500);
|
||||||
|
|
||||||
|
let sent = 0;
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
try {
|
||||||
|
for await (const msg of messagesService.streamMessages(
|
||||||
|
{
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
cursor: payload.cursor,
|
||||||
|
} as never,
|
||||||
|
pageSize,
|
||||||
|
)) {
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) break;
|
||||||
|
// Streamed DESC (newest first); the oldest emitted carries the smallest
|
||||||
|
// created_at, which is exactly the next-page cursor for "load older".
|
||||||
|
const createdAt = (msg as { created_at?: number }).created_at;
|
||||||
|
if (createdAt !== undefined) nextCursor = String(createdAt);
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot",
|
||||||
|
data: msg,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sent++;
|
||||||
|
if (sent >= maxFrames) break;
|
||||||
|
}
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot_end",
|
||||||
|
data: { sent, nextCursor },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, "stream_messages failed");
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "message_snapshot_end",
|
||||||
|
data: { sent, nextCursor, error: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
wss.on("connection", (ws: WebSocket, req) => {
|
wss.on("connection", (ws: WebSocket, req) => {
|
||||||
// Parse auth token from query string
|
// Parse auth token from query string
|
||||||
const rawUrl = req.url ?? "/";
|
const rawUrl = req.url ?? "/";
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tools } from "../src/modules/chatbot/chatbot.toolDefs.js";
|
||||||
|
|
||||||
|
const names = tools.map((t) => t.function.name);
|
||||||
|
|
||||||
|
describe("chatbot tool definitions", () => {
|
||||||
|
it("exposes a stable, non-empty tool set", () => {
|
||||||
|
expect(tools.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(new Set(names).size).toBe(names.length); // no dup names
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every tool declares a name, description, and object parameters", () => {
|
||||||
|
for (const t of tools) {
|
||||||
|
expect(t.type).toBe("function");
|
||||||
|
expect(typeof t.function.name).toBe("string");
|
||||||
|
expect(t.function.description.length).toBeGreaterThan(10);
|
||||||
|
expect(t.function.parameters.type).toBe("object");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required-only tools declare required args", () => {
|
||||||
|
const byName = new Map(tools.map((t) => [t.function.name, t]));
|
||||||
|
for (const [name, required] of [
|
||||||
|
["search_messages", "query"],
|
||||||
|
["get_user_messages", "userId"],
|
||||||
|
["get_user_profile", "userId"],
|
||||||
|
["get_user_reputation", "userId"],
|
||||||
|
["get_channel_culture", "channelId"],
|
||||||
|
["get_message_detail", "messageId"],
|
||||||
|
] as const) {
|
||||||
|
const tool = byName.get(name);
|
||||||
|
expect(tool, `missing tool ${name}`).toBeDefined();
|
||||||
|
expect(tool?.function.parameters.required).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the core server-watcher situations", () => {
|
||||||
|
for (const required of [
|
||||||
|
"get_server_stats",
|
||||||
|
"get_top_channels",
|
||||||
|
"get_recent_activity",
|
||||||
|
"get_top_flagged",
|
||||||
|
"search_messages",
|
||||||
|
"get_user_messages",
|
||||||
|
"get_user_profile",
|
||||||
|
"get_user_reputation",
|
||||||
|
"get_channel_culture",
|
||||||
|
"get_message_detail",
|
||||||
|
"get_message_reviews",
|
||||||
|
"get_voice_recordings",
|
||||||
|
"get_moderation_timeline",
|
||||||
|
"get_corrections",
|
||||||
|
]) {
|
||||||
|
expect(names, `missing ${required}`).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the contract that the WS `stream_messages` handler + frontend
|
||||||
|
* `useMessagesStream` depend on.
|
||||||
|
*
|
||||||
|
* Real behavior (src/modules/messages/messages.repository.ts → streamMany, and
|
||||||
|
* src/ws/server.ts stream_messages handler):
|
||||||
|
* - ONE `stream_messages` request streams the WHOLE history for the scope,
|
||||||
|
* internally paging `limit+1` at a time (cursor = oldest created_at of the
|
||||||
|
* page) until exhausted or maxFrames is hit.
|
||||||
|
* - Messages are emitted ONE AT A TIME, DESC (newest first).
|
||||||
|
* - The final `message_snapshot_end` carries `nextCursor` = the OLDEST emitted
|
||||||
|
* row's `created_at`, so the FE's next "load older" request pages forward.
|
||||||
|
*
|
||||||
|
* We replicate streamMany's pagination algorithm over an in-memory array so the
|
||||||
|
* test needs no DB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Row = { id: string; created_at: number; guild_id: string };
|
||||||
|
|
||||||
|
function makeStream(
|
||||||
|
rows: Row[],
|
||||||
|
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||||
|
pageSize = 50,
|
||||||
|
): () => Generator<Row, void, unknown> {
|
||||||
|
return function* () {
|
||||||
|
let cursor = query.cursor;
|
||||||
|
while (true) {
|
||||||
|
const page = rows
|
||||||
|
.filter((r) => (query.guildId ? r.guild_id === query.guildId : true))
|
||||||
|
.filter((r) => (cursor ? r.created_at < Number(cursor) : true))
|
||||||
|
.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
.slice(0, pageSize + 1);
|
||||||
|
|
||||||
|
if (page.length === 0) return;
|
||||||
|
const hasMore = page.length > pageSize;
|
||||||
|
const pageRows = hasMore ? page.slice(0, pageSize) : page;
|
||||||
|
for (const r of pageRows) yield r;
|
||||||
|
if (!hasMore) return;
|
||||||
|
cursor = String(page[pageSize - 1].created_at);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamAll(
|
||||||
|
rows: Row[],
|
||||||
|
query: { guildId?: string; channelId?: string; cursor?: string },
|
||||||
|
pageSize = 50,
|
||||||
|
maxFrames = Infinity,
|
||||||
|
): { data: Row[]; nextCursor: string | null } {
|
||||||
|
const data: Row[] = [];
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
for (const r of makeStream(rows, query, pageSize)()) {
|
||||||
|
nextCursor = String(r.created_at);
|
||||||
|
data.push(r);
|
||||||
|
if (data.length >= maxFrames) break;
|
||||||
|
}
|
||||||
|
return { data, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mk = (id: string, created_at: number, guild_id = "g1"): Row => ({
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
guild_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("messages.streamMany contract", () => {
|
||||||
|
it("emits newest-first and sets nextCursor to oldest created_at", () => {
|
||||||
|
const rows = [mk("a", 300), mk("b", 200), mk("c", 100)];
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" });
|
||||||
|
expect(data.map((r) => r.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(nextCursor).toBe("100"); // oldest emitted
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams the entire history in one request, one frame at a time", () => {
|
||||||
|
// 120 rows; one request must yield all 120 (no 50-row batch boundary).
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50);
|
||||||
|
expect(data).toHaveLength(120);
|
||||||
|
expect(data[0].id).toBe("m0"); // newest first
|
||||||
|
expect(nextCursor).toBe("881"); // oldest = m119 (1000-119)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a frame cap and leaves nextCursor mid-history", () => {
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const { data, nextCursor } = streamAll(rows, { guildId: "g1" }, 50, 60);
|
||||||
|
expect(data).toHaveLength(60);
|
||||||
|
// nextCursor = 60th oldest = m59 (1000-59=941)
|
||||||
|
expect(nextCursor).toBe("941");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paginates correctly across subsequent load-older requests", () => {
|
||||||
|
const rows = Array.from({ length: 120 }, (_, i) => mk(`m${i}`, 1000 - i));
|
||||||
|
const first = streamAll(rows, { guildId: "g1" }, 50, 50);
|
||||||
|
expect(first.data).toHaveLength(50);
|
||||||
|
expect(first.nextCursor).toBe("951"); // 50th oldest = m49
|
||||||
|
|
||||||
|
const older = streamAll(
|
||||||
|
rows,
|
||||||
|
{ guildId: "g1", cursor: first.nextCursor ?? undefined },
|
||||||
|
50,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
expect(older.data[0].id).toBe("m50"); // continues right after m49
|
||||||
|
expect(older.nextCursor).toBe("901"); // 100th oldest
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by guild", () => {
|
||||||
|
const rows = [mk("x", 500, "g1"), mk("y", 400, "g2")];
|
||||||
|
const { data } = streamAll(rows, { guildId: "g2" });
|
||||||
|
expect(data.map((r) => r.id)).toEqual(["y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/
|
services/discord-gateway/
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point → initializeDiscordGateway()
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── bootstrap.ts # Discord Gateway initialization (no HTTP server)
|
│ │ ├── bootstrap.ts # Wires client, DB, Redis, workers, schedulers
|
||||||
│ │ └── shutdown.ts # Graceful shutdown handler
|
│ │ ├── shutdown.ts # Graceful shutdown (SIGINT/SIGTERM + transient errors)
|
||||||
|
│ │ └── retention.ts # Expired-record cleanup scheduler
|
||||||
│ ├── shared/
|
│ ├── shared/
|
||||||
│ │ ├── config/
|
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
|
||||||
│ │ │ └── config.ts # Environment configuration (Zod validated)
|
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
|
||||||
│ │ ├── database/
|
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
|
||||||
│ │ │ ├── schema.ts # Drizzle ORM schema
|
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
|
||||||
│ │ │ ├── drizzle.ts # Database connection
|
│ │ ├── logger/ # pino wrapper + createChildLogger()
|
||||||
│ │ │ ├── migrate.ts # Migration runner
|
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
|
||||||
│ │ │ └── voiceRecordingRepo.ts
|
│ │ ├── utils/ # retry, pagination
|
||||||
│ │ ├── errors/
|
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
|
||||||
│ │ │ └── errors.ts # Custom error classes
|
│ │ ├── uploader.ts # Shared attachment upload helper
|
||||||
│ │ ├── logger/
|
│ │ ├── redis-channels.ts # Redis channel-name constants
|
||||||
│ │ │ ├── logger.ts # Winston logger wrapper
|
│ │ └── moderation-types.ts # Shared AI analysis domain types
|
||||||
│ │ │ └── serialization.ts # Log value serialization
|
│ └── modules/
|
||||||
│ │ ├── utils/
|
│ ├── message-capture/ # Discord event listeners + DB store
|
||||||
│ │ │ └── retry.ts # Retry with backoff utility
|
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
|
||||||
│ │ └── discord/
|
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
|
||||||
│ │ └── clientOptions.ts # Discord.js client configuration
|
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
|
||||||
│ ├── modules/
|
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
|
||||||
│ │ ├── message-capture/ # Modular MVC: Message capture & storage
|
│ ├── attachment-upload/ # Download + (sharp) resize + upload
|
||||||
│ │ │ ├── messageCapture.ts # Controller: Discord event listeners
|
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||||
│ │ │ ├── messageStore.ts # Repository: Database operations
|
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
|
||||||
│ │ │ ├── messageMetadata.ts # Service: Message metadata extraction
|
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||||
│ │ │ ├── types.ts # Domain types
|
│ ├── channel-topic/ guild-member-events/
|
||||||
│ │ │ └── index.ts # Module exports
|
│ └── gateway-metrics/ # Prometheus /metrics endpoint (port 4016)
|
||||||
│ │ ├── 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
|
|
||||||
|
|
||||||
## Architecture Patterns
|
## AI moderation pipeline (`ai-moderation/`)
|
||||||
|
|
||||||
### Modular MVC Structure
|
LLM-only judge — no regex/heuristic classification. One orchestrator call
|
||||||
Each module follows Controller-Service-Repository pattern:
|
handles a whole batch (text + media split internally, parallel paths).
|
||||||
- **Controller**: Discord event listeners (messageCapture, aiAnalyzer, voiceController)
|
|
||||||
- **Service**: Business logic (messageStore, llmModerationClient, recorder)
|
|
||||||
- **Repository**: Data access (messageStore, voiceRecordingRepo)
|
|
||||||
|
|
||||||
### Event-Driven Design
|
- `aiAnalyzer.ts` — public API: `queueMessageAnalysis`, `getAnalysisQueueStatus`,
|
||||||
- **Redis Pub/Sub**: All events published to Redis channels
|
`startPendingAIAnalysisWorker` (recovery worker + cache-prune).
|
||||||
- **Event Channels**:
|
- `batchScheduler.ts` — per-conversation debounce → `processBatch`.
|
||||||
- `discord:message:created` — New message captured
|
- `batchProcessor.ts` — batch lock/circuit-breaker, fans failed targets to
|
||||||
- `discord:message:updated` — Message edited
|
individual fallback.
|
||||||
- `discord:message:deleted` — Message deleted
|
- `individualFallbackProcessor.ts` — one-message-at-a-time retry path, own CB.
|
||||||
- `discord:message:analyzed` — AI analysis complete
|
- `conversationState.ts` / `circuitBreaker.ts` — per-conversation state,
|
||||||
- `discord:attachment:created` — Attachment detected
|
Piscina `workerPool`, `getConversationKey`.
|
||||||
- `discord:attachment:uploaded` — Attachment uploaded to storage
|
- `ai-analysis-worker.ts` — Piscina entry point (`batch` / `individual` jobs).
|
||||||
- `discord:voice:started` — Voice recording started
|
Runs `runModerationAnalysis` off the main thread.
|
||||||
- `discord:voice:stopped` — Voice recording stopped
|
- `moderationOrchestrator.ts` — exact-hash cache → batched semantic (Qdrant)
|
||||||
- `discord:voice:uploaded` — Voice segment uploaded
|
cache → LLM. Text and media paths run in parallel.
|
||||||
- `discord:analysis:queue_status` — Analysis queue status update
|
- `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` /
|
||||||
|
`userProfileStore.ts` — caches learned user profile summaries (optional).
|
||||||
|
|
||||||
### Shared Infrastructure
|
### Concurrency model
|
||||||
- **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
|
|
||||||
|
|
||||||
### No HTTP Server
|
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
||||||
- Discord Gateway service is **event-driven only**
|
`llmClient.withLlmConcurrency`.
|
||||||
- No Express, WebSocket, or HTTP routes
|
- Piscina pool (`PISCINA_MAX_THREADS`, default 4) runs the heavy LLM work off
|
||||||
- All communication via Redis pub/sub
|
the event loop; **each worker thread initializes its own pg Pool** (min 0,
|
||||||
- Backend service consumes events and serves HTTP API
|
grows to `POSTGRES_POOL_MAX`). See "Memory & connections" below.
|
||||||
|
|
||||||
## Initialization Flow
|
## Memory & DB connections
|
||||||
|
|
||||||
1. Load environment config (Zod validation)
|
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
||||||
2. Initialize database connection
|
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
||||||
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)
|
|
||||||
|
|
||||||
## 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:
|
## Event channels (Redis pub/sub)
|
||||||
1. Close database connection
|
|
||||||
2. Disconnect from voice channels
|
|
||||||
3. Close Redis connection
|
|
||||||
4. Destroy Discord client
|
|
||||||
5. Exit process
|
|
||||||
|
|
||||||
## 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**:
|
## Initialization flow
|
||||||
- discord.js-selfbot-v13
|
|
||||||
- @discordjs/voice
|
|
||||||
- @discordjs/opus
|
|
||||||
|
|
||||||
**Audio Processing**:
|
1. Validate env (Zod). Refuse to start if `AI_ANALYSIS_ENABLED` but no key.
|
||||||
- prism-media (Opus encoding/decoding)
|
2. `AUTO_MIGRATE_ON_STARTUP` → run pending Drizzle migrations.
|
||||||
- opusscript (Opus fallback)
|
3. `initializeDatabase()` (pg Pool, min 0).
|
||||||
- sharp (Image resizing)
|
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**:
|
## Graceful shutdown
|
||||||
- drizzle-orm (ORM)
|
|
||||||
- pg (PostgreSQL driver)
|
|
||||||
- zod (Config validation)
|
|
||||||
- ioredis (Redis client)
|
|
||||||
|
|
||||||
**Logging & Utilities**:
|
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
|
||||||
- winston (Structured logging)
|
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
|
||||||
- p-retry (Retry logic)
|
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
|
||||||
- p-limit (Concurrency limiting)
|
close command handler → close DB → destroy client → exit.
|
||||||
- piscina (Worker pool)
|
|
||||||
|
|
||||||
## Event Flow Example
|
## Observability
|
||||||
|
|
||||||
### Message Capture Flow
|
Prometheus scrapes `127.0.0.1:4016/metrics` (`bete_*` prefix). Collectors run
|
||||||
1. Discord emits `messageCreate` event
|
per-scrape and expose: process memory/uptime, and (when AI analysis is on) live
|
||||||
2. `messageCapture.ts` listener receives event
|
pipeline gauges — `ai_analysis_queued_conversations`,
|
||||||
3. Extract metadata (user, channel, content, timestamp)
|
`ai_analysis_active_batch_requests`, `ai_analysis_active_individual_requests`,
|
||||||
4. `messageStore.ts` inserts into database
|
`ai_analysis_individual_in_flight`, `ai_analysis_individual_circuit_breaker_active`,
|
||||||
5. `eventBroadcaster.messageCreated()` publishes to Redis
|
`ai_analysis_worker_threads`, `ai_analysis_worker_threads_active`.
|
||||||
6. Backend service subscribes to `discord:message:created` channel
|
|
||||||
7. Backend processes and stores in its own database
|
|
||||||
|
|
||||||
### Voice Recording Flow
|
## Key invariants (do not break)
|
||||||
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
|
|
||||||
|
|
||||||
## No Breaking Changes
|
- **LLM is the only judge.** Failed LLM → `status:"error"` + recovery retry.
|
||||||
|
Never reintroduce regex/heuristic content classification.
|
||||||
- Original `src/` remains untouched for now
|
- **Discord tokens are sanitized** (`discordTokens.ts`: `<:emoji:id>` →
|
||||||
- Discord Gateway is a **new service** in `services/discord-gateway/`
|
`[emoji:name]`, `<@id>` → `@user`, etc.) before content reaches the LLM, so
|
||||||
- Can run alongside existing monolith during transition
|
numeric snowflake IDs never trigger false positives.
|
||||||
- Backend service will consume Redis events
|
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
||||||
- Frontend continues to use Backend HTTP API
|
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/
|
services/discord-gateway/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── index.ts # Entry point
|
||||||
│ │ ├── bootstrap.ts
|
│ ├── app/ # bootstrap, shutdown, retention
|
||||||
│ │ │ └── Initializes Discord client, database, Redis broadcaster
|
│ ├── shared/ # config, database, logger, errors, utils, discord, uploader
|
||||||
│ │ │ Registers event listeners, handles graceful shutdown
|
│ └── modules/
|
||||||
│ │ └── shutdown.ts
|
│ ├── message-capture/ # Discord listeners + DB store + metadata
|
||||||
│ │ └── Graceful shutdown handler for SIGINT/SIGTERM/exceptions
|
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
|
||||||
│ │
|
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
|
||||||
│ ├── shared/
|
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
|
||||||
│ │ ├── config/
|
│ ├── attachment-upload/ # Download + sharp resize + upload
|
||||||
│ │ │ └── config.ts
|
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||||
│ │ │ └── Zod-validated environment configuration
|
│ ├── command-handler/ # Backend→gateway Redis commands
|
||||||
│ │ │ - Discord token, database URL, Redis URL
|
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||||
│ │ │ - AI LLM settings, recording parameters
|
│ ├── channel-topic/ guild-member-events/
|
||||||
│ │ │ - Attachment upload settings, retention policies
|
│ └── gateway-metrics/ # Prometheus /metrics (port 4016)
|
||||||
│ │ │
|
├── tests/ # Vitest suites (129 tests)
|
||||||
│ │ ├── database/
|
├── drizzle/ # Drizzle migration SQL + journal
|
||||||
│ │ │ ├── schema.ts
|
├── ARCHITECTURE.md README.md package.json tsconfig.json vitest.config.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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module Responsibilities
|
## Module responsibilities (summary)
|
||||||
|
|
||||||
### message-capture
|
### message-capture
|
||||||
**Purpose**: Capture Discord messages (create, update, delete)
|
Captures `messageCreate`/`messageUpdate`/`messageDelete`, extracts metadata,
|
||||||
**Pattern**: Controller-Service-Repository
|
stores to Postgres, publishes to Redis. Controller–Service–Repository split:
|
||||||
- **Controller** (messageCapture.ts): Listens to Discord events
|
`messageCapture.ts` (listener) → `messageStore.ts` (DB) + `messageMetadata.ts`
|
||||||
- **Service** (messageMetadata.ts): Extracts metadata
|
(service).
|
||||||
- **Repository** (messageStore.ts): Database operations
|
|
||||||
- **Events Published**:
|
|
||||||
- `discord:message:created`
|
|
||||||
- `discord:message:updated`
|
|
||||||
- `discord:message:deleted`
|
|
||||||
|
|
||||||
### ai-moderation
|
### ai-moderation
|
||||||
**Purpose**: Analyze messages with LLM for moderation
|
LLM-only moderation. Entry: `aiAnalyzer.ts` (`queueMessageAnalysis`,
|
||||||
**Pattern**: Controller-Service-Service-Service
|
`startPendingAIAnalysisWorker`, `getAnalysisQueueStatus`). Scheduling:
|
||||||
- **Controller** (aiAnalyzer.ts): Orchestrates analysis workflow
|
`batchScheduler.ts` → `batchProcessor.ts` (batch lock + circuit breaker) →
|
||||||
- **Service** (llmModerationClient.ts): LLM API integration
|
`individualFallbackProcessor.ts` (per-message retry). Heavy work runs in the
|
||||||
- **Service** (aiAnalysisWorker.ts): Worker pool management
|
Piscina pool via `ai-analysis-worker.ts` (jobs `batch` / `individual`).
|
||||||
- **Service** (indonesianTextNormalizer.ts): Text preprocessing
|
Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched
|
||||||
- **Service** (moderationPrompt.ts): Prompt generation
|
semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
|
||||||
- **Events Published**:
|
(one LLM call per sub-batch), `llmClient.ts` (central streaming client),
|
||||||
- `discord:message:analyzed`
|
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
|
||||||
- `discord:analysis:queue_status`
|
`channelCultureStore.ts` / `userProfileStore.ts`.
|
||||||
|
|
||||||
### voice-recording
|
### voice-recording
|
||||||
**Purpose**: Record voice channel audio
|
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
|
||||||
**Pattern**: Controller-Service-SubServices
|
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
|
||||||
- **Controller** (voiceController.ts): Voice connection management
|
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
|
||||||
- **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`
|
|
||||||
|
|
||||||
### attachment-upload
|
### attachment-upload
|
||||||
**Purpose**: Upload message attachments to external storage
|
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
|
||||||
**Pattern**: Service-Service
|
(sharp resize). Emits `discord:attachment:*`.
|
||||||
- **Service** (attachmentUploader.ts): Upload orchestration
|
|
||||||
- **Service** (imageResizer.ts): Image processing
|
|
||||||
- **Events Published**:
|
|
||||||
- `discord:attachment:created`
|
|
||||||
- `discord:attachment:uploaded`
|
|
||||||
|
|
||||||
### event-broadcaster
|
### event-broadcaster
|
||||||
**Purpose**: Publish events to Redis pub/sub
|
`RedisEventPublisher` (ioredis publish) + `EventBroadcaster` (typed methods).
|
||||||
**Pattern**: Service-Domain
|
Channel names in `src/shared/redis-channels.ts`.
|
||||||
- **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)
|
|
||||||
|
|
||||||
## 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
|
## Shared infrastructure
|
||||||
- Zod-validated environment variables
|
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
|
||||||
- Type-safe configuration access
|
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
|
||||||
- Sensible defaults
|
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
|
||||||
|
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
|
||||||
|
|
||||||
### database
|
## Notes
|
||||||
- Drizzle ORM schema
|
- No HTTP server (other than the metrics endpoint). Pure event-driven.
|
||||||
- PostgreSQL connection
|
- `MODULE_STRUCTURE.md` is intentionally a sketch; `ARCHITECTURE.md` is the
|
||||||
- Migration management
|
detailed reference. When they diverge, `ARCHITECTURE.md` wins.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: 0015_add_moderation_explainability.sql
|
||||||
|
-- Date: 2026-08-18
|
||||||
|
-- Description: Add structured explainability columns to moderation_actions.
|
||||||
|
-- These are READ (surfaced read-only to the public web) so they are never
|
||||||
|
-- "written but never read" — they back the public moderation transparency view.
|
||||||
|
-- Idempotent: no-ops on databases that already carry the columns.
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "moderation_actions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "severity" text
|
||||||
|
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||||
|
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "score" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Remove the user reputation feature entirely (trust scores, infractions).
|
||||||
|
-- The feature was removed from the codebase; this drops the orphaned table.
|
||||||
|
DROP TABLE IF EXISTS "user_reputations";
|
||||||
@@ -99,6 +99,27 @@
|
|||||||
"when": 1785551832190,
|
"when": 1785551832190,
|
||||||
"tag": "0013_rename_mascot_chat_to_chatbot",
|
"tag": "0013_rename_mascot_chat_to_chatbot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785621600000,
|
||||||
|
"tag": "0014_add_term_glossary_cache",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787184000000,
|
||||||
|
"tag": "0015_add_moderation_explainability",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787185000000,
|
||||||
|
"tag": "0016_drop_user_reputations",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,8 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
"@lng2004/node-datachannel",
|
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"node-av",
|
"sharp"
|
||||||
"sharp",
|
|
||||||
"zeromq"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,7 +21,6 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dank074/discord-video-stream": "6.0.0",
|
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@snazzah/davey": "^0.1.11",
|
"@snazzah/davey": "^0.1.11",
|
||||||
@@ -32,7 +28,6 @@
|
|||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"imghash": "^1.1.4",
|
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
|
|||||||
Generated
+36
-1060
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ allowBuilds:
|
|||||||
"@lng2004/node-datachannel": true
|
"@lng2004/node-datachannel": true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
node-av: true
|
node-av: true
|
||||||
|
sharp: true
|
||||||
zeromq: true
|
zeromq: true
|
||||||
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
|
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
|
||||||
# package.json is ignored). Native voice deps need their postinstall build.
|
# package.json is ignored). Native voice deps need their postinstall build.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Rewrite import specifiers in the compiled dist/ so the output runs under
|
||||||
|
// plain `node dist/index.js` (native ESM, no bundler / no tsx).
|
||||||
|
//
|
||||||
|
// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE
|
||||||
|
// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves
|
||||||
|
// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects
|
||||||
|
// extensionless relative specifiers and knows nothing about the `@/` alias, so
|
||||||
|
// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script
|
||||||
|
// fixes both:
|
||||||
|
// 1. `@/foo` -> relative path to dist/foo.js
|
||||||
|
// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js)
|
||||||
|
// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare
|
||||||
|
// package specifiers are left untouched (idempotent).
|
||||||
|
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
|
import { join, relative, dirname } from "node:path";
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
function walk(dir) {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = join(dir, e.name);
|
||||||
|
if (e.isDirectory()) walk(p);
|
||||||
|
else if (e.name.endsWith(".js")) {
|
||||||
|
const c = readFileSync(p, "utf8");
|
||||||
|
const pat = /from\s+['"]([^'"]+)['"]/g;
|
||||||
|
const n = c.replace(pat, (m, spec) => {
|
||||||
|
if (spec.startsWith("@/")) {
|
||||||
|
// Source may already carry an extension (e.g. "@/shared/config/index.js");
|
||||||
|
// only append ".js" when the specifier has none — otherwise we'd
|
||||||
|
// produce "index.js.js".
|
||||||
|
const core = spec.slice(2);
|
||||||
|
let target;
|
||||||
|
if (/\.(js|json|node|mjs|cjs)$/.test(core)) {
|
||||||
|
target = join("dist", core);
|
||||||
|
} else {
|
||||||
|
target = join("dist", core) + ".js";
|
||||||
|
}
|
||||||
|
let rel = relative(dirname(p), target);
|
||||||
|
if (!rel.startsWith(".")) rel = "./" + rel;
|
||||||
|
return `from "${rel}"`;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(spec.startsWith("./") || spec.startsWith("../")) &&
|
||||||
|
!/\.(js|json|node|mjs|cjs)$/.test(spec)
|
||||||
|
) {
|
||||||
|
return `from "${spec}.js"`;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
if (n !== c) {
|
||||||
|
writeFileSync(p, n);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk("dist");
|
||||||
|
console.log(`Fixed ${count} import specifiers in dist/`);
|
||||||
@@ -3,7 +3,11 @@ import { inArray, lt } from "drizzle-orm";
|
|||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import { ConfigError, DatabaseError } from "@/shared/errors/index";
|
import { ConfigError, DatabaseError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/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 { registerChannelTopicCapture } from "../modules/channel-topic/index.js";
|
||||||
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +15,8 @@ import {
|
|||||||
RedisEventPublisher,
|
RedisEventPublisher,
|
||||||
} from "../modules/event-broadcaster/index.js";
|
} from "../modules/event-broadcaster/index.js";
|
||||||
import {
|
import {
|
||||||
|
registerCollector,
|
||||||
|
setGauge,
|
||||||
startMetricsServer,
|
startMetricsServer,
|
||||||
stopMetricsServer,
|
stopMetricsServer,
|
||||||
} from "../modules/gateway-metrics/index.js";
|
} from "../modules/gateway-metrics/index.js";
|
||||||
@@ -19,6 +25,8 @@ import {
|
|||||||
registerMessageCapture,
|
registerMessageCapture,
|
||||||
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
setEventBroadcaster as setMessageCaptureEventBroadcaster,
|
||||||
} from "../modules/message-capture/messageCapture.js";
|
} from "../modules/message-capture/messageCapture.js";
|
||||||
|
import { setModerationEventBroadcaster } from "../modules/message-capture/moderationActionsDb.js";
|
||||||
|
import { startDigestScheduler } from "../modules/monitor/digestScheduler.js";
|
||||||
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
||||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||||
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
||||||
@@ -222,7 +230,10 @@ export async function initializeDiscordGateway() {
|
|||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
logger.info("PostgreSQL database initialized");
|
logger.info("PostgreSQL database initialized");
|
||||||
} catch (err) {
|
} 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(
|
throw new DatabaseError(
|
||||||
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
`Database initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
@@ -245,6 +256,7 @@ export async function initializeDiscordGateway() {
|
|||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
||||||
setRecorderEventBroadcaster(eventBroadcaster);
|
setRecorderEventBroadcaster(eventBroadcaster);
|
||||||
|
setModerationEventBroadcaster(eventBroadcaster);
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
||||||
|
|
||||||
@@ -264,10 +276,15 @@ export async function initializeDiscordGateway() {
|
|||||||
|
|
||||||
// Start retention cleanup scheduler
|
// Start retention cleanup scheduler
|
||||||
startRetentionCleanup();
|
startRetentionCleanup();
|
||||||
|
// Start weekly moderation digest (public, automated)
|
||||||
|
startDigestScheduler();
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
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", () => {
|
process.on("SIGINT", () => {
|
||||||
@@ -279,15 +296,97 @@ export async function initializeDiscordGateway() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
process.on("uncaughtException", (err) => {
|
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");
|
gracefulShutdown("uncaughtException");
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason, promise) => {
|
process.on("unhandledRejection", (reason) => {
|
||||||
logger.error({ reason, promise }, "Unhandled rejection");
|
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");
|
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
|
// Start metrics server
|
||||||
startMetricsServer();
|
startMetricsServer();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ import { config } from "../../shared/config/config.js";
|
|||||||
import { initializeDatabase } from "../../shared/database/drizzle.js";
|
import { initializeDatabase } from "../../shared/database/drizzle.js";
|
||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.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";
|
import { runModerationAnalysis } from "./moderationOrchestrator.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ai-analysis-worker");
|
const logger = createChildLogger("ai-analysis-worker");
|
||||||
@@ -96,7 +100,16 @@ type BatchErrorResponse = {
|
|||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
type IndividualOkResponse = { ok: true; results: AnalysisResult[] };
|
type IndividualOkResponse = {
|
||||||
|
ok: true;
|
||||||
|
results: AnalysisResult[];
|
||||||
|
/**
|
||||||
|
* Race-guard signal (2026-08-24): the message's attachment upload is still
|
||||||
|
* in-flight — NO analysis ran. The processor must re-queue the message as
|
||||||
|
* `pending` and re-schedule, never treat this as a completed moderation.
|
||||||
|
*/
|
||||||
|
uploadPending?: boolean;
|
||||||
|
};
|
||||||
type IndividualErrorResponse = {
|
type IndividualErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
results: AnalysisResult[];
|
results: AnalysisResult[];
|
||||||
@@ -274,29 +287,56 @@ async function processBatch(job: {
|
|||||||
contextBefore,
|
contextBefore,
|
||||||
targets: messages,
|
targets: messages,
|
||||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
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 contextIds = contextBefore.map((m) => m.id);
|
||||||
const attachments = await messageStore.getAttachmentsForMessages([
|
const attachments = await messageStore.getAttachmentsForMessages([
|
||||||
...targetIds,
|
...allTargetIds,
|
||||||
...contextIds,
|
...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
|
// The orchestrator handles text/media split + caching + parallel paths
|
||||||
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
||||||
// when media is present), not N per-message calls.
|
// when media is present), not N per-message calls.
|
||||||
|
const analysisStart = Date.now();
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: messages,
|
targets: readyMessages,
|
||||||
contextText,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
|
const analysisDurationMs = Date.now() - analysisStart;
|
||||||
|
|
||||||
const results = moderationResult.results.map((r) =>
|
const results = moderationResult.results.map((r) =>
|
||||||
normalizeResult(
|
normalizeResult(
|
||||||
r as unknown as AnalysisResult,
|
r as unknown as AnalysisResult,
|
||||||
messages.find((m) => m.id === r.messageId),
|
readyMessages.find((m) => m.id === r.messageId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -313,6 +353,7 @@ async function processBatch(job: {
|
|||||||
confidence: result.confidence,
|
confidence: result.confidence,
|
||||||
recommendedAction: result.recommendedAction,
|
recommendedAction: result.recommendedAction,
|
||||||
analyzedAt: Date.now(),
|
analyzedAt: Date.now(),
|
||||||
|
analysisDurationMs,
|
||||||
error: result.status === "error" ? result.analysis : null,
|
error: result.status === "error" ? result.analysis : null,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -324,9 +365,10 @@ async function processBatch(job: {
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
total: messages.length,
|
total: readyMessages.length,
|
||||||
saved: allRows.length,
|
saved: allRows.length,
|
||||||
conversationKey,
|
conversationKey,
|
||||||
|
skippedPendingUpload: messages.length - readyMessages.length,
|
||||||
},
|
},
|
||||||
"LLM batch analysis complete",
|
"LLM batch analysis complete",
|
||||||
);
|
);
|
||||||
@@ -359,8 +401,14 @@ async function processIndividual(job: {
|
|||||||
contextBefore,
|
contextBefore,
|
||||||
targets: [message],
|
targets: [message],
|
||||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
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 contextIds = contextBefore.map((m) => m.id);
|
||||||
const attachments = await messageStore.getAttachmentsForMessages([
|
const attachments = await messageStore.getAttachmentsForMessages([
|
||||||
@@ -368,10 +416,21 @@ async function processIndividual(job: {
|
|||||||
...contextIds,
|
...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: [], uploadPending: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: [message],
|
targets: [message],
|
||||||
contextText,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -136,9 +136,11 @@ export function startPendingAIAnalysisWorker(
|
|||||||
import("./cultureLearner.js")
|
import("./cultureLearner.js")
|
||||||
.then((m) => m.startCultureLearnerWorker())
|
.then((m) => m.startCultureLearnerWorker())
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
import("./userProfileLearner.js")
|
if (config.AI_USER_PROFILE_LEARNING_ENABLED) {
|
||||||
.then((m) => m.startUserProfileLearnerWorker())
|
import("./userProfileLearner.js")
|
||||||
.catch(console.error);
|
.then((m) => m.startUserProfileLearnerWorker())
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// [D] Periodic cache hygiene: purge expired moderation verdicts from
|
// [D] Periodic cache hygiene: purge expired moderation verdicts from
|
||||||
|
|||||||
@@ -47,6 +47,40 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
|
|||||||
return "none";
|
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.
|
* Check whether a message qualifies for auto-deletion.
|
||||||
* Uses the structured `analysisResult` fields when provided, falling back
|
* Uses the structured `analysisResult` fields when provided, falling back
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.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 { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||||
|
import { verdictToActionFields } from "./verdictToActionFields.js";
|
||||||
|
|
||||||
const logger = createChildLogger("auto-delete-manager");
|
const logger = createChildLogger("auto-delete-manager");
|
||||||
|
|
||||||
@@ -15,6 +20,105 @@ export interface AutoDeleteResult {
|
|||||||
reason: string;
|
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);
|
||||||
|
// Discord rejects setNickname with "Missing Permissions" (code 50013)
|
||||||
|
// whenever the target's top role sits above the bot in the role
|
||||||
|
// hierarchy — even when the bot has MANAGE_NICKNAMES. Guard on
|
||||||
|
// `manageable` (hierarchy-aware) so we skip with a clear reason
|
||||||
|
// instead of hammering a doomed PATCH on every message.
|
||||||
|
if (!member.manageable) {
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
reason: "target role above bot in hierarchy (Discord 50013)",
|
||||||
|
},
|
||||||
|
"Nick reset skipped: member not manageable by bot",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
const errCode =
|
||||||
|
error instanceof Error && "code" in error
|
||||||
|
? (error as { code?: number | string }).code
|
||||||
|
: undefined;
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
code: errCode,
|
||||||
|
},
|
||||||
|
"Nick reset failed",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Error Handling Utilities ────────────────────────────────────────
|
// ─── Error Handling Utilities ────────────────────────────────────────
|
||||||
|
|
||||||
function getErrorCode(error: unknown): number | string | undefined {
|
function getErrorCode(error: unknown): number | string | undefined {
|
||||||
@@ -71,6 +175,7 @@ async function logAutoDeleteAttempt(
|
|||||||
guild_id: message.guild_id,
|
guild_id: message.guild_id,
|
||||||
action_type: "delete_message",
|
action_type: "delete_message",
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
|
...verdictToActionFields(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: result.deleted
|
status: result.deleted
|
||||||
? "executed"
|
? "executed"
|
||||||
@@ -107,6 +212,58 @@ export async function attemptAutoDeleteFlaggedMessage(
|
|||||||
return { deleted: false, skipped: true, reason: "disabled" };
|
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",
|
||||||
|
...verdictToActionFields(message),
|
||||||
|
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 ──────────────────────────────────────────────────
|
// ── Status gate ──────────────────────────────────────────────────
|
||||||
|
|
||||||
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* batchBudget.ts
|
||||||
|
*
|
||||||
|
* Pure batch-sizing helper extracted from batchProcessor.ts so it can be
|
||||||
|
* unit-tested without pulling in the Piscina worker pool, message store,
|
||||||
|
* or any other side-effectful import chain.
|
||||||
|
*/
|
||||||
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
|
|
||||||
|
/** Token estimator contract (satisfied by conversationContext.estimateTokens). */
|
||||||
|
export type TokenEstimator = (text: string) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a batch of messages within a token budget.
|
||||||
|
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
|
||||||
|
* The estimator is injected so this stays a pure function — callers in the
|
||||||
|
* batch pipeline pass the tiktoken-based estimateTokens.
|
||||||
|
*/
|
||||||
|
export function pickBatchWithinBudget(
|
||||||
|
messages: MessageRecord[],
|
||||||
|
maxTokens: number,
|
||||||
|
tokensPerMessage: number,
|
||||||
|
estimateTokens: TokenEstimator,
|
||||||
|
): MessageRecord[] {
|
||||||
|
const batch: MessageRecord[] = [];
|
||||||
|
let usedTokens = 0;
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const content = msg.edited_content ?? msg.content;
|
||||||
|
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
||||||
|
|
||||||
|
// Stop at the first overflow instead of skipping: input is ordered
|
||||||
|
// created_at ASC, so a contiguous chronological prefix keeps the batch
|
||||||
|
// gap-free. Skipped-over messages would leave unanalyzed holes mid-
|
||||||
|
// timeline; anything past the budget is picked up by the next wave
|
||||||
|
// (processBatch always re-schedules after success).
|
||||||
|
if (usedTokens + msgTokens > maxTokens) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
batch.push(msg);
|
||||||
|
usedTokens += msgTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { config } from "../../shared/config/config.js";
|
|||||||
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
|
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
||||||
import { workerPool } from "./circuitBreaker.js";
|
import { workerPool } from "./circuitBreaker.js";
|
||||||
import { estimateTokens } from "./conversationContext.js";
|
import { estimateTokens } from "./conversationContext.js";
|
||||||
import {
|
import {
|
||||||
@@ -39,30 +40,21 @@ export let activeRequests = 0;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Picks a batch of messages within a token budget.
|
* Picks a batch of messages within a token budget.
|
||||||
* `tokensPerMessage` accounts for JSON structure overhead around each entry.
|
* Thin wrapper over the pure helper in batchBudget.ts (kept here so the
|
||||||
* Uses a rough character-based token estimate (avoids async formatMessageForPrompt
|
* existing import surface stays stable); passes the tiktoken-based
|
||||||
* since this function runs in a synchronous promise chain).
|
* estimateTokens. See batchBudget.ts for the overflow-stopping semantics.
|
||||||
*/
|
*/
|
||||||
export function pickBatchWithinBudget(
|
export function pickBatchWithinBudget(
|
||||||
messages: MessageRecord[],
|
messages: MessageRecord[],
|
||||||
maxTokens: number,
|
maxTokens: number,
|
||||||
tokensPerMessage: number,
|
tokensPerMessage: number,
|
||||||
): MessageRecord[] {
|
): MessageRecord[] {
|
||||||
const batch: MessageRecord[] = [];
|
return pickBatchWithinBudgetPure(
|
||||||
let usedTokens = 0;
|
messages,
|
||||||
|
maxTokens,
|
||||||
for (const msg of messages) {
|
tokensPerMessage,
|
||||||
const content = msg.edited_content ?? msg.content;
|
estimateTokens,
|
||||||
// Accurate token count via tiktoken (+ overhead for JSON structure)
|
);
|
||||||
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
|
||||||
|
|
||||||
if (usedTokens + msgTokens <= maxTokens) {
|
|
||||||
batch.push(msg);
|
|
||||||
usedTokens += msgTokens;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return batch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -128,30 +120,6 @@ export async function skipAgeRestrictedMessages(
|
|||||||
// Batch pipeline
|
// Batch pipeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function postBatchReputationUpdate(rows: MessageRecord[]): Promise<void> {
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.ai_status === "clean") {
|
|
||||||
import("./userReputationStore.js")
|
|
||||||
.then((store) => store.recordCleanMessage(row.user_id, row.guild_id))
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error({ error: e }, "Failed to record clean message streak"),
|
|
||||||
);
|
|
||||||
} else if (row.ai_status === "flagged" && row.ai_severity !== "none") {
|
|
||||||
import("./userReputationStore.js")
|
|
||||||
.then((store) =>
|
|
||||||
store.recordInfraction(
|
|
||||||
row.user_id,
|
|
||||||
row.guild_id,
|
|
||||||
row.ai_severity as "low" | "medium" | "high" | "critical",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error({ error: e }, "Failed to record infraction penalty"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processBatch(
|
export async function processBatch(
|
||||||
conversationKey: string,
|
conversationKey: string,
|
||||||
messages: MessageRecord[],
|
messages: MessageRecord[],
|
||||||
@@ -196,21 +164,6 @@ export async function processBatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-batch reputation updates (fire-and-forget)
|
|
||||||
postBatchReputationUpdate(
|
|
||||||
result.rows.filter((r) => {
|
|
||||||
if (r.ai_status === "error") {
|
|
||||||
try {
|
|
||||||
const flags = JSON.parse(r.ai_moderation_flags ?? "[]") as string[];
|
|
||||||
return !flags.includes("analysis_api_failed");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
recordConversationBatchFailure(conversationKey);
|
recordConversationBatchFailure(conversationKey);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* cacheStore.ts
|
||||||
|
*
|
||||||
|
* Shared Redis cache used by the AI-moderation modules (term glossary, etc.).
|
||||||
|
*
|
||||||
|
* Extracted when SearXNG was removed (replaced by the Wikipedia adapter in
|
||||||
|
* wikipediaClient.ts). The cache was never SearXNG-specific — it is a generic
|
||||||
|
* namespaced key/value store with graceful degradation when Redis is
|
||||||
|
* unavailable. Other modules import `makeCacheKey`, `cacheGet`, `cacheSet`,
|
||||||
|
* and `initCacheStore` instead of reaching into a search module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Redis from "ioredis";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const log = createChildLogger("cache-store");
|
||||||
|
|
||||||
|
const CACHE_PREFIX = "gmw:";
|
||||||
|
const CACHE_TTL = 86400; // 24 hours (used as a sane default)
|
||||||
|
|
||||||
|
let redis: Redis | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the shared Redis connection for the moderation cache.
|
||||||
|
* Safe to call multiple times — only creates one connection.
|
||||||
|
* Degrades gracefully to `null` (no-cache) when Redis is unavailable.
|
||||||
|
*/
|
||||||
|
export function initCacheStore(redisUrl: string): void {
|
||||||
|
if (redis) return;
|
||||||
|
redis = new Redis(redisUrl, {
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
retryStrategy(times) {
|
||||||
|
const delay = Math.min(times * 200, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
lazyConnect: true,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
});
|
||||||
|
redis.on("error", (err) => {
|
||||||
|
log.warn({ err: err.message }, "Cache Redis error");
|
||||||
|
});
|
||||||
|
redis.connect().catch(() => {
|
||||||
|
log.warn("Cache Redis unavailable — falling back to no-cache");
|
||||||
|
redis = null;
|
||||||
|
});
|
||||||
|
log.info("Cache Redis initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exposes the shared Redis connection; null when Redis is unavailable. */
|
||||||
|
export function getCacheRedis(): Redis | null {
|
||||||
|
return redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a namespaced cache key (shared across modules). */
|
||||||
|
export function makeCacheKey(namespace: string, key: string): string {
|
||||||
|
return `${CACHE_PREFIX}${namespace}:${key.toLowerCase().trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a value from the cache; null on miss/unavailable. */
|
||||||
|
export async function cacheGet(key: string): Promise<string | null> {
|
||||||
|
if (!redis) return null;
|
||||||
|
try {
|
||||||
|
return await redis.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a value to the cache, fire-and-forget. */
|
||||||
|
export function cacheSet(key: string, value: string, ttlSeconds: number): void {
|
||||||
|
if (!redis) return;
|
||||||
|
redis.setex(key, ttlSeconds, value).catch(() => {
|
||||||
|
// Cache write failed silently
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default TTL (exposed for callers that want the standard window). */
|
||||||
|
export const DEFAULT_CACHE_TTL = CACHE_TTL;
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../message-capture/messageMetadata.js";
|
} from "../message-capture/messageMetadata.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||||
|
import { escapeXml, resolveDisplayName } from "./moderationBuilders.js";
|
||||||
|
|
||||||
const logger = createChildLogger("conversationContext");
|
const logger = createChildLogger("conversationContext");
|
||||||
|
|
||||||
@@ -13,6 +14,26 @@ export interface ConversationContextInput {
|
|||||||
contextBefore: MessageRecord[];
|
contextBefore: MessageRecord[];
|
||||||
targets: MessageRecord[];
|
targets: MessageRecord[];
|
||||||
maxTokens: number;
|
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;
|
let _encoder: ReturnType<typeof encodingForModel> | null = null;
|
||||||
@@ -103,26 +124,143 @@ export function formatMessageForPrompt(
|
|||||||
msg: MessageRecord,
|
msg: MessageRecord,
|
||||||
label: "context" | "target",
|
label: "context" | "target",
|
||||||
): string {
|
): string {
|
||||||
const content = sanitizeDiscordTokens(
|
const content = truncateContextLine(
|
||||||
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
sanitizeDiscordTokens(
|
||||||
|
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const timestamp = formatTimestamp(msg.created_at);
|
const timestamp = formatTimestamp(msg.created_at);
|
||||||
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
|
||||||
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
|
||||||
const refInfo = formatReferenceInfo(msg);
|
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.
|
* 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(
|
export function buildConversationContext(
|
||||||
input: ConversationContextInput,
|
input: ConversationContextInput,
|
||||||
): string[] {
|
): ConversationContextResult {
|
||||||
const { contextBefore, targets, maxTokens } = input;
|
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) =>
|
const targetLines = targets.map((msg) =>
|
||||||
formatMessageForPrompt(msg, "target"),
|
formatMessageForPrompt(msg, "target"),
|
||||||
);
|
);
|
||||||
@@ -131,7 +269,7 @@ export function buildConversationContext(
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextLines = contextBefore.map((msg) =>
|
const contextLines = gatedNewestFirst.map((msg) =>
|
||||||
formatMessageForPrompt(msg, "context"),
|
formatMessageForPrompt(msg, "context"),
|
||||||
);
|
);
|
||||||
const selectedContextLines: string[] = [];
|
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(
|
logger.debug(
|
||||||
{
|
{
|
||||||
targetCount: targets.length,
|
targetCount: targets.length,
|
||||||
contextCount: selectedContextLines.length,
|
contextCount: selectedContextLines.length,
|
||||||
|
status,
|
||||||
|
dropped,
|
||||||
usedTokens,
|
usedTokens,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
},
|
},
|
||||||
"Conversation context built",
|
"Conversation context built",
|
||||||
);
|
);
|
||||||
return selectedContextLines;
|
return { lines: selectedContextLines, descriptor, dropped };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* fallbackResultClassifier.ts
|
||||||
|
*
|
||||||
|
* Pure classifier for the individual-fallback worker response.
|
||||||
|
*
|
||||||
|
* Bug history (2026-08-24): the worker's upload-pending race guard returned
|
||||||
|
* `{ ok: true, results: [] }` (a legacy "no results yet" signal), but the
|
||||||
|
* processor treated ANY `ok:true` as a successful moderation. Empty results
|
||||||
|
* meant nothing was written to the DB — the message stayed stuck in
|
||||||
|
* `ai_status='processing'` with nobody watching it until the 300s cleanup
|
||||||
|
* reverted it. That single gap produced the ~330-400s attachment delay
|
||||||
|
* cluster. Classification now happens in ONE pure function so every outcome
|
||||||
|
* has an explicit, testable owner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WorkerResultKind =
|
||||||
|
| "success"
|
||||||
|
| "upload_pending"
|
||||||
|
| "incomplete"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface ClassifiableWorkerResult {
|
||||||
|
ok?: boolean;
|
||||||
|
/** Upload-pending marker set by ai-analysis-worker's race guard. */
|
||||||
|
uploadPending?: boolean;
|
||||||
|
results?: Array<{ status?: string; flags?: string[] | string } | undefined>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagsOf(r: { flags?: string[] | string }): string[] {
|
||||||
|
if (!r.flags) return [];
|
||||||
|
if (Array.isArray(r.flags)) return r.flags;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(r.flags) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an individual-fallback worker response:
|
||||||
|
* - "upload_pending": explicit race-guard signal — retry shortly, NOT an error.
|
||||||
|
* - "success": at least one result and none is analysis_incomplete.
|
||||||
|
* - "incomplete": LLM ran but dropped/failed this message after retries
|
||||||
|
* (analysis_incomplete flag) — terminal exhausted path.
|
||||||
|
* - "error": anything else (ok:false, or ok:true with NO explainable
|
||||||
|
* results). The old code silently succeeded here — never again.
|
||||||
|
*/
|
||||||
|
export function classifyIndividualWorkerResult(
|
||||||
|
result: ClassifiableWorkerResult,
|
||||||
|
): WorkerResultKind {
|
||||||
|
if (result.uploadPending === true) return "upload_pending";
|
||||||
|
const results = (result.results ?? []).filter(
|
||||||
|
(r): r is NonNullable<typeof r> => Boolean(r),
|
||||||
|
);
|
||||||
|
if (results.length === 0) return "error";
|
||||||
|
if (result.ok !== true) return "error";
|
||||||
|
for (const r of results) {
|
||||||
|
const flags = flagsOf(r);
|
||||||
|
if (flags.includes("analysis_incomplete")) return "incomplete";
|
||||||
|
if ((r.status ?? "") === "") return "error";
|
||||||
|
}
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
import { getConversationKey, workerPool } from "./circuitBreaker.js";
|
import { getConversationKey, workerPool } from "./circuitBreaker.js";
|
||||||
import { fireAlert } from "./conversationState.js";
|
import { fireAlert } from "./conversationState.js";
|
||||||
|
import { classifyIndividualWorkerResult } from "./fallbackResultClassifier.js";
|
||||||
import {
|
import {
|
||||||
broadcastAnalysisCompleted,
|
broadcastAnalysisCompleted,
|
||||||
LAST_ERROR,
|
LAST_ERROR,
|
||||||
@@ -78,26 +79,82 @@ async function processIndividualFallback(
|
|||||||
message,
|
message,
|
||||||
skipNormalAnalysis: false,
|
skipNormalAnalysis: false,
|
||||||
} as unknown)) as
|
} as unknown)) as
|
||||||
| { ok: true; results: AnalysisResult[] }
|
| { ok: true; results: AnalysisResult[]; uploadPending?: boolean }
|
||||||
| { ok: false; results: AnalysisResult[]; error: string };
|
| { ok: false; results: AnalysisResult[]; error: string };
|
||||||
|
|
||||||
|
// Explicit outcome classification (2026-08-24): the old code treated any
|
||||||
|
// ok:true as a completed moderation, so the upload-pending race guard's
|
||||||
|
// empty results left messages stuck in `processing` until the 300s
|
||||||
|
// cleanup reverted them — the root cause of the ~330s attachment delays.
|
||||||
|
const kind = classifyIndividualWorkerResult(workerResult);
|
||||||
|
|
||||||
|
if (kind === "upload_pending") {
|
||||||
|
// Attachment still uploading — put the row back to `pending` and
|
||||||
|
// re-schedule this conversation immediately. The next scheduler cycle
|
||||||
|
// (~debounce 250ms) re-fetches; once upload_status flips to done the
|
||||||
|
// race guard passes and analysis proceeds. NOT an error: never touches
|
||||||
|
// the circuit breaker counters.
|
||||||
|
const revertedRows = await messageStore
|
||||||
|
.updateMessagesAIAnalysisBulk([
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
result: {
|
||||||
|
status: "pending",
|
||||||
|
flags: null,
|
||||||
|
score: null,
|
||||||
|
analysis: null,
|
||||||
|
categories: null,
|
||||||
|
severity: null,
|
||||||
|
confidence: null,
|
||||||
|
recommendedAction: null,
|
||||||
|
analyzedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.catch((dbErr: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ messageId, error: String(dbErr) },
|
||||||
|
"Failed to revert upload-pending message to pending",
|
||||||
|
);
|
||||||
|
return [] as MessageRecord[];
|
||||||
|
});
|
||||||
|
for (const row of revertedRows) {
|
||||||
|
broadcastAnalysisCompleted(row);
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{ messageId, conversationKey },
|
||||||
|
"Individual fallback: attachment upload in-flight — requeued as pending + rescheduled",
|
||||||
|
);
|
||||||
|
setImmediate(() => {
|
||||||
|
import("./batchScheduler.js")
|
||||||
|
.then((m) => m.scheduleConversationAnalysis(conversationKey))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let analysisResult: { results: AnalysisResult[] } | null = null;
|
let analysisResult: { results: AnalysisResult[] } | null = null;
|
||||||
|
|
||||||
if (workerResult.ok) {
|
if (kind === "success") {
|
||||||
const stillIncomplete = workerResult.results.some((r) =>
|
analysisResult = workerResult;
|
||||||
r.flags.includes("analysis_incomplete"),
|
} else if (kind === "incomplete") {
|
||||||
|
exhaustedOnIncomplete = true;
|
||||||
|
analysisResult = null;
|
||||||
|
} else {
|
||||||
|
// "error" — includes ok:true with unexplainable empty results (the old
|
||||||
|
// silent-success bug). Throw so it is treated as a transient failure.
|
||||||
|
throw new Error(
|
||||||
|
(workerResult as { error?: string }).error ??
|
||||||
|
"Individual worker returned no explainable results",
|
||||||
);
|
);
|
||||||
if (stillIncomplete) {
|
|
||||||
exhaustedOnIncomplete = true;
|
|
||||||
analysisResult = null;
|
|
||||||
} else {
|
|
||||||
analysisResult = workerResult;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No heuristic fallback: an incomplete/errored LLM result stays a
|
// No heuristic fallback: an incomplete/errored LLM result stays a
|
||||||
// retryable error — the recovery worker picks it up later. Producing a
|
// retryable error — the recovery worker picks it up later. Producing a
|
||||||
// regex/wordlist verdict here would reintroduce false positives.
|
// regex/wordlist verdict here would reintroduce false positives.
|
||||||
|
// (incomplete keeps its exhausted flag so the catch writes the terminal
|
||||||
|
// individual_analysis_exhausted status.)
|
||||||
if (!analysisResult) {
|
if (!analysisResult) {
|
||||||
throw new Error(`LLM analysis failed for message ${messageId}`);
|
throw new Error(`LLM analysis failed for message ${messageId}`);
|
||||||
}
|
}
|
||||||
@@ -123,33 +180,6 @@ async function processIndividualFallback(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
broadcastAnalysisCompleted(row);
|
broadcastAnalysisCompleted(row);
|
||||||
scheduleAutoDelete(row);
|
scheduleAutoDelete(row);
|
||||||
|
|
||||||
// Update reputation autonomously
|
|
||||||
if (row.ai_status === "clean") {
|
|
||||||
import("./userReputationStore.js")
|
|
||||||
.then((store) => store.recordCleanMessage(row.user_id, row.guild_id))
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error(
|
|
||||||
{ error: e },
|
|
||||||
"Failed to record clean message streak in fallback",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (row.ai_status === "flagged" && row.ai_severity !== "none") {
|
|
||||||
import("./userReputationStore.js")
|
|
||||||
.then((store) =>
|
|
||||||
store.recordInfraction(
|
|
||||||
row.user_id,
|
|
||||||
row.guild_id,
|
|
||||||
row.ai_severity as "low" | "medium" | "high" | "critical",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error(
|
|
||||||
{ error: e },
|
|
||||||
"Failed to record infraction penalty in fallback",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultSummary = analysisResult.results[0];
|
const resultSummary = analysisResult.results[0];
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export async function callModerationLLM(
|
|||||||
targetIds: string[],
|
targetIds: string[],
|
||||||
label: string,
|
label: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
// Output-side token cap. Defaults to the previous hard-coded value; batch
|
||||||
|
// callers pass a prompt-derived ceiling so small batches don't reserve a
|
||||||
|
// 16k completion budget (some routers pre-allocate KV cache per max_tokens).
|
||||||
|
maxTokens?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
results: AnalysisResult[];
|
results: AnalysisResult[];
|
||||||
raw: ChatCompletion | null;
|
raw: ChatCompletion | null;
|
||||||
@@ -75,7 +79,7 @@ export async function callModerationLLM(
|
|||||||
];
|
];
|
||||||
const completion = await llmChat({
|
const completion = await llmChat({
|
||||||
messages,
|
messages,
|
||||||
max_tokens: 16384,
|
max_tokens: maxTokens ?? 16384,
|
||||||
jsonResponse: { type: "json_object" },
|
jsonResponse: { type: "json_object" },
|
||||||
retries: 0,
|
retries: 0,
|
||||||
signal,
|
signal,
|
||||||
@@ -119,6 +123,7 @@ export async function callModerationLLM(
|
|||||||
{
|
{
|
||||||
error: state.lastParseError,
|
error: state.lastParseError,
|
||||||
contentLength: rawContent.length,
|
contentLength: rawContent.length,
|
||||||
|
contentPreview: rawContent.slice(0, 200),
|
||||||
targetIds,
|
targetIds,
|
||||||
model: config.AI_LLM_MODEL,
|
model: config.AI_LLM_MODEL,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,20 @@ const log = createChildLogger("llm-client");
|
|||||||
// Concurrency limiter for LLM API calls (inlined from concurrencyLimiter.ts)
|
// Concurrency limiter for LLM API calls (inlined from concurrencyLimiter.ts)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const llmSemaphore = pLimit(config.AI_LLM_MAX_CONCURRENT ?? 5);
|
// The limiter is cached per configured concurrency value so it can be tuned
|
||||||
|
// (env / BWS) without a code change and always reflects the current config —
|
||||||
|
// a module-level `pLimit(config.X)` would freeze the cap at import time.
|
||||||
|
let llmSemaphore = pLimit(config.AI_LLM_MAX_CONCURRENT ?? 5);
|
||||||
|
let llmSemaphoreLimit = config.AI_LLM_MAX_CONCURRENT ?? 5;
|
||||||
|
|
||||||
|
function getLlmSemaphore() {
|
||||||
|
const wanted = config.AI_LLM_MAX_CONCURRENT ?? 5;
|
||||||
|
if (wanted !== llmSemaphoreLimit) {
|
||||||
|
llmSemaphore = pLimit(wanted);
|
||||||
|
llmSemaphoreLimit = wanted;
|
||||||
|
}
|
||||||
|
return llmSemaphore;
|
||||||
|
}
|
||||||
|
|
||||||
let activeCount = 0;
|
let activeCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
@@ -30,7 +43,7 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
"Queuing LLM request",
|
"Queuing LLM request",
|
||||||
);
|
);
|
||||||
|
|
||||||
return llmSemaphore(async () => {
|
return getLlmSemaphore()(async () => {
|
||||||
pendingCount--;
|
pendingCount--;
|
||||||
activeCount++;
|
activeCount++;
|
||||||
|
|
||||||
@@ -56,7 +69,16 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
|
|||||||
*/
|
*/
|
||||||
type LLMResponseChunk = {
|
type LLMResponseChunk = {
|
||||||
choices?: Array<{
|
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 };
|
message?: { content?: string | null };
|
||||||
finish_reason?: string | null;
|
finish_reason?: string | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -67,6 +89,39 @@ type LLMResponseChunk = {
|
|||||||
finish_reason?: string;
|
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.
|
// Lazy singleton — created on first use so that config is always resolved.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -105,12 +160,78 @@ export interface LlmCallOpts {
|
|||||||
top_p?: number;
|
top_p?: number;
|
||||||
/** Force JSON output via response_format: { type: "json_object" }. */
|
/** Force JSON output via response_format: { type: "json_object" }. */
|
||||||
jsonResponse?: { 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). */
|
/** Extra retries beyond DEFAULT_RETRIES (default 2). */
|
||||||
retries?: number;
|
retries?: number;
|
||||||
/** Whether to use streaming (if true, will consume stream and return aggregated result) */
|
/** Whether to use streaming (if true, will consume stream and return aggregated result) */
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
/** Optional AbortSignal to cancel the API request */
|
/** Optional AbortSignal to cancel the API request */
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* Per-request timeout in ms. Falls back to the client-level default
|
||||||
|
* (60s) when omitted. Vision/image analysis passes a longer budget here
|
||||||
|
* so a single large-image call isn't killed early by the shared default.
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,33 +246,12 @@ export async function llmChat(
|
|||||||
const client = getClient();
|
const client = getClient();
|
||||||
if (!client) return null;
|
if (!client) return null;
|
||||||
|
|
||||||
const {
|
const { retries = DEFAULT_RETRIES, signal } = opts;
|
||||||
messages,
|
const disableThinking =
|
||||||
model = config.AI_LLM_MODEL,
|
opts.disableThinking ?? config.AI_LLM_DISABLE_THINKING;
|
||||||
max_tokens,
|
|
||||||
temperature,
|
|
||||||
top_p,
|
|
||||||
jsonResponse,
|
|
||||||
retries = DEFAULT_RETRIES,
|
|
||||||
stream,
|
|
||||||
signal,
|
|
||||||
} = opts;
|
|
||||||
|
|
||||||
const params = {
|
const params = buildLlmParams(opts, disableThinking);
|
||||||
model,
|
const model = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return retryWithBackoff(
|
return retryWithBackoff(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -161,21 +261,14 @@ export async function llmChat(
|
|||||||
) => {
|
) => {
|
||||||
const response = await client.chat.completions.create(currentParams, {
|
const response = await client.chat.completions.create(currentParams, {
|
||||||
signal,
|
signal,
|
||||||
|
...(opts.timeout ? { timeout: opts.timeout } : {}),
|
||||||
});
|
});
|
||||||
if (currentParams.stream) {
|
if (currentParams.stream) {
|
||||||
let content = "";
|
let content = "";
|
||||||
let finishReason = "stop";
|
let finishReason = "stop";
|
||||||
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
|
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
|
||||||
const choice = chunk?.choices?.[0];
|
const choice = chunk?.choices?.[0];
|
||||||
const textChunk =
|
content += extractChunkText(chunk);
|
||||||
choice?.delta?.content ||
|
|
||||||
choice?.message?.content ||
|
|
||||||
choice?.text ||
|
|
||||||
chunk?.message?.content ||
|
|
||||||
chunk?.response ||
|
|
||||||
chunk?.content ||
|
|
||||||
"";
|
|
||||||
content += textChunk;
|
|
||||||
const fr = choice?.finish_reason || chunk?.finish_reason;
|
const fr = choice?.finish_reason || chunk?.finish_reason;
|
||||||
if (fr) finishReason = fr;
|
if (fr) finishReason = fr;
|
||||||
}
|
}
|
||||||
@@ -249,6 +342,12 @@ export async function llmChat(
|
|||||||
* Convenience for vision (image/sticker/emoji) analysis.
|
* Convenience for vision (image/sticker/emoji) analysis.
|
||||||
* Returns the raw completion content (trimmed) or null.
|
* 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
|
* NOTE: retries are disabled here on purpose — visionAnalyzer.ts already
|
||||||
* wraps this call in its own 3-attempt loop with exponential backoff.
|
* 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).
|
* A second retry layer would multiply worst-case API calls (3×3=9/image).
|
||||||
@@ -257,10 +356,10 @@ export async function llmVision(
|
|||||||
promptText: string,
|
promptText: string,
|
||||||
imageUrl: { url: string },
|
imageUrl: { url: string },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const completion = await llmChat({
|
const params = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user" as const,
|
||||||
content: [
|
content: [
|
||||||
{ type: "text" as const, text: promptText },
|
{ type: "text" as const, text: promptText },
|
||||||
{ type: "image_url" as const, image_url: imageUrl },
|
{ type: "image_url" as const, image_url: imageUrl },
|
||||||
@@ -272,8 +371,26 @@ export async function llmVision(
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
stream: true, // router always streams SSE; non-stream waits for full body and times out
|
timeout: config.AI_LLM_VISION_ANALYSIS_TIMEOUT_MS ?? 60_000,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Streaming first (the router always streams SSE; a non-stream request
|
||||||
|
// waits for the full body and times out on slow models). Fallback (2026-08-24):
|
||||||
|
// large GIFs/images sometimes get their SSE stream truncated mid-flight by
|
||||||
|
// the upstream ("Stream ended before producing a non-ping SSE event") — all
|
||||||
|
// streaming retries fail identically, so retry ONCE with stream:false where
|
||||||
|
// the router assembles the complete response server-side.
|
||||||
|
let completion: Awaited<ReturnType<typeof llmChat>>;
|
||||||
|
try {
|
||||||
|
completion = await llmChat({ ...params, stream: true });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/stream ended before producing a non-ping sse/i.test(msg)) {
|
||||||
|
completion = await llmChat({ ...params, stream: false });
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!completion) return null;
|
if (!completion) return null;
|
||||||
return completion.choices[0]?.message?.content?.trim() ?? null;
|
return completion.choices[0]?.message?.content?.trim() ?? null;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
setCachedMediaAnalysis,
|
setCachedMediaAnalysis,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
import { getChannelCulture } from "./channelCultureStore.js";
|
import { getChannelCulture } from "./channelCultureStore.js";
|
||||||
|
import { estimateTokens } from "./conversationContext.js";
|
||||||
import type { RetryState } from "./llmCaller.js";
|
import type { RetryState } from "./llmCaller.js";
|
||||||
import { callModerationLLM } from "./llmCaller.js";
|
import { callModerationLLM } from "./llmCaller.js";
|
||||||
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
|
||||||
@@ -26,7 +27,7 @@ const log = createChildLogger("mediaBatchProcessor");
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export async function runMediaBatch(
|
export async function runMediaBatch(
|
||||||
targets: MessageRecord[],
|
targets: MessageRecord[],
|
||||||
contextText: string,
|
contextBlock: string,
|
||||||
attachments: AttachmentRecord[] | undefined,
|
attachments: AttachmentRecord[] | undefined,
|
||||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||||
if (!targets.length) return { results: [], raw: null };
|
if (!targets.length) return { results: [], raw: null };
|
||||||
@@ -58,14 +59,23 @@ export async function runMediaBatch(
|
|||||||
const channelCulture = channelCultureObj?.culture_summary;
|
const channelCulture = channelCultureObj?.culture_summary;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||||
const systemText = buildSystemPromptModular({
|
const systemText = buildSystemPromptModular({
|
||||||
contextText,
|
|
||||||
mode: "mixed",
|
mode: "mixed",
|
||||||
correctedExamples,
|
correctedExamples,
|
||||||
channelCulture,
|
channelCulture,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-message blocks (from prepareMediaMessage) contain the message
|
||||||
|
// content + reference/reply context only — no per-user reputation or
|
||||||
|
// profile context is injected (kept minimal per user request).
|
||||||
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
|
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 (conversation) lives in the USER payload, ordered
|
||||||
|
// oldest-first so targets come last.
|
||||||
|
const userBlocks = [
|
||||||
|
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 perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||||
const batchTimeout = Math.min(
|
const batchTimeout = Math.min(
|
||||||
@@ -78,11 +88,22 @@ export async function runMediaBatch(
|
|||||||
timeoutId.unref();
|
timeoutId.unref();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Output budget scales with the prompt (see textBatchProcessor): small
|
||||||
|
// media batches don't need the full 16k completion window.
|
||||||
|
const promptEstimate =
|
||||||
|
2000 +
|
||||||
|
estimateTokens(userContent) +
|
||||||
|
targets.reduce((sum, m) => sum + estimateTokens(m.content ?? "") + 50, 0);
|
||||||
|
const dynamicMaxTokens = Math.min(
|
||||||
|
16384,
|
||||||
|
Math.max(2048, Math.ceil(promptEstimate * 1.5)),
|
||||||
|
);
|
||||||
const result = await callModerationLLM(
|
const result = await callModerationLLM(
|
||||||
async (_state: RetryState) => ({ system: systemText, user: userContent }),
|
async (_state: RetryState) => ({ system: systemText, user: userContent }),
|
||||||
targetIds,
|
targetIds,
|
||||||
`media-batch:${targetIds.length}msgs`,
|
`media-batch:${targetIds.length}msgs`,
|
||||||
abortController.signal,
|
abortController.signal,
|
||||||
|
dynamicMaxTokens,
|
||||||
);
|
);
|
||||||
log.info(
|
log.info(
|
||||||
{ mediaCount: targets.length, resultCount: result.results.length },
|
{ mediaCount: targets.length, resultCount: result.results.length },
|
||||||
|
|||||||
@@ -7,28 +7,22 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import {
|
import {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
getCachedMediaByPhash,
|
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
upsertCachedMediaByPhash,
|
|
||||||
} from "./textCacheStore.js";
|
} from "./textCacheStore.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
acquireMediaAnalysisLock,
|
acquireMediaAnalysisLock,
|
||||||
computeImagePhash,
|
|
||||||
deleteCachedMediaAnalysis,
|
deleteCachedMediaAnalysis,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
getCachedMediaByPhash,
|
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
upsertCachedMediaByPhash,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Convenience alias for upsertCachedMediaAnalysis. */
|
/** Convenience alias for upsertCachedMediaAnalysis. */
|
||||||
|
|||||||
@@ -296,101 +296,137 @@ export async function downloadAndExtractFrame(
|
|||||||
imageMap: Map<string, MessageImagePart[]>,
|
imageMap: Map<string, MessageImagePart[]>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const log = createChildLogger("mediaAnalysis");
|
const log = createChildLogger("mediaAnalysis");
|
||||||
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
|
// Prefer the upload proxy (uploaded_url); the Discord CDN link can expire
|
||||||
if (!urlToUse) return;
|
// 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);
|
let imageBytes: Buffer | null = null;
|
||||||
try {
|
let lastStatus = 0;
|
||||||
const res = await fetch(urlToUse, { signal: controller.signal });
|
let lastError: string | null = null;
|
||||||
if (!res.ok || !res.body) return;
|
for (const urlToUse of urlCandidates) {
|
||||||
|
const { controller, clear } = createAbortControllerWithTimeout(15000);
|
||||||
let totalBytes = 0;
|
try {
|
||||||
const chunks: Uint8Array[] = [];
|
const res = await fetch(urlToUse, { signal: controller.signal });
|
||||||
const reader = res.body.getReader();
|
if (!res.ok || !res.body) {
|
||||||
while (true) {
|
lastStatus = res.status;
|
||||||
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;
|
|
||||||
log.warn(
|
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 {
|
continue;
|
||||||
// Last resort: check file extension
|
}
|
||||||
const ext = att.filename?.toLowerCase().split(".").pop();
|
|
||||||
if (ext && ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
|
let totalBytes = 0;
|
||||||
const mimeMap: Record<string, string> = {
|
const chunks: Uint8Array[] = [];
|
||||||
jpg: "image/jpeg",
|
const reader = res.body.getReader();
|
||||||
jpeg: "image/jpeg",
|
while (true) {
|
||||||
png: "image/png",
|
const { done, value } = await reader.read();
|
||||||
gif: "image/gif",
|
if (done) break;
|
||||||
webp: "image/webp",
|
if (value) {
|
||||||
bmp: "image/bmp",
|
totalBytes += value.length;
|
||||||
};
|
if (totalBytes > 10 * 1024 * 1024) {
|
||||||
resolvedMime = mimeMap[ext];
|
reader.cancel();
|
||||||
log.warn(
|
return;
|
||||||
{ attachmentId: att.id, filename: att.filename, ext },
|
}
|
||||||
"Image MIME sniff failed — using file extension fallback",
|
chunks.push(value);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
imageBytes = Buffer.concat(chunks);
|
||||||
|
break;
|
||||||
// If all fallbacks fail, still try with generic image/jpeg
|
} catch (err) {
|
||||||
if (!resolvedMime) {
|
lastError = err instanceof Error ? err.message : String(err);
|
||||||
resolvedMime = "image/jpeg";
|
|
||||||
log.warn(
|
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 } =
|
if (!imageBytes) {
|
||||||
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) {
|
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{
|
||||||
attachmentId: att.id,
|
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 {
|
return;
|
||||||
clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (candidate.customEmojiId || candidate.stickerName) {
|
||||||
const vck = candidate.customEmojiId
|
const vck = candidate.customEmojiId
|
||||||
? makeCustomEmojiCacheKey(candidate.customEmojiId)
|
? makeCustomEmojiCacheKey(candidate.customEmojiId)
|
||||||
: makeStickerCacheKey(candidate.stickerName!);
|
: makeStickerCacheKey(candidate.stickerName ?? "");
|
||||||
const cached = await getCachedMediaAnalysis(vck);
|
const cached = await getCachedMediaAnalysis(vck);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
const existing = mediaAnalysisMap.get(targetId) ?? [];
|
||||||
@@ -494,8 +530,9 @@ export async function fetchUrlInline(
|
|||||||
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${targetId}]`,
|
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${targetId}]`,
|
||||||
});
|
});
|
||||||
} else if (result.type === "text" && result.textContent) {
|
} else if (result.type === "text" && result.textContent) {
|
||||||
|
const titleAttr = result.title ? ` title="${escapeXml(result.title)}"` : "";
|
||||||
webTexts.push(
|
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 { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
import { sanitizeDiscordTokens } from "./discordTokens.js";
|
||||||
|
import { sanitizeAiContent } from "./prompts/output.js";
|
||||||
|
|
||||||
/** Simple XML-escaping for content text. */
|
/** Simple XML-escaping for content text. */
|
||||||
export function escapeXml(s: string): string {
|
export function escapeXml(s: string): string {
|
||||||
@@ -19,6 +20,101 @@ export function escapeXml(s: string): string {
|
|||||||
.replace(/"/g, """);
|
.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.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 — REMOVED (2026-08-22).
|
||||||
|
// Per-user profile/history context was stripped from the moderation prompt
|
||||||
|
// (context minimization): buildUserProfilesBlock / buildUserProfileRef /
|
||||||
|
// UserProfileEntry / buildUserHistoryXml had no remaining production callers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Returns the real text content for AI analysis, stripping fallback text
|
||||||
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
|
||||||
@@ -36,6 +132,27 @@ export function getAnalysisContent(message: MessageRecord): string {
|
|||||||
).trim();
|
).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.
|
* Builds a <reference> XML element for reply/forward/crosspost context.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user