Compare commits
229
Commits
05eb490214
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4f9d4a5c7d | ||
|
|
2c995b41d7 | ||
|
|
18dd6a56ba | ||
|
|
a690e5b63e | ||
|
|
62ffb676f9 | ||
|
|
4797aca20f | ||
|
|
42b8afd412 | ||
|
|
7575a701bd | ||
|
|
9f91155944 | ||
|
|
f20889868d | ||
|
|
aa440eda69 | ||
|
|
f251e69f51 | ||
|
|
9a2fa999bf | ||
|
|
f999be4fa0 | ||
|
|
a309570d29 | ||
|
|
2f51f94610 | ||
|
|
88484f12a9 | ||
|
|
a2542493cd | ||
|
|
f84bf723c5 | ||
|
|
24db0f19b1 | ||
|
|
ce5db6aa3c | ||
|
|
44a0358b0c | ||
|
|
d36c8777fe | ||
|
|
9fd4ded9c8 | ||
|
|
55d28dc928 | ||
|
|
02e2243a98 | ||
|
|
8528f2c73d | ||
|
|
53f26185bc | ||
|
|
a57eeb2e22 | ||
|
|
9abb09dd33 | ||
|
|
831254bb71 | ||
|
|
9718940258 | ||
|
|
d1c1f3e4a7 | ||
|
|
7513681b4b | ||
|
|
2b815e156c | ||
|
|
03d59f0738 | ||
|
|
5cc0f8a243 | ||
|
|
12c55ef486 | ||
|
|
38c27eb5bb | ||
|
|
bd044e95c3 | ||
|
|
ec64a078bf | ||
|
|
37defa5915 | ||
|
|
39421c39cb | ||
|
|
1d27f67788 | ||
|
|
d1e6f3b47a | ||
|
|
dbcf9d68f2 | ||
|
|
ef4281cd1f | ||
|
|
25f6609a9f | ||
|
|
3f199aa70d | ||
|
|
a82265f4a9 | ||
|
|
78d514b73d | ||
|
|
7d2bd75f6c | ||
|
|
b3a2f2ec10 | ||
|
|
6293d588bc | ||
|
|
2357421841 | ||
|
|
308be9f05a | ||
|
|
a0b3f7e9b2 | ||
|
|
98064d1dd9 | ||
|
|
0daee56213 | ||
|
|
762e78d6b6 | ||
|
|
6ce784471e | ||
|
|
0ef2b715c4 | ||
|
|
dfe689bdec | ||
|
|
ada7a768f8 | ||
|
|
493bca590d | ||
|
|
823b484497 | ||
|
|
891c1305f0 | ||
|
|
189ab1c1f6 | ||
|
|
9d60f00934 | ||
|
|
903e7c4aeb | ||
|
|
0771e62223 | ||
|
|
8e7ba67248 | ||
|
|
8a4024f619 | ||
|
|
ec89d64dbf | ||
|
|
6f41c22cb5 | ||
|
|
01c18b2060 | ||
|
|
1f91f99de3 | ||
|
|
6df4f306dd | ||
|
|
fc475dfbb7 | ||
|
|
dc119b5d5a | ||
|
|
2f298cfe22 | ||
|
|
7ab9a7fd2d | ||
|
|
67e432564d | ||
|
|
f2b797e6ce | ||
|
|
8480407167 | ||
|
|
1249ae81d8 | ||
|
|
60084b3cc3 | ||
|
|
0bd4369ae9 | ||
|
|
a2cda745f7 | ||
|
|
460857b4eb | ||
|
|
69213ebd75 | ||
|
|
2addfb6492 | ||
|
|
ce899e9c56 | ||
|
|
24aa2bca30 | ||
|
|
9957321423 |
+22
-10
@@ -1,5 +1,16 @@
|
||||
# Discord Bot Configuration
|
||||
# =============================================================================
|
||||
#
|
||||
# PRODUCTION ENV IS DECLARATIVE:
|
||||
# The runtime env files on the VPS (/etc/gmw/backend.env,
|
||||
# /etc/gmw/discord-gateway.env) are WRITTEN BY CI from Gitea Actions secrets
|
||||
# (BACKEND_ENV, GATEWAY_ENV) — see .gitea/workflows/deploy.yml.
|
||||
# To change production env: update the secret in Gitea repo settings
|
||||
# (Settings → Actions → Secrets), then push any commit to main. Never
|
||||
# SSH into the VPS to edit env files by hand — CI will overwrite them.
|
||||
#
|
||||
# This file documents every variable; values for production live in the
|
||||
# secrets, not here.
|
||||
|
||||
# === Discord ===
|
||||
DISCORD_TOKEN=your_bot_token_here # REQUIRED
|
||||
@@ -28,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||
|
||||
# === Webserver ===
|
||||
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001)
|
||||
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||
|
||||
# === Connection ===
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
||||
@@ -44,7 +55,7 @@ VERBOSE=false # Enable verbose/debug logging (default:
|
||||
|
||||
# === Database (PostgreSQL) ===
|
||||
# Option 1: Connection string (overrides individual params)
|
||||
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot
|
||||
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/dcbot
|
||||
|
||||
# Option 2: Individual connection parameters
|
||||
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
|
||||
@@ -56,11 +67,11 @@ POSTGRES_POOL_MIN=2 # Minimum pool connections (default: 2)
|
||||
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
||||
|
||||
# === Redis ===
|
||||
REDIS_URL=redis://localhost:6379 # Redis connection string (default: redis://localhost:6379)
|
||||
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
|
||||
|
||||
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
|
||||
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
|
||||
BACKEND_WS_URL=ws://backend:3000/ws # Backend WebSocket URL for gateway PCM streaming
|
||||
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
|
||||
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
|
||||
|
||||
# === Attachments ===
|
||||
@@ -74,13 +85,19 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
||||
# === AI Analysis ===
|
||||
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||
# AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
||||
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (default)
|
||||
AI_LLM_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute on imrnes; /api/v1 exposes OpenAI-compatible chat+embeddings)
|
||||
AI_LLM_MODEL=text # LLM text model name (default: text)
|
||||
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
||||
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
||||
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||
QDRANT_URL=http://100.121.180.82:6333 # Qdrant vector store for embeddings (semantic cache); when set, vectors are stored/searched in Qdrant instead of Postgres
|
||||
# QDRANT_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation)
|
||||
# QDRANT_API_KEY= # Qdrant API key (optional)
|
||||
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
|
||||
AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before resize (default: 1024)
|
||||
AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20)
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000)
|
||||
AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000)
|
||||
|
||||
# === AI Analysis Tuning ===
|
||||
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
|
||||
@@ -94,11 +111,6 @@ AI_ANALYSIS_PROCESSING_TIMEOUT_MS=120000 # Conversation lock timeout in ms (defa
|
||||
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT=50 # Max concurrent individual-fallback jobs (default: 50)
|
||||
AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD=50 # Consecutive errors before circuit breaker trips (default: 50)
|
||||
|
||||
# === OpenAI Moderation (optional separate provider) ===
|
||||
# OPENAI_MODERATION_API_KEY= # OpenAI API key for moderation endpoint
|
||||
# OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1 # OpenAI moderation base URL (default)
|
||||
# OPENAI_MODERATION_MODEL=omni-moderation-latest # OpenAI moderation model (default)
|
||||
|
||||
# === Auto-Delete ===
|
||||
AUTO_DELETE_FLAGGED_ENABLED=true # Enable auto-deletion of flagged messages (default: true)
|
||||
AUTO_DELETE_FLAGGED_DRY_RUN=true # Dry-run mode: log but do not delete (default: false)
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@ NODE_ENV=test
|
||||
# Use a separate database/data area for tests. It may be on the same PostgreSQL host,
|
||||
# but the database name must clearly be a test database so destructive test setup
|
||||
# cannot touch production data.
|
||||
TEST_DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test
|
||||
DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test
|
||||
TEST_DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
|
||||
DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Build & Deploy (Nix)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
service: [backend, discord-gateway, proxy]
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
run: |
|
||||
git clone https://git.imrnes.team/MythEclipse/GMW.git .
|
||||
git checkout ${{ github.sha }}
|
||||
|
||||
- name: Build & Deploy ${{ matrix.service }}
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# --- Install Nix & Build ---
|
||||
curl -fsSL https://install.determinate.systems/nix \
|
||||
| sh -s -- install linux --no-confirm --init none 2>&1
|
||||
|
||||
mkdir -p /etc/nix
|
||||
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
|
||||
|
||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||
|
||||
SERVICE="${{ matrix.service }}"
|
||||
echo "=== Building: $SERVICE ==="
|
||||
nix build ".#$SERVICE" --impure --option sandbox false 2>&1
|
||||
|
||||
STORE_PATH=$(readlink result)
|
||||
echo "=== Store path: $STORE_PATH"
|
||||
|
||||
# --- Deploy ---
|
||||
NIX_BIN="/nix/var/nix/profiles/default/bin"
|
||||
PROFILE="/nix/var/nix/profiles/gmw-$SERVICE"
|
||||
|
||||
key_file=$(mktemp /tmp/deploy-key.XXXXXX)
|
||||
printf '%s\n' "$VPS_SSH_KEY" > "$key_file"
|
||||
chmod 600 "$key_file"
|
||||
|
||||
export NIX_SSHOPTS="-i $key_file -o StrictHostKeyChecking=no"
|
||||
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH" 2>&1
|
||||
|
||||
ssh -i "$key_file" -o StrictHostKeyChecking=no \
|
||||
"${VPS_USER}@${VPS_HOST}" "
|
||||
if [ -d $PROFILE ] && [ ! -L $PROFILE ]; then
|
||||
rm -rf $PROFILE
|
||||
fi
|
||||
export PATH=\$PATH:$NIX_BIN
|
||||
nix-env --profile $PROFILE --set $STORE_PATH
|
||||
systemctl daemon-reload
|
||||
systemctl restart gmw-$SERVICE
|
||||
sleep 3
|
||||
systemctl status gmw-$SERVICE --no-pager 2>&1 | head -12
|
||||
" 2>&1
|
||||
@@ -0,0 +1,260 @@
|
||||
name: Build & Deploy (Nix)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: gmw-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
run: corepack enable && corepack prepare pnpm@11 --activate
|
||||
|
||||
- name: Install deps (backend)
|
||||
working-directory: services/backend
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck + test (backend)
|
||||
working-directory: services/backend
|
||||
run: |
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only
|
||||
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts"
|
||||
|
||||
- name: Install deps (discord-gateway)
|
||||
working-directory: services/discord-gateway
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck + test (discord-gateway)
|
||||
working-directory: services/discord-gateway
|
||||
run: |
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
./node_modules/.bin/vitest run
|
||||
|
||||
- name: Biome check (all services)
|
||||
run: |
|
||||
cd services/backend && ./node_modules/.bin/biome check src/ tests/
|
||||
cd ../discord-gateway && ./node_modules/.bin/biome check src/
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [backend, discord-gateway, proxy, frontend]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
with:
|
||||
determinate: false
|
||||
extra-conf: |
|
||||
sandbox = false
|
||||
accept-flake-config = true
|
||||
# Attic binary cache as substituter on the runner: lets CI pull the
|
||||
# prebuilt attic client (and any cached deps/builds) over HTTPS,
|
||||
# no SSH round-trip needed. extra-substituters (NOT
|
||||
# extra-trusted-substituters) is required — Determinate Nix never
|
||||
# merges trusted-* substituters for nix-store CLI clients.
|
||||
extra-substituters = https://attic.asepharyana.my.id/gmw
|
||||
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88=
|
||||
# NOTE: nix-installer-action unconditionally injects
|
||||
# 'build-provenance-tags' into /etc/nix/nix.conf (a Determinate
|
||||
# Nix-only setting). With determinate:false the runner's upstream
|
||||
# nix warns 'unknown setting build-provenance-tags' on every
|
||||
# invocation — benign, cosmetic. Switching determinate:true would
|
||||
# silence it but changes the runner's nix flavor.
|
||||
|
||||
- name: Cache Nix
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||
with:
|
||||
use-flakehub: false
|
||||
|
||||
- name: Build ${{ matrix.service }}
|
||||
id: build
|
||||
run: |
|
||||
nix build .#${{ matrix.service }} --impure --option sandbox false --print-build-logs
|
||||
STORE_PATH=$(readlink result)
|
||||
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
|
||||
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
|
||||
|
||||
- name: Setup SSH key
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
sed -i 's/\r$//' ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Push build result to Attic binary cache (attic.asepharyana.my.id) so
|
||||
# the VPS can substitute it instead of a single-stream `nix copy ssh://`.
|
||||
#
|
||||
# Fast path: push DIRECTLY from the runner to the public attic endpoint
|
||||
# (validated 2026-08-10: token auth over public HTTPS works without
|
||||
# Tailscale). This skips the ~794MB closure SSH copy to the VPS that
|
||||
# used to take 25+ minutes per new store path.
|
||||
#
|
||||
# The attic client is NOT in nixpkgs anymore and has no prebuilt
|
||||
# releases, so we pull the same prebuilt closure the VPS uses
|
||||
# (/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0, ~52MB).
|
||||
# The closure itself lives in the attic cache (pushed once from the
|
||||
# VPS), so the runner bootstraps it over HTTPS via the configured
|
||||
# extra-substituters — no SSH round-trip. If that fails we fall back
|
||||
# to `nix copy --from ssh://`, then the old VPS-hop flow (SSH copy to
|
||||
# VPS, then attic push from the VPS over Tailscale) so the deploy step
|
||||
# always has a working closure path.
|
||||
- name: Push to Attic cache
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$ATTIC_TOKEN" ]; then
|
||||
echo "ATTIC_TOKEN not set; skipping attic push"
|
||||
exit 0
|
||||
fi
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
|
||||
attic_push_vps_hop() {
|
||||
echo "Fallback: VPS-hop attic push"
|
||||
# Copy closure to VPS (fast if attic already has it via substitute)
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|
||||
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
# Push from VPS → Attic over Tailscale.
|
||||
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
|
||||
# writing the narinfo to gmw when chunks exist in the upstream
|
||||
# cache.nixos.org — leaving the path 404 on gmw so the VPS deploy's
|
||||
# nix-store --realise can't find it and falls back to ssh copy.
|
||||
# sudo: attic must read root's config (~/.config/attic), which has
|
||||
# the imrnes-ts server → Tailscale. Non-root users' configs only
|
||||
# have the public `pub` server → "Server imrnes-ts does not exist".
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|
||||
|| echo "attic push failed (non-fatal; ssh copy fallback below)"
|
||||
}
|
||||
|
||||
# ── Get an attic client on the runner ────────────────────────────
|
||||
# Order: PATH → pull the prebuilt closure from the attic cache
|
||||
# itself (extra-substituters configured in Install Nix step, HTTPS
|
||||
# only, no SSH) → pull over ssh from the VPS → VPS-hop.
|
||||
# The attic client closure is stored in the attic cache (pushed
|
||||
# once from the VPS), so the fast path never depends on SSH.
|
||||
ATTIC_BIN=""
|
||||
if command -v attic >/dev/null 2>&1; then
|
||||
ATTIC_BIN="$(command -v attic)"
|
||||
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
|
||||
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
elif nix copy --from "ssh://$VPS_USER@$VPS_HOST" "$ATTIC_DIR" 2>>/tmp/attic-bootstrap.err; then
|
||||
echo "✅ Pulled attic client from VPS over ssh"
|
||||
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||
else
|
||||
echo "attic client unavailable on runner; using VPS-hop flow"
|
||||
echo "--- bootstrap errors (stderr) ---"
|
||||
tail -5 /tmp/attic-bootstrap.err 2>/dev/null || true
|
||||
attic_push_vps_hop
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Direct push: runner → attic public endpoint ──────────────────
|
||||
# --ignore-upstream-cache-filter forces the narinfo write even when
|
||||
# the path's chunks already exist in upstream cache.nixos.org (which
|
||||
# attic would otherwise skip, leaving the path 404 on the gmw cache).
|
||||
mkdir -p "$HOME/.config/attic"
|
||||
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||
default-server = "pub"
|
||||
|
||||
[servers.pub]
|
||||
endpoint = "https://attic.asepharyana.my.id"
|
||||
token = "$ATTIC_TOKEN"
|
||||
EOF
|
||||
# Retry the direct push — a transient 502 (e.g. atticd restart,
|
||||
# Traefik blip) must not abort the whole closure upload. attic push
|
||||
# is idempotent, so re-running only uploads what's still missing.
|
||||
push_ok=""
|
||||
for attempt in 1 2 3; do
|
||||
if "$ATTIC_BIN" push pub:gmw "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||
echo "✅ Pushed $STORE_PATH to attic directly from runner"
|
||||
push_ok=1
|
||||
break
|
||||
fi
|
||||
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
if [ -z "$push_ok" ]; then
|
||||
echo "Direct attic push failed after 3 attempts; using VPS-hop flow"
|
||||
attic_push_vps_hop
|
||||
fi
|
||||
|
||||
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
|
||||
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
|
||||
- name: Deploy ${{ matrix.service }} to VPS
|
||||
run: |
|
||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
|
||||
if [ -n "${{ secrets.ATTIC_TOKEN }}" ] && ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null; then
|
||||
echo "Substituted ${{ matrix.service }} from Attic cache"
|
||||
else
|
||||
echo "Attic substitute failed; falling back to ssh copy"
|
||||
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Updating profile ==="
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
|
||||
|
||||
echo "=== Restarting service ===\n"
|
||||
ssh "$VPS_USER@$VPS_HOST" \
|
||||
"sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && for i in \$(seq 1 15); do state=\$(sudo systemctl is-active gmw-${{ matrix.service }} 2>/dev/null || echo inactive); [ \"\$state\" = \"active\" ] && break; sleep 2; done; echo \"final-state=\$state\"; [ \"\$state\" = \"active\" ]"
|
||||
echo "✅ gmw-${{ matrix.service }} deployed"
|
||||
|
||||
cleanup:
|
||||
# Bersihkan sampah Nix di VPS SETELAH semua deploy selesai: hapus generasi
|
||||
# profile lama + nix store gc. Profil yang sedang dipakai tidak disentuh.
|
||||
needs: build-and-deploy
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Nix GC on VPS
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Publish to FlakeHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
flakehub-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: DeterminateSystems/determinate-nix-action@main
|
||||
- uses: DeterminateSystems/flakehub-push@main
|
||||
with:
|
||||
visibility: public
|
||||
rolling: true
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Mirror to Gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mirror to Gitea
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/GMW.git"
|
||||
git push --mirror gitea
|
||||
echo "✅ Mirrored to Gitea (MythEclipse/GMW)"
|
||||
+1
-1
@@ -12,7 +12,7 @@ worktrees/
|
||||
.worktrees/
|
||||
services/frontend/frontend/dist/
|
||||
target/
|
||||
|
||||
nix/
|
||||
# Gitea CI runner logs
|
||||
.gitea/workflows/*.log
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
# GMW Frontend — Greenfield Rebuild (Visual-System Overhaul + Custom UI + Motion/3D)
|
||||
|
||||
> **For Hermes:** Execute with `subagent-driven-development` (one fresh subagent per task, two-stage review). Each task is 1–3 min, atomic, independently verifiable, committed after each. Reuse `src/lib/api/*`, `src/lib/ws/*`, `src/lib/types/*`, hooks verbatim. Never invent endpoints.
|
||||
|
||||
**Goal:** Rebuild the GMW Discord-automod dashboard frontend from scratch — drop shadcn/ui + glass/teal/purple aesthetic entirely, replace with a custom, distinctive design system where every page has its own visual metaphor (no uniform bordered-card grid), and push the presentation layer with **Framer Motion (`motion`) choreography + signature Three.js scenes**. Re-integrate to the EXISTING backend API + WebSocket contract (do NOT touch the backend).
|
||||
|
||||
**Architecture:** Next.js 16 App Router (SSR) + React 19 + TS strict + Tailwind v4. Keep the *plumbing* (data contract), rebuild the *skin + primitives + motion*. Each route = one self-contained `page.tsx` (server fetch + client view in the same file via a `"use client"` sibling export). Custom SVG charts (no recharts). Custom micro-primitives (no shadcn/base-ui). New token system in `globals.css`. **Motion:** `motion/react` app-wide for page transitions, spring micro-interactions, layout animation. **3D:** raw `three` (no react-three-fiber — leaner) in exactly TWO signature scenes, lazy-loaded client-only with graceful fallback. Deployed unchanged via existing flake + `gmw-proxy` nginx (`:4009` → Next `:4017`).
|
||||
|
||||
**Tech Stack:** next@16, react@19, tailwindcss@4 (`@import "tailwindcss"`), `next/font/google` (Bricolage Grotesque + Inter + JetBrains Mono), `swr` (data revalidation), `lucide-react` (icons only), `motion` (Framer Motion successor — `motion/react`), `three` + `@types/three` (signature scenes only), `clsx` + `tailwind-merge`. **Removed:** `@shadcn/react`, `@base-ui/react`, `recharts`, `shadcn` CLI, `cmdk`, `sonner`, `react-day-picker`, `embla-carousel-react`, `react-resizable-panels`, `input-otp`, all 50 `components/ui/*`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Design System (the creative core — read before coding)
|
||||
|
||||
**Persona:** Controlled UX Designer + a tactical "ops console" voice. Material honesty: hierarchy via **scale/weight/tonal blocks**, NOT borders/shadows. Per `frontend-design` skill principle — spend the boldness in ONE signature place per page, keep the rest disciplined. Motion is choreography, not confetti: **one orchestrated moment per page**, everything else quiet.
|
||||
|
||||
### Palette (warm, signal-driven — NO teal/cyan/purple/blue gradients)
|
||||
Light mode (`:root`):
|
||||
```
|
||||
--canvas: oklch(0.96 0.012 80) /* warm off-white, not cream */
|
||||
--surface: oklch(0.92 0.014 80) /* tonal block, replaces bordered card */
|
||||
--surface-2: oklch(0.88 0.016 80)
|
||||
--ink: oklch(0.22 0.02 70) /* primary text */
|
||||
--ink-soft: oklch(0.46 0.02 70) /* secondary text */
|
||||
--hairline: oklch(0.22 0.02 70 / 0.10) /* structural rules ONLY, sparse */
|
||||
--signal: oklch(0.78 0.17 125) /* lime — OK / live / primary accent */
|
||||
--signal-ink: oklch(0.20 0.03 70) /* text ON signal */
|
||||
--amber: oklch(0.80 0.15 70) /* WARN */
|
||||
--vermilion: oklch(0.62 0.21 25) /* FLAGGED / destructive */
|
||||
--ring: var(--signal)
|
||||
```
|
||||
Dark mode (`.dark`, default theme per `next-themes`):
|
||||
```
|
||||
--canvas: oklch(0.13 0.015 70) /* warm charcoal, not blue-black */
|
||||
--surface: oklch(0.18 0.02 70)
|
||||
--surface-2: oklch(0.23 0.022 70)
|
||||
--ink: oklch(0.93 0.01 75)
|
||||
--ink-soft: oklch(0.62 0.02 75)
|
||||
--hairline: oklch(1 0 0 / 0.09)
|
||||
--signal: oklch(0.88 0.18 125)
|
||||
--signal-ink: oklch(0.18 0.03 70)
|
||||
--amber: oklch(0.85 0.15 70)
|
||||
--vermilion: oklch(0.68 0.21 25)
|
||||
```
|
||||
Three semantic signals reused everywhere: **lime = OK/live, amber = warn, vermilion = flag/danger**. This kills the purple-accent + teal-primary monotony.
|
||||
|
||||
### Typography (3 roles, deliberate pairing — not "Inter everywhere")
|
||||
- **Display:** `Bricolage Grotesque` (700–800) — characterful grotesque for headers/big numbers.
|
||||
- **Body/UI:** `Inter` (400–600).
|
||||
- **Data/label:** `JetBrains Mono` (500/700) — all stats, timestamps, channel IDs, metrics.
|
||||
Load all three via `next/font/google` with CSS variables (keep current `--font-inter`/`--font-jetbrains-mono` names + add `--font-display`).
|
||||
|
||||
### Layout & signature
|
||||
- **No `card` with border.** Use tonal `--surface` blocks with generous radius (`--r: 14px`) and internal padding; separate blocks with whitespace + sparse hairlines only where structurally meaningful.
|
||||
- **Signature element = "scan-tick":** a 1px animated pulse line (CSS keyframe `scan`) that marks every live/section header — NOT a card outline. Global canvas carries a faint warm dot-grid texture (low opacity) instead of the current bluish dotted radial-gradient.
|
||||
- **Nav = left "spine":** vertical rail of icon nodes joined by a hairline; active node gets a `--signal` dot + label reveal. Collapses to a bottom tab-bar < 768px (CSS only, no JS sidebar primitive).
|
||||
- **Header = "status bar":** connection state (WS dot), guild selector, live clock — mono font, reads like an instrument readout.
|
||||
|
||||
## 0.1 Motion & 3D Layer (the "lebih kreatif" addition)
|
||||
|
||||
### Motion rules (from `motion/react`)
|
||||
- **Page transitions:** one shared `RouteTransition` in `(dashboard)/layout.tsx` — `AnimatePresence mode="popLayout"` + `motion.div key={pathname}` (fade + 8px rise + slight blur-out, ~220ms, `easeOut`). Consistent everywhere, zero per-page boilerplate.
|
||||
- **Enter choreography (per page, ONE signature moment):** staggered rise-in for the hero/ticker group using `staggerChildren` variants; afterwards, quiet springs for hover/tap (`scale: 1.03` on interactive blocks, `whileTap` on buttons).
|
||||
- **Layout animation:** `layout` prop on list items (messages rows, recording rows, queue) so add/remove/filter reflows smoothly; `layoutId` for shared-element transitions (ticker → detail modal on dashboard).
|
||||
- **Live pulse:** `motion` drives the severity ticks / speaker rings with springs, not CSS `transition` alone.
|
||||
- **`useReducedMotion()`** (from `motion/react`) gates ALL heavy motion; CSS `@media (prefers-reduced-motion: reduce)` additionally kills `scan`/`spin-disc` keyframes. Accessibility floor, non-negotiable.
|
||||
- **No scroll-jacking, no marquee loops, no per-element confetti.** One moment per page. (`frontend-design` skill: "Satu momen orkestrasi biasanya lebih mengena daripada efek tersebar.")
|
||||
|
||||
### 3D rules (raw `three`, no R3F — lean bundle)
|
||||
- Exactly **two** scenes, chosen because they carry real data meaning: **Dashboard hero** (`SignalField` — a particle field whose pulse density reflects live activity) and **Voice page** (`OrbField` — speakers as glowing orbs whose height/ring radius reacts to who is speaking). Everything else stays 2D/motion.
|
||||
- **Lazy + client-only:** `next/dynamic(() => import("./SignalField"), { ssr: false, loading: () => <StaticFallback/> })`. Three ships in its own chunk, loaded only on those two routes.
|
||||
- **WebGL guard:** if `!window.WebGLRenderingContext` or context creation fails → render the static SVG/CSS fallback (a stylized 2D version of the same visual). Never blank.
|
||||
- **Perf guardrails:** `dpr: [1, 1.75]`, `powerPreference: "high-performance"`, `antialias: true`; RAF loop paused on `document.hidden`; `dispose()` geometries/materials on unmount; particle count capped by `navigator.hardwareConcurrency` + viewport (`Math.min(900, w*h/2000)`).
|
||||
- **Style:** warm palette ONLY — signal-lime particles, amber/vermilion for flag/warn states; fog + soft additive blending for glow (no harsh white lights, no metallic PBR).
|
||||
- **Interactivity:** subtle pointer parallax (camera lerp toward cursor) + gentle idle rotation. No drag/drop, no raycasting menus.
|
||||
|
||||
### Per-page metaphor (kills monotony — each page feels different)
|
||||
| Route | Metaphor | Signature visual | Motion / 3D |
|
||||
|---|---|---|---|
|
||||
| `/dashboard` | Live ops overview | **3D signal particle field** hero + asymmetric ticker blocks + radial moderation gauge | **3D SignalField** (reacts to activity), staggered ticker rise-in, gauge draws on mount |
|
||||
| `/messages` | Transcript | Left channel **timeline spine**; right = flowing message entries with left **severity tick** (no bordered cards); search = command palette | `layout` on message rows, spring severity ticks, palette types in |
|
||||
| `/voice` | Stage | **3D orb field** of speakers + equalizer rings; activity = horizontal **session ribbon** | **3D OrbField** (speaking → orb rises + ring pulses), session ribbon draws sequentially |
|
||||
| `/media` | Turntable | Rotating **disc** now-playing; queue = borderless list | CSS 3D disc spin (spring on play/pause), queue `layout` reflow, progress bar springs |
|
||||
| `/recordings` | Tape library | Rows with **waveform thumbnail** (custom SVG from duration) | Waveform bars spring on hover; new upload animates in (AnimatePresence) |
|
||||
| `/moderation` | Security log | Vertical **event flow** with status nodes (dot + line), not a table of cards | Nodes pulse on live action; timeline draws in sequence |
|
||||
| `/analysis` | Query console | Terminal-style search panel | Typing cursor + results stagger |
|
||||
|
||||
---
|
||||
|
||||
## 1. What to KEEP (reuse verbatim — correct + integrates to backend)
|
||||
- `src/lib/api/server.ts` — 11 server fetchers (`getDashboardStats`, `getActivity`, `getMediaStatus`, `getConfig`, `getModerationStats/Actions`, `getGuilds`, `getVoiceStatus`, `getRecordings`, `getMessages`). **No change.**
|
||||
- `src/lib/api/client.ts` — `apiRequest` + `ApiError`. **No change.**
|
||||
- `src/lib/ws/*` — `connection.ts`, `context.tsx`, `types.ts` (17 typed events: `message_created/updated/deleted/analyzed`, `voice_*`, `media_state`, `voice_pcm_data` binary). **No change.**
|
||||
- `src/lib/types/*` — all interfaces. **No change.**
|
||||
- `src/lib/format.ts`, `src/lib/utils.ts` (`cn`). **No change.**
|
||||
- `src/lib/navigation.ts` — `navItems`. Keep but extend icons/labels if needed.
|
||||
- Hooks: `src/hooks/*` (use-dashboard, use-media, use-messages, use-voice, use-moderation, use-recordings, use-guilds, use-config, use-chatbot-user, use-action, use-mobile), `src/lib/hooks/use-mounted.ts`. **Reuse** (verify no bad imports into deleted barrels).
|
||||
- Feature logic kept but **reskinned**: `src/components/media/music-player.tsx`, `src/components/voice/*`, `src/components/chatbot/*`, `src/components/messages/*`, `src/components/recordings/*`, `src/components/moderation/moderation-section.tsx`, `src/components/analysis/search-panel.tsx`, `src/components/dashboard/*` (charts → rewritten as custom SVG).
|
||||
|
||||
## 2. What to DELETE
|
||||
- `src/components/ui/*` (all 50 shadcn primitives).
|
||||
- `components.json`, `@shadcn/react` + `@base-ui/react` + `shadcn` deps.
|
||||
- `recharts` (replace with custom SVG chart helpers in `src/components/charts/`).
|
||||
- `src/app/globals.css` → rewrite (no `@import "shadcn/tailwind.css"`, no `--color-primary` teal, no `.glass*`, no `.text-gradient`/`.gradient-border` teal/purple, no bluish body texture).
|
||||
- `src/components/layout/app-sidebar.tsx` (shadcn Sidebar) → replace with custom `Spine` nav.
|
||||
- All `page.tsx`+`view.tsx` pairs → merge into single `page.tsx` per route.
|
||||
|
||||
## 3. What to BUILD (new)
|
||||
- New `globals.css` (tokens above + utilities + keyframes `scan`, `eq`, `fade-up`, `spin-disc`).
|
||||
- `src/components/primitives/` — minimal custom: `Button`, `Input`, `Select`, `Dialog`, `Tooltip`, `Badge`, `Progress`, `Avatar`, `Skeleton`, `Toast`, `Sheet`.
|
||||
- `src/components/motion/` — `RouteTransition.tsx`, `Stagger.tsx`, `variants.ts`.
|
||||
- `src/components/three/` — `SignalField.tsx`, `OrbField.tsx`, `WebGLGuard.tsx`, `StaticFallback.tsx`, `useThreeScene.ts`.
|
||||
- `src/components/charts/` — `Sparkline`, `AreaActivity`, `RadialGauge`, `SessionRibbon`, `Waveform`.
|
||||
- `src/components/layout/` — `Spine.tsx`, `StatusBar.tsx`, `ThemeToggle.tsx` (reskin).
|
||||
- 7 merged `page.tsx` files (one per route) implementing the metaphors above.
|
||||
- `src/app/layout.tsx` — root (fonts + ThemeProvider + Toaster), `src/app/(dashboard)/layout.tsx` — providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot, `src/app/page.tsx` → redirect `/dashboard`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target File & Folder Structure (authoritative)
|
||||
|
||||
Everything below `services/frontend/src/` is the new tree. `REWRITE` replaces existing; `NEW` creates; `DELETE` removes. The `app/` route tree collapses `page.tsx`+`view.tsx` into single `page.tsx` files containing BOTH server fetch (default export) and client view (`"use client"` named export in same file).
|
||||
|
||||
```
|
||||
services/frontend/
|
||||
├─ package.json REWRITE (drop shadcn/base-ui/recharts; add motion, three, @types/three)
|
||||
├─ pnpm-workspace.yaml REWRITE (onlyBuiltDependencies: keep build list minimal)
|
||||
├─ components.json DELETE (shadcn registry config — no longer used)
|
||||
├─ next.config.ts KEEP (output: standalone, trailingSlash, images.unoptimized)
|
||||
├─ tsconfig.json KEEP (paths "@/*" → src/*, strict)
|
||||
├─ postcss.config.mjs KEEP (@tailwindcss/postcss)
|
||||
├─ biome.json KEEP
|
||||
└─ src/
|
||||
├─ app/
|
||||
│ ├─ layout.tsx REWRITE (3 fonts + ThemeProvider + custom Toaster; rm sonner)
|
||||
│ ├─ globals.css REWRITE (new token system §0; rm glass/teal/purple)
|
||||
│ ├─ page.tsx KEEP (redirect → /dashboard/)
|
||||
│ └─ (dashboard)/
|
||||
│ ├─ layout.tsx REWRITE (providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot; rm shadcn Sidebar)
|
||||
│ ├─ dashboard/ page.tsx REWRITE (server fetch + <DashboardView/> client; 3D SignalField hero)
|
||||
│ ├─ messages/ page.tsx REWRITE (server seeds + <MessagesView/>; spine + severity ticks)
|
||||
│ ├─ voice/ page.tsx REWRITE (server seeds + <VoiceView/>; 3D OrbField hero)
|
||||
│ ├─ media/ page.tsx REWRITE (server seeds + <MediaView/>; turntable disc)
|
||||
│ ├─ recordings/ page.tsx REWRITE (server seeds + <RecordingsView/>; waveform rows)
|
||||
│ ├─ moderation/ page.tsx REWRITE (server seeds + <ModerationView/>; event-flow)
|
||||
│ └─ analysis/ page.tsx REWRITE (client <AnalysisView/>; query console)
|
||||
├─ components/
|
||||
│ ├─ ui/ DELETE (all 50 shadcn primitives)
|
||||
│ ├─ primitives/ NEW (Button, Input, Select, Dialog, Tooltip, Badge, Progress, Avatar, Skeleton, Toast, Sheet, index.ts)
|
||||
│ ├─ motion/ NEW (variants.ts, Stagger.tsx, RouteTransition.tsx)
|
||||
│ ├─ three/ NEW (WebGLGuard, SignalField, OrbField, StaticFallback, useThreeScene)
|
||||
│ ├─ charts/ NEW (Sparkline, AreaActivity, RadialGauge, SessionRibbon, Waveform)
|
||||
│ ├─ layout/ REWRITE (Spine NEW, StatusBar NEW, ThemeToggle REWRITE, app-sidebar DELETE)
|
||||
│ ├─ dashboard/ REWRITE (stat-card DELETE; activity-chart/hourly/top-channels/moderation-donut/users/channels/reactions REWRITE)
|
||||
│ ├─ messages/ REWRITE (message-card DELETE; message-list/detail/detail-view/ai-status-badge/ai-analysis-panel/attachments-grid/lightbox/search-overlay REWRITE)
|
||||
│ ├─ voice/ REWRITE (voice-connection-card/connection-card/microphone-card DELETE; speaker-waveform/active-speakers-panel/activity-timeline/mic-control/listen-control REWRITE)
|
||||
│ ├─ media/ REWRITE (music-player, mini-player REWRITE)
|
||||
│ ├─ recordings/ REWRITE (recording-card DELETE; recording-player REWRITE)
|
||||
│ ├─ moderation/ REWRITE (moderation-section REWRITE)
|
||||
│ ├─ analysis/ REWRITE (search-panel REWRITE)
|
||||
│ ├─ chatbot/ REWRITE (chatbot-container, chat-panel REWRITE; chatbot-context, index KEEP)
|
||||
│ └─ shared/ REWRITE (empty-state, error-state, loading-skeleton, error-boundary, guild-selector REWRITE; index KEEP)
|
||||
├─ hooks/ KEEP (verify no bad imports)
|
||||
├─ lib/
|
||||
│ ├─ api/ KEEP (server.ts, client.ts, index.ts)
|
||||
│ ├─ ws/ KEEP (connection.ts, context.tsx, types.ts, ws-hook.ts)
|
||||
│ ├─ types/ KEEP (all interfaces)
|
||||
│ ├─ hooks/ KEEP (use-media-player.tsx, use-mounted.ts)
|
||||
│ ├─ audio/ KEEP (voice PCM decode helpers if present)
|
||||
│ ├─ format.ts KEEP
|
||||
│ ├─ utils.ts KEEP (cn)
|
||||
│ └─ navigation.ts KEEP
|
||||
└─ (public assets) KEEP
|
||||
```
|
||||
|
||||
### 4.1 Single-file page pattern (mandatory)
|
||||
```tsx
|
||||
// server component (default export) — runs on the server, fetches initial data
|
||||
import { getX, getY } from "@/lib/api/server";
|
||||
import { XView } from "./page"; // self-import of the named client export
|
||||
|
||||
export default async function Page() {
|
||||
const [a, b] = await Promise.allSettled([getX(), getY()]);
|
||||
return <XView initialA={a.status === "fulfilled" ? a.value : undefined}
|
||||
initialB={b.status === "fulfilled" ? b.value : undefined} />;
|
||||
}
|
||||
|
||||
// client component (named export) — hydrated, takes initialData as SWR fallback
|
||||
"use client";
|
||||
export function XView({ initialA, initialB }: Props) {
|
||||
const { data } = useX(initialA); // SWR fallbackData = initialA
|
||||
}
|
||||
```
|
||||
Self-referencing the named export keeps the file single-artifact while satisfying Next's RSC boundary (default = server, named = client). Tabs live inside `XView`.
|
||||
|
||||
### 4.2 Import rules (lint gate)
|
||||
- No `@/components/ui/*` (deleted) — all UI via `@/components/primitives`.
|
||||
- `three` only imported inside `src/components/three/*`; pages import those via `next/dynamic({ ssr: false })`.
|
||||
- `motion` imported from `motion/react` only.
|
||||
- All data: `@/lib/api/server` (server) / `@/lib/api/client` (client) — never invented endpoints.
|
||||
|
||||
---
|
||||
|
||||
## TASKS (granular — every file is its own task)
|
||||
|
||||
### PHASE 0 — Dependency surgery
|
||||
- **T0.1** Edit `package.json`: remove `dependencies["@base-ui/react"]`.
|
||||
- **T0.2** Remove `dependencies["@shadcn/react"]`.
|
||||
- **T0.3** Remove `dependencies["shadcn"]`.
|
||||
- **T0.4** Remove `dependencies["recharts"]`.
|
||||
- **T0.5** Remove `dependencies["cmdk"]`.
|
||||
- **T0.6** Remove `dependencies["sonner"]`.
|
||||
- **T0.7** Remove `dependencies["react-day-picker"]`.
|
||||
- **T0.8** Remove `dependencies["embla-carousel-react"]`.
|
||||
- **T0.9** Remove `dependencies["react-resizable-panels"]`.
|
||||
- **T0.10** Remove `dependencies["input-otp"]`.
|
||||
- **T0.11** Add `dependencies["motion"]: "^12.0.0"`, `dependencies["three"]: "^0.180.0"`, `devDependencies["@types/three"]: "^0.180.0"`.
|
||||
- **T0.12** `rm -f pnpm-lock.yaml && pnpm install` (regenerate lockfile).
|
||||
- **T0.13** Verify `pnpm ls recharts @shadcn/react @base-ui/react` → empty; `pnpm ls motion three @types/three` → present.
|
||||
- **T0.14** `cat pnpm-workspace.yaml`: confirm `onlyBuiltDependencies` keeps needed native builds, no broken shadcn postinstall.
|
||||
|
||||
### PHASE 1 — Design tokens (globals.css)
|
||||
- **T1.1** Rewrite `@theme { }` head: light `:root` palette from §0 (canvas/surface/ink/ink-soft/hairline/signal/signal-ink/amber/vermilion/ring).
|
||||
- **T1.2** Add radius tokens `--r: 14px`, `--r-panel: 12px`, `--r-control: 8px`, `--r-pill: 9999px`.
|
||||
- **T1.3** Add `--font-display` token; keep `--font-sans`/`--font-mono`.
|
||||
- **T1.4** Add `.dark { }` override block with §0 dark values (warm charcoal).
|
||||
- **T1.5** Replace `@layer base body` bg: warm dot-grid `radial-gradient(oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px)` + faint warm glow; remove old bluish radial layers.
|
||||
- **T1.6** Retint scrollbar thumb to warm `oklch(0.4 0.02 70 / 0.2)`; keep `::selection` signal-tinted.
|
||||
- **T1.7** Delete `.glass`, `.glass-elevated`, `.glass-intense`, `.dark .glass-intense` utilities.
|
||||
- **T1.8** Delete `.text-gradient` and `.gradient-border`.
|
||||
- **T1.9** Add `.surface` utility (bg var(--surface), radius var(--r), padding).
|
||||
- **T1.10** Add `.scan-tick` (1px animated pulse line, keyframe `scan`).
|
||||
- **T1.11** Add `.ticker`, `.pill`, `.mono` utilities.
|
||||
- **T1.12** Add keyframes `scan`, `eq`, `fade-up`, `spin-disc` (keep used existing ones if still referenced).
|
||||
- **T1.13** Add `@media (prefers-reduced-motion: reduce)` kill switch for scan/eq/spin-disc/pulse-ring/shimmer.
|
||||
- **T1.14** Remove `@import "shadcn/tailwind.css";` (line 3); verify nothing else depends on shadcn CSS vars.
|
||||
- **T1.15** Verify `grep -c "0.52 0.17 215\|0.55 0.2 280" src/app/globals.css` → `0`.
|
||||
- **T1.16** `pnpm biome check src/app/globals.css` → no errors.
|
||||
|
||||
### PHASE 2 — Root layout + fonts
|
||||
- **T2.1** In `layout.tsx` add `Bricolage_Grotesque` (`variable: "--font-display"`, subsets `["latin"]`, `display: "swap"`).
|
||||
- **T2.2** Apply `inter.variable`, `jetbrainsMono.variable`, `bricolage.variable` to `<html>`.
|
||||
- **T2.3** Remove `import { Toaster } from "@/components/ui/sonner"`.
|
||||
- **T2.4** Comment out `<Toaster />` temporarily (re-enabled after T3.10).
|
||||
- **T2.5** Keep `suppressHydrationWarning`, `ThemeProvider` (defaultTheme dark, enableSystem false).
|
||||
- **T2.6** `npx tsc --noEmit` (fonts only; rest may still error until primitives exist).
|
||||
|
||||
### PHASE 3 — Custom primitives (replace 50 shadcn ui)
|
||||
- **T3.1** `primitives/Button.tsx`: `motion.button`, variants `primary`/`ghost`/`danger`, `cn()` merge, `cursor-pointer`, `focus-visible:ring-2 ring-signal`, `whileTap` scale 0.97 gated by `useReducedMotion()`.
|
||||
- **T3.2** `primitives/Input.tsx`: native `<input>`, `bg-surface`, `rounded-[var(--r-control)]`, `mono` prop.
|
||||
- **T3.3** `primitives/Select.tsx`: native `<select>` styled, `bg-surface`.
|
||||
- **T3.4** `primitives/Dialog.tsx`: native `<dialog>` + `showModal()`, warm `::backdrop`, `AnimatePresence`, `onClose`.
|
||||
- **T3.5** `primitives/Tooltip.tsx`: CSS group-hover popover.
|
||||
- **T3.6** `primitives/Badge.tsx`: tonal pill, `variant` → `bg-{tone}/15 text-{tone}` (signal/amber/vermilion/neutral).
|
||||
- **T3.7** `primitives/Progress.tsx`: SVG track + `motion` fill, `value`/`max`, signal color.
|
||||
- **T3.8** `primitives/Avatar.tsx`: `<img>` + initials fallback, signal bg, size prop.
|
||||
- **T3.9** `primitives/Skeleton.tsx`: shimmer block (signal-tinted), `aria-hidden`.
|
||||
- **T3.10** `primitives/Toast.tsx`: `ToastProvider` context + portal, `useToast()`, motion slide-in, auto-dismiss.
|
||||
- **T3.11** `primitives/Sheet.tsx`: mobile drawer (`translate-x` spring), overlay, `open`/`onClose`.
|
||||
- **T3.12** `primitives/index.ts` re-export all 11.
|
||||
- **T3.13** Re-enable `<Toaster />` in `layout.tsx` (T2.4).
|
||||
- **T3.14** Verify `npx tsc --noEmit` on primitives; `grep -rl "@/components/ui/" src/components/primitives` → empty.
|
||||
|
||||
### PHASE 4 — Motion foundation
|
||||
- **T4.1** `motion/variants.ts`: export `spring`, `ease`, `fadeUp`, `stagger` (per §0.1).
|
||||
- **T4.2** `motion/Stagger.tsx`: `StaggerGroup` + `StaggerItem` (`"use client"`).
|
||||
- **T4.3** `motion/RouteTransition.tsx`: `"use client"`, `usePathname`, `AnimatePresence mode="popLayout"`, reduced-motion fallback to plain `<div>`.
|
||||
- **T4.4** Verify `npx tsc --noEmit` on motion; `motion/react` import resolves.
|
||||
|
||||
### PHASE 5 — Custom SVG charts (replace recharts)
|
||||
- **T5.1** `charts/Sparkline.tsx`: `<svg>` polyline from `points:number[]`, signal stroke, no axes.
|
||||
- **T5.2** `charts/AreaActivity.tsx`: filled `<path>` area, low-opacity signal gradient, `pathLength` draw gated by reduced-motion.
|
||||
- **T5.3** `charts/RadialGauge.tsx`: `<circle>` arc `stroke-dasharray`, center mono label.
|
||||
- **T5.4** `charts/SessionRibbon.tsx`: horizontal segments per speaker duration.
|
||||
- **T5.5** `charts/Waveform.tsx`: bars from deterministic seed, spring scaleY on hover.
|
||||
- **T5.6** Verify `npx tsc --noEmit` on charts; no `recharts` import.
|
||||
|
||||
### PHASE 6 — Three.js foundation (lazy, guarded)
|
||||
- **T6.1** `three/WebGLGuard.tsx`: `"use client"`, detect webgl2/webgl, render `children` or `fallback`.
|
||||
- **T6.2** `three/useThreeScene.ts`: shared hook — renderer init (`dpr:[1,1.75]`, `powerPreference`), RAF with `document.hidden` pause, `dispose()` on unmount, resize observer.
|
||||
- **T6.3** `three/SignalField.tsx`: `Points` BufferGeometry (~min(900, w*h/2000)), additive blend, signal-lime, fog, idle rotation + sine drift, `activity` prop, pointer parallax. Uses `useThreeScene`.
|
||||
- **T6.4** `three/OrbField.tsx`: per-speaker `Sphere`, y-scale + ring lerp to speaking, tones signal/idle/vermilion.
|
||||
- **T6.5** `three/StaticFallback.tsx`: 2D SVG/CSS silhouette for both scenes.
|
||||
- **T6.6** Verify `grep -rl "from \"three\"" src | grep -v "components/three"` → empty; `npx tsc --noEmit` on three.
|
||||
|
||||
### PHASE 7 — Layout shell
|
||||
- **T7.1** `layout/Spine.tsx`: `"use client"`, vertical rail from `navItems`, icon node + hairline, active signal dot + label reveal (motion spring), `max-md:` bottom tab-bar.
|
||||
- **T7.2** `layout/StatusBar.tsx`: `"use client"`, page title + WS status dot (motion pulse) + `GuildSelector` + live clock (mono) + `ThemeToggle`.
|
||||
- **T7.3** `layout/ThemeToggle.tsx`: restyle, keep `next-themes` logic, motion icon swap.
|
||||
- **T7.4** Rewrite `(dashboard)/layout.tsx`: keep SWRConfig/WsProvider/MediaPlayerProvider/ChatbotProvider + sync functions verbatim; swap `AppSidebar`→`Spine`, header→`StatusBar`, wrap children in `RouteTransition`; remove `SidebarInset`/`SidebarTrigger`/`Separator`; keep MiniPlayer+ChatbotContainer.
|
||||
- **T7.5** Delete `layout/app-sidebar.tsx`.
|
||||
- **T7.6** Verify `grep -rl "components/ui/sidebar\|app-sidebar" src` → empty; `npx tsc --noEmit`.
|
||||
|
||||
### PHASE 8 — Dashboard page + components
|
||||
- **T8.1** Rewrite `dashboard/page.tsx`: default async `getDashboardStats`+`getActivity` → `<DashboardView>`; named `"use client"` view with useStats/useActivity, 3D hero + tickers + tabs.
|
||||
- **T8.2** Add `WebGLGuard`+`SignalField` hero with `activity` ratio; overlay headline (Bricolage) + `<RadialGauge>`.
|
||||
- **T8.3** Build asymmetric ticker row with `StaggerGroup` + 4 `.surface` blocks (mono number + label + `<Sparkline>`); inline (replaces stat-card).
|
||||
- **T8.4** Delete `dashboard/stat-card.tsx`.
|
||||
- **T8.5** Reskin `dashboard/activity-chart.tsx` → `charts/AreaActivity` (daily).
|
||||
- **T8.6** Reskin `dashboard/hourly-activity-chart.tsx` → `charts/AreaActivity` (hourly).
|
||||
- **T8.7** Reskin `dashboard/top-channels-chart.tsx` → `charts/` + `.surface`.
|
||||
- **T8.8** Reskin `dashboard/moderation-donut.tsx` → `charts/RadialGauge`.
|
||||
- **T8.9** Reskin `dashboard/users-section.tsx` → `.surface`.
|
||||
- **T8.10** Reskin `dashboard/channels-section.tsx` → `.surface`.
|
||||
- **T8.11** Reskin `dashboard/reactions-section.tsx` → `.surface`.
|
||||
- **T8.12** Verify `grep -rl "components/ui/card" src/app/\(dashboard\)/dashboard src/components/dashboard` → empty; `npx tsc --noEmit`.
|
||||
|
||||
### PHASE 9 — Messages page + components
|
||||
- **T9.1** Rewrite `messages/page.tsx`: default `getMessages(guildId)`(+channels) → `<MessagesView>`; client spine + entries.
|
||||
- **T9.2** Delete `messages/message-card.tsx`.
|
||||
- **T9.3** Rewrite `messages/message-list.tsx`: left timeline spine + right severity-tick entries (`surface` + `border-l-2` lime/amber/vermilion, motion spring tick), `layout` reflow.
|
||||
- **T9.4** Rewrite `messages/message-detail.tsx` → `.surface`.
|
||||
- **T9.5** Rewrite `messages/message-detail-view.tsx` → `.surface` pane.
|
||||
- **T9.6** Rewrite `messages/ai-status-badge.tsx` → `primitives/Badge`.
|
||||
- **T9.7** Rewrite `messages/ai-analysis-panel.tsx` → `.surface`.
|
||||
- **T9.8** Rewrite `messages/attachments-grid.tsx` → `.surface` grid.
|
||||
- **T9.9** Rewrite `messages/lightbox.tsx` → `primitives/Dialog`.
|
||||
- **T9.10** Rewrite `messages/search-overlay.tsx` → console palette, type-in animation, `primitives/Dialog`.
|
||||
- **T9.11** Verify no `components/ui/card` in messages tree; `npx tsc --noEmit`.
|
||||
|
||||
### PHASE 10 — Voice page + components
|
||||
- **T10.1** Rewrite `voice/page.tsx`: default `getVoiceStatus()` → `<VoiceView>`; client `WebGLGuard`+`OrbField` hero + ribbon + tabs.
|
||||
- **T10.2** Delete `voice/voice-connection-card.tsx`, `connection-card.tsx`, `microphone-card.tsx`.
|
||||
- **T10.3** Rewrite `voice/speaker-waveform.tsx` → SVG ring / eq bars.
|
||||
- **T10.4** Rewrite `voice/active-speakers-panel.tsx` → `.surface`.
|
||||
- **T10.5** Rewrite `voice/activity-timeline.tsx` → `charts/SessionRibbon`.
|
||||
- **T10.6** Rewrite `voice/mic-control.tsx` → `primitives/Button`.
|
||||
- **T10.7** Rewrite `voice/listen-control.tsx` → `primitives/Button`.
|
||||
- **T10.8** Verify `npx tsc --noEmit`; no `components/ui/card` in voice tree.
|
||||
|
||||
### PHASE 11 — Media page + components
|
||||
- **T11.1** Rewrite `media/page.tsx`: default `getMediaStatus()` → `<MediaView>`; client turntable disc + transport + queue.
|
||||
- **T11.2** Rewrite `media/music-player.tsx`: CSS-3D disc (spin-disc, pause when not playing, spring on play/pause), mono meta, `primitives/Button` transport, `.surface` queue rows with `layout`.
|
||||
- **T11.3** Rewrite `media/mini-player.tsx` → compact `.surface`.
|
||||
- **T11.4** Verify `npx tsc --noEmit`; no `components/ui/card` in media tree.
|
||||
|
||||
### PHASE 12 — Recordings page + components
|
||||
- **T12.1** Rewrite `recordings/page.tsx`: default `getRecordings(50)` → `<RecordingsView>`; client rows `AnimatePresence`+`layout`, live `voice_recording_uploaded` prepend.
|
||||
- **T12.2** Delete `recordings/recording-card.tsx`.
|
||||
- **T12.3** Rewrite `recordings/recording-player.tsx` → `.surface` row + `charts/Waveform` + `primitives/Button`/`Dialog` play/delete.
|
||||
- **T12.4** Verify `npx tsc --noEmit`.
|
||||
|
||||
### PHASE 13 — Moderation + Analysis pages
|
||||
- **T13.1** Rewrite `moderation/page.tsx`: default `getModerationStats/Actions` → `<ModerationView>`; client vertical event-flow, live actions pulse-in.
|
||||
- **T13.2** Rewrite `moderation/moderation-section.tsx` → event-flow, no Card/table.
|
||||
- **T13.3** Rewrite `analysis/page.tsx`: client `<AnalysisView/>` terminal console (`primitives/Input` mono + blinking caret, `.surface` results staggered).
|
||||
- **T13.4** Rewrite `analysis/search-panel.tsx` → terminal style.
|
||||
- **T13.5** Verify `npx tsc --noEmit`.
|
||||
|
||||
### PHASE 14 — Chatbot + shared + final cleanup
|
||||
- **T14.1** Rewrite `chatbot/chatbot-container.tsx` → `.surface`, keep drag/minimize.
|
||||
- **T14.2** Rewrite `chatbot/chat-panel.tsx` → bubbles via `AnimatePresence`, `primitives/*`.
|
||||
- **T14.3** Keep `chatbot/chatbot-context.tsx` + `index.ts`.
|
||||
- **T14.4** Rewrite `shared/empty-state.tsx` → tonal.
|
||||
- **T14.5** Rewrite `shared/error-state.tsx`.
|
||||
- **T14.6** Rewrite `shared/loading-skeleton.tsx` → `primitives/Skeleton`.
|
||||
- **T14.7** Rewrite `shared/error-boundary.tsx`.
|
||||
- **T14.8** Rewrite `shared/guild-selector.tsx` → `primitives/Select`.
|
||||
- **T14.9** `rm -rf src/components/ui && rm -f components.json`.
|
||||
- **T14.10** `grep -rn "components/ui/\|@shadcn\|@base-ui\|recharts" src` → MUST be empty.
|
||||
- **T14.11** `grep -rl "from \"recharts\"\|@base-ui\|@shadcn" src` → empty (double-check).
|
||||
- **T14.12** Verify `npx tsc --noEmit` across whole `src`.
|
||||
|
||||
### PHASE 15 — Build + lint gate
|
||||
- **T15.1** `cd services/frontend && npx tsc --noEmit` → 0 errors.
|
||||
- **T15.2** `pnpm biome check` → fix all issues (no `any` in new files).
|
||||
- **T15.3** `pnpm build` (standalone) → success, emits `.next/standalone/server.js`.
|
||||
- **T15.4** Inspect `.next/static/chunks/` for three-heavy chunk loaded only on dashboard/voice; confirm NOT in `/dashboard/` initial SSR HTML.
|
||||
- **T15.5** Confirm `pnpm-lock.yaml` present (reproducible flake install).
|
||||
- **T15.6** `grep -c "0.52 0.17 215\|0.55 0.2 280" .next/static/css/*.css` → 0.
|
||||
|
||||
### PHASE 16 — Local runtime smoke test (no prod)
|
||||
- **T16.1** Start local standalone: `GMW_BACKEND_URL=http://127.0.0.1:4001 PORT=4017 node .next/standalone/server.js &`.
|
||||
- **T16.2** `curl -s -o /dev/null -w "%{http_code}"` for all 7 routes → 200.
|
||||
- **T16.3** `curl /dashboard/ | grep -o "Bricolage\|signal\|surface"` → present.
|
||||
- **T16.4** `curl /_next/static/css/*.css | grep "0.52 0.17 215\|0.55 0.2 280"` → empty.
|
||||
- **T16.5** Headless browser `/dashboard/`+`/voice/` WebGL on: no console errors, `<canvas>` present, `<StaticFallback/>` NOT rendered.
|
||||
- **T16.6** Same pages WebGL off: `<StaticFallback/>` renders, no crash.
|
||||
- **T16.7** Kill local server. Do NOT touch prod unit.
|
||||
- **T16.8** Confirm 7 routes 200 + no console errors in T16.5/16.6.
|
||||
|
||||
### PHASE 17 — Flake + staging deploy
|
||||
- **T17.1** Inspect `flake.nix` frontend drv: `filterSource` ignores `out/.next/node_modules`; `pnpm-lock.yaml` included.
|
||||
- **T17.2** `nix build .#gmw-frontend --impure --sandbox-off` → succeeds.
|
||||
- **T17.3** `nix copy` frontend drv to VPS into staging profile (test port e.g. 4217).
|
||||
- **T17.4** Create/adjust staging systemd unit with `PORT=4217` exported BEFORE `node server.js` (LIDM PORT bug).
|
||||
- **T17.5** `sudo systemctl restart gmw-frontend-staging`; `curl` staging → 200.
|
||||
- **T17.6** Browser-check staging `/dashboard/`+`/voice/` (3D visible, fallback test).
|
||||
- **T17.7** Verify staging CSS has no old teal/purple; new design renders.
|
||||
|
||||
### PHASE 18 — Production swap (CONFIRM WITH USER FIRST)
|
||||
- **T18.1** STOP — send staging screenshots/URL; await explicit approval before touching prod.
|
||||
- **T18.2** On approval: `nix-env --profile /nix/var/nix/profiles/gmw-frontend --set <new-drv>`.
|
||||
- **T18.3** Confirm `gmw-frontend.service` exports `PORT=4017` before exec.
|
||||
- **T18.4** `sudo systemctl restart gmw-frontend`.
|
||||
- **T18.5** `curl` all 7 routes on `https://imphnen.asepharyana.my.id` → 200.
|
||||
- **T18.6** Browser verify prod: new design, old teal gone, 3D scenes render.
|
||||
- **T18.7** `journalctl -u gmw-frontend -f` 5 min; confirm WS reconnect + live features.
|
||||
- **T18.8** Notify user with before/after notes; keep rollback plan (`nix-env --set <previous>; systemctl restart`).
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
- **Scope:** 7 pages + charts + primitives + motion + 2 three scenes + layout. Big but mechanical; each task is isolated (~90 atomic tasks).
|
||||
- **Bundle weight:** `three` adds ~150KB gz but ONLY on dashboard/voice routes (lazy chunk, `ssr:false`). `motion` ~35KB gz app-wide — acceptable.
|
||||
- **WebGL compatibility:** covered by `WebGLGuard` + static fallback. Old devices / strict privacy browsers never blank.
|
||||
- **Motion excess:** risk of "AI-generated" scattered animation. Guard: one signature moment per page, shared variants, reduced-motion gates.
|
||||
- **Feature regressions:** Voice PCM playback, media transport, chatbot drag — logic preserved, only skin changes. Smoke test (T16) catches SSR breaks; live WS/3D needs real backend (staging T17).
|
||||
- **Removed deps:** dropping `recharts`/`sonner`/`cmdk` means rewriting charts + toasts + search palette — accounted for in Phases 3/5/9.
|
||||
- **Next standalone PORT bug:** `server.js` may not read `PORT` — ensure unit exports `PORT=4017` before exec (T17.4/T18.3).
|
||||
- **three + React 19:** raw three avoids R3F compat surface; lifecycle (dispose + RAF) handled in T6.2.
|
||||
- **next-themes:** keep (light/dark toggle); default dark.
|
||||
|
||||
## Open questions (answer before T18)
|
||||
- Q1: Deploy to prod now or staging-only first? (Recommend staging + screenshot review.)
|
||||
- Q2: Keep `react-day-picker`/`embla` if any page still needs them? (Plan assumes no — verify in T14.10 grep.)
|
||||
- Q3: Any brand name/wordmark change from "Discord Automod"? (Keep "Bete" identity unless told.)
|
||||
- Q4: 3D depth — full 3D scenes on dashboard+voice as specced, or also a 3D accent on media (disc)? (Default: dashboard+voice only; media disc stays CSS 3D.)
|
||||
@@ -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.
|
||||
@@ -1,695 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Bete (Discord Moderation Watcher)** — A comprehensive microservice-based Discord monitoring and moderation bot. Captures text messages, images, voice audio, and screenshares from Discord servers. Features AI-powered content moderation with auto-delete, voice recording with real-time streaming, music playback, and a React dashboard.
|
||||
|
||||
Built with **pnpm workspace monorepo** with 3 services and 1 shared library:
|
||||
|
||||
| Package | Path | Description |
|
||||
|---------|------|-------------|
|
||||
| `discord-moderation-backend` | `services/backend` | Express HTTP/WS server, REST API, Redis bridge |
|
||||
| `@bete/discord-gateway` | `services/discord-gateway` | Discord client, voice recording, message capture, AI moderation |
|
||||
| `frontend` | `services/frontend` | Next.js 16 (React 19) static dashboard, Tailwind v4, shadcn/ui |
|
||||
| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities |
|
||||
|
||||
**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite.
|
||||
|
||||
**Inter-service communication:** Redis pub/sub.
|
||||
|
||||
## Architecture
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```
|
||||
Discord
|
||||
|
|
||||
v
|
||||
discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend
|
||||
| pub/sub (broadcast) (Next.js static)
|
||||
| |
|
||||
| |
|
||||
<------------------+
|
||||
(command channel)
|
||||
```
|
||||
|
||||
1. **discord-gateway** connects to Discord via `discord.js-selfbot-v13`, captures events (messages, voice, attachments), stores in PostgreSQL, and publishes events to Redis channels (e.g., `discord:message:created`, `discord:voice:pcm`).
|
||||
|
||||
2. **backend** subscribes to Redis channels, broadcasts events to WebSocket clients, and serves REST API endpoints.
|
||||
|
||||
3. **frontend** connects via WebSocket and HTTP to the backend, provides a dashboard for live monitoring (text, voice, media) and AI moderation oversight.
|
||||
|
||||
4. **Command flow (reverse):** Frontend -> Backend HTTP/WS -> Redis (`backend:command`) -> discord-gateway (command handler) - for actions like connect voice, play media, moderate message.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Message Capture:
|
||||
Discord -> messageCapture.ts -> messageStore.ts (PostgreSQL)
|
||||
|
|
||||
+> eventBroadcaster -> Redis -> backend -> WS clients
|
||||
|
||||
Voice Recording:
|
||||
Discord -> voiceController.ts -> recorder.ts -> OGG files on disk
|
||||
| |
|
||||
+> eventBroadcaster +> decoder.ts -> PCM -> Redis -> WS clients
|
||||
|
||||
AI Moderation:
|
||||
messageStore -> aiAnalyzer.ts -> LLM API -> moderation result
|
||||
| |
|
||||
+> eventBroadcaster +> update message in DB
|
||||
```
|
||||
|
||||
## Service Breakdown
|
||||
|
||||
### backend (`services/backend`)
|
||||
|
||||
Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
|
||||
|
||||
**REST API endpoints (all public):**
|
||||
- `GET /api/health` — Health check with optional `?verbose=true`
|
||||
- `GET /api/config` — App configuration
|
||||
- `GET /api/messages` — List messages (cursor pagination)
|
||||
- `GET /api/messages/:channelId` — Messages by channel
|
||||
- `GET /api/messages/:channelId/attachments` — Attachments by channel
|
||||
- `GET /api/messages/detail/:id` — Single message
|
||||
- `POST /api/messages/reanalyze-batch` — Bulk retry AI analysis
|
||||
- `POST /api/messages/:id/reanalyze` — Retry single message
|
||||
- `POST /api/messages/:id/moderate` — Dispatch moderation action
|
||||
- `GET /api/review` — Flagged/warned messages
|
||||
- `GET /api/analysis/search` — Full-text search with `?q=`
|
||||
- `POST /api/chat` — AI chatbot chat
|
||||
- `GET /api/chat/history` — Chat history
|
||||
- `POST /api/chat/clear` — Clear chat history
|
||||
- `POST /api/voice/command` — Send voice transmit commands
|
||||
- `GET /api/status` — Voice connection status
|
||||
- `POST /api/connect` — Connect to voice channel
|
||||
- `POST /api/disconnect` — Disconnect from voice
|
||||
- `GET /api/guilds` — List guilds
|
||||
- `GET /api/guilds/:guildId/channels` — Text channels
|
||||
- `GET /api/guilds/:guildId/voice-channels` — Voice channels
|
||||
- `GET /api/media/status` — Media player status
|
||||
- `POST /api/media/queue` — Queue media (music/screen)
|
||||
- `POST /api/media/skip` — Skip current track
|
||||
- `POST /api/media/stop` — Stop playback
|
||||
- `POST /api/media/volume` — Set volume
|
||||
- `GET /api/recordings` — Voice recordings list
|
||||
- `GET /api/ui-state` — Get persistent UI state
|
||||
- `POST /api/ui-state` — Save UI state
|
||||
|
||||
**Modules (feature-based, under `src/modules/`):**
|
||||
- `health/` — Database connectivity check
|
||||
- `messages/` — Message + attachment CRUD, review, reanalyze
|
||||
- `voice/` — Voice connection, guilds, channels
|
||||
- `media/` — Music/screenshare player control
|
||||
- `analysis/` — Full-text search across analyzed messages
|
||||
- `chatbot/` — AI chatbot with server context
|
||||
- `recordings/` — Voice recording listing
|
||||
- `ui-state/` — Persistent UI state for dashboard
|
||||
- `config/` — App config endpoint
|
||||
|
||||
**WebSocket events (outbound to frontend):**
|
||||
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
|
||||
- `attachment_created`, `attachment_uploaded`
|
||||
- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded`
|
||||
- `voice_active_user`, `voice_pcm_data`
|
||||
- `analysis_queue_status`
|
||||
- `user_state`, `ui_state`, `media_state`
|
||||
- `heartbeat` (every 30s)
|
||||
|
||||
**WebSocket inbound (from frontend):**
|
||||
- JSON `{ type: "voice_transmit", buffer: "<base64 PCM>" }` — forwarded to Redis
|
||||
- JSON `{ type: "voice_command", command: "..." }` — forwarded to discord-gateway
|
||||
|
||||
### discord-gateway (`services/discord-gateway`)
|
||||
|
||||
The core service that connects to Discord using `discord.js-selfbot-v13`.
|
||||
|
||||
**Modules:**
|
||||
|
||||
- **`message-capture/`** — Listens to `messageCreate`, `messageUpdate`, `messageDelete` events. Stores messages in PostgreSQL. Handles edits, deletes, and backlog sync.
|
||||
- `messageCapture.ts` — Event listeners
|
||||
- `messageStore.ts` — Database operations (upsert, update, delete)
|
||||
- `messageMetadata.ts` — User/channel metadata extraction
|
||||
- `broadcaster.ts` — Internal event dispatch
|
||||
- `pagination.ts` — Backlog sync for historical messages
|
||||
- `analyticsStore.ts` — Per-channel analytics tracking
|
||||
|
||||
- **`voice-recording/`** — Voice channel connection, recording, and real-time PCM streaming.
|
||||
- `voiceController.ts` — Connection lifecycle (connect/disconnect per guild+channel)
|
||||
- `recorder.ts` — Manages speaking users, subscribes to audio streams
|
||||
- `recorder/audioStream.ts` — Opus packet subscription per user
|
||||
- `recorder/decoder.ts` — Opus to PCM decoding with rotation/cooldown
|
||||
- `recorder/segment.ts` — OGG file segment rotation (default 5s)
|
||||
- `recorder/metadata.ts` — User metadata JSON for each segment
|
||||
- `recorder/sessionRecording.ts` — Session-scoped recording management
|
||||
- `recorder/uploader.ts` — Upload completed segments
|
||||
- `player.ts` — Discord player (music/screenshare playback)
|
||||
- `transmitter.ts` — Browser-to-Discord audio transmission (Redis -> Opus -> Discord)
|
||||
- `muxer.ts` — Audio muxing logic
|
||||
- `packetFilter.ts` — Opus packet filtering
|
||||
- `ffmpegProcess.ts` — FFmpeg-based processing
|
||||
- `mediaTypes.ts` — Audio/video format definitions
|
||||
- `teleUpload.ts` — Upload to tele/picser
|
||||
|
||||
- **`attachment-upload/`** — Downloads Discord attachments, uploads to external service.
|
||||
- `attachmentUploader.ts` — Download + upload with retry
|
||||
- `imageResizer.ts` — Resize images before upload
|
||||
- `teleUpload.ts` — Upload to tele/picser API
|
||||
|
||||
- **`ai-moderation/`** — AI-powered content moderation pipeline.
|
||||
- `aiAnalyzer.ts` — Analysis worker (batch + individual fallback)
|
||||
- `aiAnalysisWorker.ts` — Piscina worker thread for batch processing
|
||||
- `llmClient.ts` — Generic LLM API client
|
||||
- `llmModerationClient.ts` — Moderation-specific LLM client
|
||||
- `moderationPrompt.ts` — System prompt builder with few-shot
|
||||
- `autoDeleteManager.ts` — Auto-delete flagged messages
|
||||
- `conversationContext.ts` — Conversation window builder
|
||||
- `concurrencyLimiter.ts` — Rate limiter for LLM calls
|
||||
- `channelCultureStore.ts` — Channel norms/slang context
|
||||
- `cultureLearner.ts` — Learn channel culture over time
|
||||
- `userReputationStore.ts` — User trust scores
|
||||
- `textCacheStore.ts` — Deduplicate repeated text analysis
|
||||
- `stickerCache.ts` — Upload and cache sticker images
|
||||
- `stickerPrompt.ts` — Sticker analysis prompt
|
||||
- `urlFetcher.ts` — Fetch URL content for analysis
|
||||
- `responseLogger.ts` — Log moderation responses
|
||||
|
||||
- **`event-broadcaster/`** — Redis pub/sub publisher for all events.
|
||||
- `eventBroadcaster.ts` — `EventBroadcaster` class with typed methods
|
||||
- `eventTypes.ts` — Channel constants and event interfaces
|
||||
|
||||
- **`command-handler/`** — Listens on `backend:command` Redis channel for backend requests.
|
||||
- `commandHandler.ts` — Handles voice connect/disconnect, guilds, channels, media, transmit
|
||||
|
||||
**Infrastructure:**
|
||||
- `src/shared/config/config.ts` — Zod-validated env config (DISCORD_TOKEN, REDIS_URL, AI_LLM_*, etc.)
|
||||
- `src/shared/database/schema.ts` — Full PostgreSQL schema definition
|
||||
- `src/shared/database/drizzle.ts` — Drizzle + pg pool initialization
|
||||
- `src/shared/database/migrate.ts` — Migration runner with advisory locking
|
||||
- `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries
|
||||
- `src/shared/discord/clientOptions.ts` — Discord client configuration
|
||||
|
||||
### frontend (`services/frontend`)
|
||||
|
||||
Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind v4 + shadcn/ui + base-ui.
|
||||
|
||||
**Tech stack:**
|
||||
- Next.js 16 (App Router, static export)
|
||||
- React 19 with React Compiler
|
||||
- TypeScript strict
|
||||
- Tailwind v4 + shadcn/ui + base-ui components
|
||||
- lucide-react icons
|
||||
|
||||
**Feature structure:**
|
||||
- `src/app/` — App Router pages (login, dashboard with tabs)
|
||||
- `src/features/` — Feature components (dashboard, messages, live, chatbot)
|
||||
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
|
||||
- `src/components/` — Shared UI components (layout, ui)
|
||||
|
||||
### shared (`packages/shared`)
|
||||
|
||||
Shared library used by both backend and discord-gateway.
|
||||
|
||||
**Exports:**
|
||||
- `@bete/shared` — Everything below
|
||||
- `@bete/shared/types` — AppConfig, MessageRecord, AttachmentRecord, VoiceSegment, etc.
|
||||
- `@bete/shared/errors` — AppError, ValidationError, NotFoundError, UnauthorizedError, DatabaseError, ConfigError, DiscordError, TimeoutError, etc.
|
||||
- `@bete/shared/logger` — Pino-based `createChildLogger(context)`
|
||||
- `@bete/shared/utils` — Shared utilities
|
||||
|
||||
## Database Schema (PostgreSQL)
|
||||
|
||||
All tables defined in `services/discord-gateway/src/shared/database/schema.ts`.
|
||||
|
||||
### messages
|
||||
Stores text messages with AI moderation results.
|
||||
- `id` (text PK), `guild_id`, `channel_id`, `thread_id`
|
||||
- `user_id`, `username`, `avatar_url`
|
||||
- `content`, `edited_content`, `type` (text|edited|deleted)
|
||||
- `created_at`, `edited_at`, `deleted_at`
|
||||
- `ai_status` (pending|processing|clean|warn|flagged|error)
|
||||
- `ai_moderation_flags`, `ai_moderation_score`, `ai_analysis`, `ai_categories`
|
||||
- `ai_severity` (none|low|medium|high|critical), `ai_confidence`
|
||||
- `ai_recommended_action` (none|monitor|warn|review|delete|escalate)
|
||||
- `ai_analyzed_at`, `ai_error`, `metadata`
|
||||
- Indexes: channel, user, created_at, thread, channel+created, thread+created, ai_status+created, guild+ai_status+created, guild+created+deleted, channel+ai_status+created, thread+ai_status+created
|
||||
|
||||
### attachments
|
||||
Discord attachment metadata with upload tracking.
|
||||
- `id` (text PK), `message_id` (FK -> messages cascade), `guild_id`, `channel_id`
|
||||
- `filename`, `size`, `type` (MIME), `discord_url`, `uploaded_url`
|
||||
- `upload_status` (pending|uploaded|failed), `upload_error`
|
||||
- `created_at`, `uploaded_at`
|
||||
- Indexes: channel, message, upload_status, channel+created, thread+created
|
||||
|
||||
### voice_recordings
|
||||
Voice segment metadata.
|
||||
- `id` (text PK), `user_id`, `username`, `avatar_url`
|
||||
- `guild_id`, `channel_id`, `channel_name`
|
||||
- `filename`, `size_bytes`, `download_url`
|
||||
- `upload_status` (pending|uploaded|failed), `upload_error`
|
||||
- `created_at`, `uploaded_at`
|
||||
- Indexes: user_id, channel_id, created_at
|
||||
|
||||
### ui_state
|
||||
Persistent dashboard UI state (key-value).
|
||||
- `key` (text PK), `value` (text), `updated_at`
|
||||
|
||||
### ai_analysis_runs
|
||||
Tracks AI analysis batch runs.
|
||||
- `id` (text PK), `conversation_key`, `target_message_ids` (JSON)
|
||||
- `model`, `request_tokens_estimate`, `response_raw`
|
||||
- `status` (pending|processing|completed|failed), `error`
|
||||
- `created_at`, `completed_at`
|
||||
- Indexes: conversation_key, status, created_at
|
||||
|
||||
### user_reputations
|
||||
User trust scores for AI context.
|
||||
- `user_id` (text PK), `guild_id`, `trust_score`, `clean_message_streak`
|
||||
- `total_infractions`, `last_infraction_at`, `created_at`, `updated_at`
|
||||
- Indexes: guild_id, trust_score
|
||||
|
||||
### channel_cultures
|
||||
AI-generated channel norms and slang summaries.
|
||||
- `channel_id` (text PK), `guild_id`, `culture_summary`, `last_analyzed_at`
|
||||
- Index: guild_id
|
||||
|
||||
### message_reviews
|
||||
Manual review tracking for flagged messages.
|
||||
- `id` (text PK), `message_id`, `guild_id`, `channel_id`
|
||||
- `reviewer_id`, `status` (pending|approved|rejected|escalated)
|
||||
- `notes`, `created_at`, `reviewed_at`
|
||||
- Indexes: message_id, status, created_at, guild+status+created
|
||||
|
||||
### moderation_actions
|
||||
Action audit log (delete/mute/warn/kick/ban).
|
||||
- `id` (text PK), `message_id`, `user_id`, `guild_id`
|
||||
- `action_type` (delete_message|mute_user|warn_user|kick_user|ban_user)
|
||||
- `reason`, `executed_by`, `status` (pending|executed|failed)
|
||||
- `error`, `created_at`, `executed_at`
|
||||
- Indexes: message_id, user_id, status, guild+status+created
|
||||
|
||||
### retention_policies
|
||||
Data retention rules per guild/channel.
|
||||
- `id` (text PK), `guild_id`, `channel_id`
|
||||
- `retention_days`, `apply_to_media`, `apply_to_voice`, `enabled`
|
||||
- `created_at`, `updated_at`
|
||||
- Indexes: guild_id, enabled
|
||||
|
||||
### text_analysis_cache
|
||||
Caches normalized-text moderation results to avoid redundant LLM calls.
|
||||
- `text` (text PK), `flags` (JSON array), `source` (local|primary_ai|vision_llm)
|
||||
- `analyzed_at`, `expires_at`, `hit_count`
|
||||
- Indexes: expires_at, source
|
||||
|
||||
### sticker_cache
|
||||
Uploaded sticker image URLs for vision analysis.
|
||||
- `name` (text PK), `image_url`, `mime_type`, `fetched_at`
|
||||
- Index: fetched_at
|
||||
|
||||
### corrected_moderations
|
||||
Manual corrections (false positives) for few-shot injection.
|
||||
- `id` (text PK), `message_id`, `original_flags`, `corrected_flags`
|
||||
- `correction_notes`, `content_snippet`, `created_at`
|
||||
- Indexes: created_at, message_id
|
||||
|
||||
### muxer_jobs
|
||||
Audio post-processing job queue.
|
||||
- `id` (text PK), `data` (JSON), `status` (pending|processing|completed|failed)
|
||||
- `attempts`, `maxAttempts`, `created_at`, `updated_at`, `error`
|
||||
- Indexes: status, created_at
|
||||
|
||||
## Redis Communication
|
||||
|
||||
### discord-gateway publishes (event channels):
|
||||
| Channel | Event type | When |
|
||||
|---------|-----------|------|
|
||||
| `discord:message:created` | `message_created` | New message |
|
||||
| `discord:message:updated` | `message_updated` | Message edited |
|
||||
| `discord:message:deleted` | `message_deleted` | Message deleted |
|
||||
| `discord:message:analyzed` | `message_analyzed` | AI analysis complete |
|
||||
| `discord:attachment:created` | `attachment_created` | New attachment |
|
||||
| `discord:attachment:uploaded` | `attachment_uploaded` | Upload complete |
|
||||
| `discord:voice:started` | `voice_recording_started` | Recording started |
|
||||
| `discord:voice:stopped` | `voice_recording_stopped` | Recording stopped |
|
||||
| `discord:voice:uploaded` | `voice_recording_uploaded` | Upload complete |
|
||||
| `discord:voice:active_user` | `voice_active_user` | Speaker state change |
|
||||
| `discord:voice:pcm` | `voice_pcm_data` | Live PCM audio chunk |
|
||||
| `discord:analysis:queue_status` | `analysis_queue_status` | Queue stats |
|
||||
|
||||
### backend publishes (command channel):
|
||||
| Channel | Command type | Description |
|
||||
|---------|-------------|-------------|
|
||||
| `backend:command` | `voice:connect` | Connect to voice |
|
||||
| `backend:command` | `voice:disconnect` | Disconnect voice |
|
||||
| `backend:command` | `voice:channels` | List voice channels |
|
||||
| `backend:command` | `voice:transmit:start/stop` | Audio transmit |
|
||||
| `backend:command` | `guilds:list` | List guilds |
|
||||
| `backend:command` | `guilds:text-channels` | List text channels |
|
||||
| `backend:command` | `media:queue/skip/stop/volume` | Media control |
|
||||
| `backend:command` | `moderation:action` | Execute moderation action |
|
||||
|
||||
Envelope format: `{ id, type, payload, replyChannel }`.
|
||||
|
||||
Status keys: `voice:status`, `media:status` (set by discord-gateway, read by backend).
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
pnpm install
|
||||
|
||||
# Run each service in development mode (separate terminal each)
|
||||
pnpm run dev:backend # Backend on port 3001
|
||||
pnpm run dev:discord-gateway # Discord client + all features
|
||||
pnpm run dev:web # Frontend via next dev (port 3000)
|
||||
|
||||
# Build
|
||||
pnpm run build:backend
|
||||
pnpm run build:discord-gateway
|
||||
pnpm run build:web # next build (static export)
|
||||
|
||||
# Type checking
|
||||
pnpm run typecheck # Node services (pnpm -r)
|
||||
pnpm run typecheck:web # Frontend typecheck (next build)
|
||||
|
||||
# Lint (Biome)
|
||||
pnpm run lint
|
||||
|
||||
# Format (Biome)
|
||||
pnpm run format
|
||||
|
||||
# Run tests across all packages
|
||||
pnpm run test
|
||||
|
||||
# Database migrations (Drizzle)
|
||||
pnpm run db:generate # Generate new migration
|
||||
pnpm run db:migrate # Apply pending migrations
|
||||
pnpm run db:studio # Open Drizzle Studio
|
||||
|
||||
# Install yt-dlp for media download
|
||||
pnpm run install:yt-dlp
|
||||
|
||||
# Deploy to VPS (build + hot-patch running containers)
|
||||
./deploy.sh # Build + deploy all services
|
||||
./deploy.sh --frontend # Frontend (Next.js) only
|
||||
./deploy.sh --backend # Backend TypeScript only
|
||||
./deploy.sh --no-build # Skip build, just copy files
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration via `.env` (see `.env.example`). Managed by Zod schemas:
|
||||
- discord-gateway: `services/discord-gateway/src/shared/config/config.ts`
|
||||
- backend: `services/backend/src/shared/config/index.ts`
|
||||
|
||||
### Core (both services)
|
||||
- `DISCORD_TOKEN` — Discord user token (required)
|
||||
- `MONITOR_GUILD_ID` — Target guild for text monitoring
|
||||
- `NODE_ENV` — development|production|test
|
||||
- `LOG_LEVEL` — Pino log level (default: info)
|
||||
- `VERBOSE` — Enable debug logging (default: false)
|
||||
|
||||
### Database (PostgreSQL)
|
||||
- `DATABASE_URL` — Connection string (overrides individual params)
|
||||
- `POSTGRES_HOST`, `POSTGRES_PORT` (5432), `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
|
||||
- `POSTGRES_POOL_MIN` (2), `POSTGRES_POOL_MAX` (10)
|
||||
- `AUTO_MIGRATE_ON_STARTUP` (default: true)
|
||||
|
||||
### Redis
|
||||
- `REDIS_URL` — Connection string (default: redis://localhost:6379)
|
||||
|
||||
### Voice Recording (discord-gateway)
|
||||
- `RECORDINGS_DIR` — Audio file output (default: ./recordings)
|
||||
- `RECORDING_SEGMENT_MS` — OGG segment duration (default: 5000)
|
||||
- `DECODER_ROTATE_MS` — Opus decoder rotation (default: 5000)
|
||||
- `DECODER_COOLDOWN_MS` — Decoder error cooldown (default: 30000)
|
||||
- `AUDIO_STREAM_SILENCE_DURATION_MS` — Silence threshold (default: 3000)
|
||||
- `VOICE_CONNECTION_TIMEOUT_MS` — Connection timeout (default: 15000)
|
||||
- `RECONNECT_TIMEOUT_MS` — Reconnect timeout (default: 5000)
|
||||
- `PACKET_FILTER_MIN_SIZE` — Minimum Opus packet size (default: 8)
|
||||
- `OPUS_FRAME_SIZE` (960), `AUDIO_SAMPLE_RATE` (48000), `AUDIO_CHANNELS` (2)
|
||||
- `VOICE_GUILD_ID`, `VOICE_CHANNEL_ID`
|
||||
|
||||
### Attachments
|
||||
- `TELE_UPLOAD_URL` — Upload endpoint (default: https://upload.asepharyana.my.id/api/upload)
|
||||
- `ATTACHMENT_UPLOAD_TIMEOUT_MS` (30000), `ATTACHMENT_MAX_SIZE_MB` (100), `ATTACHMENT_RETRY_ATTEMPTS` (3)
|
||||
|
||||
### AI Moderation (discord-gateway)
|
||||
- `AI_ANALYSIS_ENABLED` — Enable AI analysis (default: false)
|
||||
- `AI_LLM_API_KEY` — LLM API key (required if enabled)
|
||||
- `AI_LLM_BASE_URL` — LLM endpoint (default: https://9router.asepharyana.my.id/v1)
|
||||
- `AI_LLM_MODEL` — Text model (default: text)
|
||||
- `AI_LLM_VISION_MODEL` — Vision model (optional fallback)
|
||||
- `AI_LLM_MAX_CONCURRENT` (5), `AI_LLM_TEXT_BATCH_SIZE` (20)
|
||||
- `AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS` (60000), `AI_LLM_IMAGE_MAX_DIMENSION` (1024)
|
||||
- `AI_ANALYSIS_DEBOUNCE_MS` (500), `AI_ANALYSIS_MAX_BATCH_SIZE` (200)
|
||||
- `AI_ANALYSIS_PROCESSING_TIMEOUT_MS` (120000)
|
||||
- `PISCINA_MAX_THREADS` — Worker pool size (optional)
|
||||
|
||||
### Auto-Delete
|
||||
- `AUTO_DELETE_FLAGGED_ENABLED` (true), `AUTO_DELETE_FLAGGED_DRY_RUN` (true)
|
||||
- `AUTO_DELETE_FLAGGED_DELAY_MS` (0), `AUTO_DELETE_MIN_CONFIDENCE` (0.5)
|
||||
- `AUTO_DELETE_ALLOWED_SEVERITIES`, `AUTO_DELETE_ALLOWED_CATEGORIES`
|
||||
- `AUTO_DELETE_EXCLUDED_CHANNEL_IDS`, `AUTO_DELETE_EXCLUDED_USER_IDS`
|
||||
- `AUTO_DELETE_NOTIFY_USER`, `AUTO_DELETE_LOG_CHANNEL_ID`
|
||||
|
||||
### OpenAI Moderation (optional separate endpoint)
|
||||
- `OPENAI_MODERATION_API_KEY`, `OPENAI_MODERATION_BASE_URL`, `OPENAI_MODERATION_MODEL`
|
||||
|
||||
### Backend
|
||||
- `WEBSERVER_PORT` (3001)
|
||||
- `BACKLOG_SYNC_HOURS` (24), `BACKLOG_SYNC_BATCH_SIZE` (100)
|
||||
|
||||
### Retention
|
||||
- `RETENTION_MESSAGES_DAYS` (0=off), `RETENTION_ATTACHMENTS_DAYS`, `RETENTION_VOICE_DAYS`
|
||||
- `RETENTION_CLEANUP_INTERVAL_MS` (86400000), `RETENTION_DRY_RUN` (true)
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use **Vitest**. Currently minimal test coverage. Test directories should be created per service:
|
||||
|
||||
```
|
||||
services/backend/tests/
|
||||
services/discord-gateway/tests/
|
||||
services/frontend/tests/
|
||||
```
|
||||
|
||||
Run tests: `pnpm run test` (runs `vitest run` in each package).
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Formatter**: Biome (2-space indent)
|
||||
- **Linter**: Biome with strict rules
|
||||
- **Language**: TypeScript with strict mode
|
||||
- **Logging**: Use `createChildLogger(context)` from `@bete/shared/logger`
|
||||
- **Errors**: Throw custom `AppError` subclasses with `code` + `statusCode`
|
||||
- **Database**: Use Drizzle ORM or raw parameterized queries (never string interpolation)
|
||||
- **Imports**: Use `.js` extensions in source files (ESM convention)
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
All inter-service communication happens through Redis pub/sub. The discord-gateway publishes events on typed channels, the backend subscribes and broadcasts to WebSocket clients. The backend publishes commands on `backend:command` with reply channels for request-response patterns.
|
||||
|
||||
### Message Capture Lifecycle
|
||||
|
||||
1. Discord event fires (`messageCreate`, `messageUpdate`, `messageDelete`)
|
||||
2. Check guild matches MONITOR_GUILD_ID
|
||||
3. Extract message metadata (user, channel, content, timestamp)
|
||||
4. Upsert into `messages` table in PostgreSQL
|
||||
5. Publish event to Redis (`discord:message:*`)
|
||||
6. If attachments exist, insert into `attachments` table with `status='pending'`
|
||||
7. Start async upload to tele/picser (non-blocking)
|
||||
8. On success: update `uploaded_url`, `status='uploaded'`
|
||||
9. On failure: store error, `status='failed'`
|
||||
|
||||
### AI Moderation Pipeline
|
||||
|
||||
1. Messages with `ai_status='pending'` are picked up by `aiAnalyzer.ts`
|
||||
2. Batches messages by conversation (thread/channel proximity)
|
||||
3. Builds context window (recent messages + channel culture + user reputation)
|
||||
4. Calls LLM via `llmModerationClient.ts` with moderation prompt
|
||||
5. Updates message with `ai_status`, `ai_moderation_flags`, `ai_severity`, `ai_confidence`, `ai_recommended_action`
|
||||
6. If `AUTO_DELETE_FLAGGED_ENABLED` and confidence meets threshold, triggers auto-delete
|
||||
7. Falls back to individual analysis for messages that could not be batched
|
||||
8. Caches normalized text results in `text_analysis_cache` to avoid repeat calls
|
||||
|
||||
### Voice Recording Lifecycle
|
||||
|
||||
1. `VoiceController.connect(guildId, channelId)` via Redis command
|
||||
2. Joins Discord voice channel, sets up audio receiver
|
||||
3. On user start speaking: create per-user stream, OGG segment manager, Opus decoder
|
||||
4. Opus packets -> OGG segments on disk + PCM decode for WebSocket broadcast
|
||||
5. PCM data published to Redis (`discord:voice:pcm`) -> backend -> WS clients
|
||||
6. On silence (3s timeout): close stream, finalize segment
|
||||
7. After segment complete: upload to external storage, update database
|
||||
8. `VoiceController.disconnect()` stops all recording
|
||||
|
||||
### WebSocket Protocol (frontend)
|
||||
|
||||
**Outbound (backend -> frontend):**
|
||||
- Binary: PCM audio (24kHz mono s16le), prefixed with 4-byte user hash
|
||||
- JSON events: all typed in `WSEventMap` — `message_*`, `voice_*`, `attachment_*`, `user_state`, `ui_state`, `media_state`
|
||||
|
||||
**Inbound (frontend -> backend):**
|
||||
- JSON `{ type: "voice_transmit", buffer: "<base64 PCM>" }` for mic-to-Discord
|
||||
- JSON `{ type: "voice_command", command: "..." }` for voice control
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
discord-gateway handles SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
||||
1. Close database pool
|
||||
2. Disconnect voice controller
|
||||
3. Close event broadcaster (Redis)
|
||||
4. Close command handler (Redis)
|
||||
5. Destroy Discord client
|
||||
6. Exit process
|
||||
|
||||
### Public API
|
||||
|
||||
All backend endpoints are publicly accessible — no authentication required.
|
||||
|
||||
## Recording Structure
|
||||
|
||||
```
|
||||
recordings/
|
||||
+-- <user-id>/
|
||||
| +-- <user-id>-<session-start>-0.ogg
|
||||
| +-- <user-id>-<session-start>-0.json
|
||||
| +-- <user-id>-<session-start>-1.ogg
|
||||
| +-- ...
|
||||
```
|
||||
|
||||
Each segment is 5s (configurable via `RECORDING_SEGMENT_MS`). Metadata JSON includes user info, roles, timestamps, duration.
|
||||
|
||||
## Vendor Packages
|
||||
|
||||
### discord.js-selfbot-v13 (`vendor/discord.js-selfbot-v13`)
|
||||
Fork of discord.js-selfbot-v13 (git submodule). Provides Discord API access via user account.
|
||||
|
||||
### discord-video-stream (`vendor/discord-video-stream`)
|
||||
Go Live / video streaming support library. Includes:
|
||||
- H264 encoding (NVENC, VAAPI, software)
|
||||
- WebRTC wrapper for Discord voice/video connections
|
||||
- Stream connection management
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Shared (`@bete/shared`):**
|
||||
- pino — Structured logging
|
||||
- zod — Schema validation
|
||||
|
||||
**Backend:**
|
||||
- express 5 — HTTP server
|
||||
- ws — WebSocket server
|
||||
- helmet — Security headers
|
||||
- @discordjs/voice — Voice state querying (minimal)
|
||||
- drizzle-orm + pg — PostgreSQL ORM
|
||||
- ioredis — Redis client
|
||||
- pino, pino-http — Logging
|
||||
- prom-client — Prometheus metrics
|
||||
- axios — HTTP client
|
||||
- zod — Config validation
|
||||
|
||||
**discord-gateway:**
|
||||
- discord.js-selfbot-v13 — Discord client (user account)
|
||||
- @discordjs/voice — Voice connection
|
||||
- @discordjs/opus — Native Opus codec
|
||||
- prism-media — Audio encode/decode
|
||||
- @snazzah/davey — DA-VEY (Discord Audio Video End-to-end encryption)
|
||||
- ioredis — Redis client
|
||||
- drizzle-orm + pg — PostgreSQL ORM
|
||||
- sharp — Image processing
|
||||
- openai — OpenAI API client
|
||||
- piscina — Worker threads for AI analysis
|
||||
- tiktoken — Token counting
|
||||
- p-retry, p-limit — Async utilities
|
||||
- lru-cache — In-memory caching
|
||||
- libsodium-wrappers — Encryption
|
||||
- node-crc — CRC checksums
|
||||
- imghash — Image hashing
|
||||
- ws — WebSocket (internal)
|
||||
- zod — Config validation
|
||||
|
||||
**Frontend:**
|
||||
- react 19, react-dom 19
|
||||
- @tanstack/react-query — Data fetching
|
||||
- three, @react-three/fiber, @react-three/drei — 3D
|
||||
- gsap, framer-motion — Animations
|
||||
- @radix-ui/* — Accessible UI primitives
|
||||
- tailwindcss 4, @tailwindcss/postcss — Styling
|
||||
- lucide-react — Icons
|
||||
- clsx, tailwind-merge — Class management
|
||||
- vite 8 — Bundler
|
||||
|
||||
## Notes
|
||||
|
||||
- Bot uses selfbot variant (user account) — check Discord ToS
|
||||
- Opus decoding requires native `@discordjs/opus` or `opusscript` under Node.js
|
||||
- OGG segments include metadata JSON for each segment (user info, timestamps, duration)
|
||||
- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord
|
||||
- Graceful shutdown ensures clean disconnection and resource cleanup
|
||||
- All database operations use parameterized queries to prevent SQL injection
|
||||
- Attachment uploads are non-blocking (async) to avoid blocking message capture
|
||||
- Message capture continues even if AI analysis or attachment upload fails
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a new config variable
|
||||
1. Add to config schema in both `services/backend/src/shared/config/index.ts` and `services/discord-gateway/src/shared/config/config.ts` with Zod validation
|
||||
2. Add to `.env.example` with description
|
||||
3. Use via `config.VARIABLE_NAME`
|
||||
|
||||
### Add a new REST endpoint
|
||||
1. Create route handler in `services/backend/src/modules/<module>/<name>.routes.ts`
|
||||
2. Register in `services/backend/src/http/app.ts`
|
||||
3. Use `asyncHandler` wrapper for error handling
|
||||
4. Return JSON response
|
||||
|
||||
### Add a new WebSocket event
|
||||
1. Add to `eventTypes.ts` in discord-gateway
|
||||
2. Add publish method to `EventBroadcaster` in discord-gateway
|
||||
3. Add subscription + broadcast mapping in `services/backend/src/ws/redis-bridge.ts`
|
||||
4. Add event type to `WSEventMap` in frontend `events.ts`
|
||||
5. Add handler to `WsHandlers` in frontend `socket.ts`
|
||||
|
||||
### Add a new database table
|
||||
1. Add table definition in `services/discord-gateway/src/shared/database/schema.ts`
|
||||
2. Generate migration: `pnpm run db:generate`
|
||||
3. Check migration file in `drizzle/migrations/`
|
||||
4. Apply: `pnpm run db:migrate`
|
||||
|
||||
### Add a new Redis command
|
||||
1. Add handler case in `commandHandler.ts` switch statement
|
||||
2. Add publish call on backend side (see `voice.service.ts` or `media.service.ts`)
|
||||
3. Update frontend API client if needed
|
||||
|
||||
### Debug AI moderation
|
||||
- Set `AI_ANALYSIS_ENABLED=true` and `VERBOSE=true`
|
||||
- Check `ai_status`, `ai_error` fields in messages table
|
||||
- Monitor `/api/analysis/search?q=<text>` for analysis results
|
||||
- Check `ai_analysis_runs` table for batch run status
|
||||
- Adjust `AI_ANALYSIS_*` tuning variables
|
||||
|
||||
### Debug voice recording
|
||||
- Set `VERBOSE=true`
|
||||
- Check `/api/status` for active connection
|
||||
- Monitor segment files in `recordings/<user-id>/`
|
||||
- Check `voice_recordings` table for upload status
|
||||
|
||||
## CodeGraph Usage (Required)
|
||||
|
||||
- Use CodeGraph first for repo-level questions: architecture, dependencies, references, callers/callees, impact, flow, routes, components.
|
||||
- If graph is missing or stale, run scan first to refresh `.codegraph/graph.json`.
|
||||
- Prefer graph-backed flow:
|
||||
1. scan-codegraph (build/refresh graph)
|
||||
2. query-codegraph (find definitions/references/callers/dependencies)
|
||||
3. analyze-codegraph (architecture, impact, risk, cycles, orphans, hotspots)
|
||||
4. export-codegraph (json/mermaid/dot/markdown/html when needed)
|
||||
5. open-codegraph-ui (interactive visualization when requested)
|
||||
- Avoid broad grep/find or repeated wide file reads before graph lookup, except for exact literal search or known single-file edits.
|
||||
@@ -1,118 +0,0 @@
|
||||
# Bete — Discord Moderation Dashboard
|
||||
|
||||
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis AI opsional, dan menyediakan dashboard web real-time.
|
||||
|
||||
**Stack utama:** Node.js (Express 5), pnpm, TypeScript, React 19 (Next.js 16), Tailwind v4, shadcn/ui, Drizzle ORM, PostgreSQL, WebSocket, Redis pub/sub.
|
||||
|
||||
## Prasyarat
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm 11.x
|
||||
- FFmpeg di `PATH` (untuk audio muxing dan playback media)
|
||||
- `yt-dlp` di `PATH` (untuk resolve audio YouTube/Spotify)
|
||||
- Bun (untuk frontend dev — opsional, bisa pake pnpm)
|
||||
- PostgreSQL 15+
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
# Edit .env sesuai konfigurasi server
|
||||
```
|
||||
|
||||
## Menjalankan
|
||||
|
||||
```bash
|
||||
# Backend (port 3001)
|
||||
pnpm run dev:backend
|
||||
|
||||
# Discord Gateway (capture messages, voice, dll)
|
||||
pnpm run dev:discord-gateway
|
||||
|
||||
# Frontend (port 3000)
|
||||
pnpm run dev:web
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm run build:backend
|
||||
pnpm run build:discord-gateway
|
||||
pnpm run build:web # next build — static export ke out/
|
||||
pnpm run build # build semua service
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
./deploy.sh # Build + deploy semua service ke VPS
|
||||
./deploy.sh --frontend # Frontend only
|
||||
./deploy.sh --backend # Backend only
|
||||
./deploy.sh --no-build # Skip build, copy files aja
|
||||
```
|
||||
|
||||
## Service Architecture
|
||||
|
||||
```
|
||||
Discord
|
||||
|
|
||||
v
|
||||
discord-gateway ←→ Redis ←→ backend (Express 5) ←→ frontend (Next.js)
|
||||
| pub/sub | |
|
||||
| +— REST API (/api/*) |
|
||||
| +— WebSocket (/ws) |
|
||||
+— message capture +— AI moderation |
|
||||
+— voice recording +— dashboard data +— dashboard UI
|
||||
+— attachment upload +— real-time updates
|
||||
```
|
||||
|
||||
## Fitur
|
||||
|
||||
- **Message capture**: Capture pesan baru, edit, dan delete dari Discord
|
||||
- **Voice recording**: Rekam voice channel ke segmen OGG per user, streaming PCM real-time ke WebSocket
|
||||
- **Attachment upload**: Download + upload attachment ke external storage
|
||||
- **AI moderation**: Analisis pesan opsional via LLM, auto-delete, queue management
|
||||
- **Dashboard**: Messages feed, AI analysis review, voice connection, music player, recordings, user/channel stats
|
||||
- **Media playback**: Playback dari URL, file lokal, YouTube, Spotify
|
||||
- **WebSocket**: Real-time event streaming untuk semua aktivitas
|
||||
- **Public API**: Semua endpoint REST dan WebSocket dapat diakses tanpa autentikasi
|
||||
|
||||
## Struktur Proyek
|
||||
|
||||
```
|
||||
services/
|
||||
├── backend/ # Express 5 REST API + WebSocket server
|
||||
│ ├── src/modules/ # Feature modules (messages, voice, media, dll)
|
||||
│ └── src/http/ # Express app setup, middleware
|
||||
├── discord-gateway/ # Discord client, voice recording, AI analysis
|
||||
│ ├── src/modules/ # message-capture, voice-recording, ai-moderation
|
||||
│ └── src/shared/ # Config, database, Discord client
|
||||
└── frontend/ # Next.js 16 dashboard (static export)
|
||||
├── src/app/ # Pages (login, dashboard tabs)
|
||||
├── src/features/ # Feature components (dashboard, live, messages)
|
||||
└── src/lib/ # API client, WebSocket, types
|
||||
packages/
|
||||
└── shared/ # Shared types, errors, logger, utilities
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
PostgreSQL via Drizzle ORM. Migrasi:
|
||||
|
||||
```bash
|
||||
pnpm run db:generate # Generate migration
|
||||
pnpm run db:migrate # Apply migration
|
||||
pnpm run db:studio # Drizzle Studio
|
||||
```
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Backend broadcast event berikut ke frontend via WebSocket:
|
||||
|
||||
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
|
||||
- `attachment_created`, `attachment_uploaded`
|
||||
- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded`
|
||||
- `voice_active_user`, `voice_pcm_data`
|
||||
- `media_state`
|
||||
- `reaction_*`, `thread_*`, `presence_updated`, `guild_member_*`
|
||||
+9
-3
@@ -35,12 +35,15 @@
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off",
|
||||
"useIterableCallbackReturn": "off",
|
||||
"noArrayIndexKey": "warn"
|
||||
"noArrayIndexKey": "off",
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useSemanticElements": "off",
|
||||
"useButtonType": "off",
|
||||
"noAutofocus": "off"
|
||||
"noAutofocus": "off",
|
||||
"useMediaCaption": "off",
|
||||
"noStaticElementInteractions": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "warn"
|
||||
@@ -50,7 +53,10 @@
|
||||
},
|
||||
"correctness": {
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"noUnusedFunctionParameters": "warn"
|
||||
"noUnusedFunctionParameters": "warn",
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedPrivateClassMembers": "warn"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
|
||||
@@ -1,542 +0,0 @@
|
||||
# CI/CD Overhaul Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Migrate from hybrid CI/CD (GitHub Actions + GitLab CI + hot-deploy) to single Gitea CI pipeline with container registry — VPS pulls only.
|
||||
|
||||
**Architecture:** Three Docker images (backend, discord-gateway, proxy) built in Gitea CI, pushed to `git.imrnes.team/MythEclipse/GMW/*`, VPS pulls and restarts via SSH. No more hot-deploy bind-mounts.
|
||||
|
||||
**Tech Stack:** Gitea CI (Act Runner, GitHub Actions-compatible syntax), Docker Buildx, Gitea Container Registry, appleboy/ssh-action
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Docker images must be self-contained (no bind-mount overlay at runtime)
|
||||
- All three images must be built from monorepo root using `infra/docker/Dockerfile.*`
|
||||
- Frontend static export built inside proxy Dockerfile (multi-stage, Next.js → Nginx)
|
||||
- Gitea CI variables: GITEA_REGISTRY_TOKEN (secret), VPS_HOST (secret), VPS_USER (secret), VPS_SSH_KEY (secret), ENV_FILE (secret), GITEA_REGISTRY (variable)
|
||||
- Registry URL: `git.imrnes.team/MythEclipse/GMW/`
|
||||
- Work on `main` branch only
|
||||
- Must preserve voice recordings volume persistence across container restarts
|
||||
|
||||
---
|
||||
### Task 1: Create Gitea CI workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/deploy.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Dockerfiles at `infra/docker/Dockerfile.{backend,discord-gateway,proxy}`
|
||||
- Produces: Docker images pushed to `git.imrnes.team/MythEclipse/GMW/bete-*:latest` and `:{sha}`
|
||||
- Depends on: Task 2 (proxy Dockerfile), Task 3 (backend Dockerfile) — but workflow can reference files that are being written in the same commit
|
||||
|
||||
- [ ] **Step 1: Create `.gitea/workflows/` directory and `deploy.yml`**
|
||||
|
||||
```bash
|
||||
mkdir -p .gitea/workflows
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the workflow file**
|
||||
|
||||
Create `.gitea/workflows/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: Build & Deploy
|
||||
run-name: "Build & Deploy ${{ gitea.sha }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
service: [backend, discord-gateway, proxy]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ vars.GITEA_REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build & Push ${{ matrix.service }}
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: infra/docker/Dockerfile.${{ matrix.service }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:${{ gitea.sha }}
|
||||
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:latest
|
||||
cache-from: type=gha,scope=bete-${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=bete-${{ matrix.service }}
|
||||
build-args: |
|
||||
VITE_BE_API_URL=https://imphnen.asepharyana.my.id
|
||||
VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Deploy to VPS
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
env:
|
||||
ENV_FILE: ${{ secrets.ENV_FILE }}
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
envs: ENV_FILE
|
||||
script: |
|
||||
set -eu
|
||||
APP_DIR=/opt/imphenbot
|
||||
cd "$APP_DIR/infra/docker"
|
||||
printf '%s\n' "$ENV_FILE" | tr -d '\r' > .env
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
Note: Gitea's Act Runner supports `gitea.*` context variables (`gitea.sha`, `gitea.actor`, `gitea.ref`). If `gitea.*` vars don't resolve, fall back to `github.*` equivalents (Act Runner emulates GitHub context).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/deploy.yml
|
||||
git commit -m "ci: add Gitea CI workflow for build & deploy
|
||||
|
||||
Gitea CI builds three Docker images (backend, discord-gateway, proxy),
|
||||
pushes to Gitea Container Registry, then deploys to VPS via SSH pull.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 2: Rewrite Dockerfile.proxy for Next.js static export
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `infra/docker/Dockerfile.proxy`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `services/frontend/` (Next.js app), `packages/shared/` (workspace dep), `infra/docker/nginx/nginx.conf`
|
||||
- Produces: Nginx image serving Next.js static export at `/usr/share/nginx/html/`
|
||||
|
||||
- [ ] **Step 1: Rewrite Dockerfile.proxy**
|
||||
|
||||
Replace entire content with:
|
||||
|
||||
```dockerfile
|
||||
# ---- Stage 1: Build Next.js static export ----
|
||||
FROM node:22-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Install build essentials for native deps
|
||||
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
|
||||
python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy dependency manifests first for layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY services/frontend/package.json ./services/frontend/package.json
|
||||
COPY services/frontend/tsconfig.json ./services/frontend/tsconfig.json
|
||||
|
||||
# Install dependencies (frontend + shared)
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --filter './packages/shared' --filter './services/frontend'
|
||||
|
||||
# Copy source code
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY services/frontend/ ./services/frontend/
|
||||
|
||||
# Pass API/WS URLs as build args for the frontend
|
||||
ARG VITE_BE_API_URL
|
||||
ARG VITE_BE_WS_URL
|
||||
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
|
||||
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
|
||||
|
||||
# Build shared lib first, then frontend static export
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm --filter './packages/shared' run build
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm --filter frontend run build
|
||||
|
||||
# ---- Stage 2: Nginx ----
|
||||
FROM nginx:alpine
|
||||
|
||||
# Nginx config (API/WS proxy + static file serving)
|
||||
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Static export from frontend builder
|
||||
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate nginx.conf handles static files correctly**
|
||||
|
||||
Read and confirm `infra/docker/nginx/nginx.conf`.
|
||||
|
||||
```bash
|
||||
cat infra/docker/nginx/nginx.conf
|
||||
```
|
||||
|
||||
Verify it has:
|
||||
- Static file location with `try_files $uri /index.html` (SPA fallback)
|
||||
- `/api` and `/ws` proxied to `http://backend:3000`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/docker/Dockerfile.proxy
|
||||
git commit -m "docker(proxy): rewrite for Next.js static export
|
||||
|
||||
Replaced stale Rust WASM build with multi-stage Docker build:
|
||||
stage 1 builds Next.js static export, stage 2 serves via Nginx.
|
||||
Includes VITE_BE_API_URL/VITE_BE_WS_URL build args.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 3: Add build args to Dockerfile.backend
|
||||
|
||||
**Files:**
|
||||
- Modify: `infra/docker/Dockerfile.backend`
|
||||
|
||||
- [ ] **Step 1: Add VITE build args to Dockerfile.backend**
|
||||
|
||||
Insert after `WORKDIR /app`:
|
||||
|
||||
```dockerfile
|
||||
# Build args for frontend API URLs (passed through for future use)
|
||||
ARG VITE_BE_API_URL
|
||||
ARG VITE_BE_WS_URL
|
||||
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
|
||||
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
|
||||
```
|
||||
|
||||
Note: These are consumed by the proxy Dockerfile (Task 2), not needed by backend itself but passed through the CI workflow to all three images for consistency.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/docker/Dockerfile.backend
|
||||
git commit -m "docker(backend): add VITE_BE_API_URL and VITE_BE_WS_URL build args
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 4: Rewrite docker-compose.yml for Gitea registry + no bind-mounts
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `infra/docker/docker-compose.yml`
|
||||
|
||||
- [ ] **Step 1: Write new docker-compose.yml**
|
||||
|
||||
Replace entire content:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
proxy:
|
||||
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-proxy:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-proxy
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.my.id`)"
|
||||
- "traefik.http.routers.imphenbot.entrypoints=websecure"
|
||||
- "traefik.http.routers.imphenbot.tls=true"
|
||||
- "traefik.http.services.imphenbot.loadbalancer.server.port=80"
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
backend:
|
||||
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-backend:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
WEBSERVER_PORT: 3000
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 15s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
discord-gateway:
|
||||
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-discord-gateway:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-discord-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- recordings:/app/recordings
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
networks:
|
||||
- app-shared-net
|
||||
|
||||
volumes:
|
||||
recordings:
|
||||
|
||||
networks:
|
||||
app-shared-net:
|
||||
name: app-shared-net
|
||||
external: true
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Image refs: `registry.gitlab.com/...` → `${GITEA_REGISTRY}/MythEclipse/GMW/...`
|
||||
- Removed all bind-mount volumes: `./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`
|
||||
- Changed `./recordings` bind-mount → named volume `recordings:` (persists across restarts)
|
||||
- Added `depends_on: backend` to proxy (proxy needs backend for API/WS, though Nginx handles startup gracefully)
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/docker/docker-compose.yml
|
||||
git commit -m "docker(compose): switch to Gitea registry, remove bind-mounts
|
||||
|
||||
Images now come from git.imrnes.team/MythEclipse/GMW. All hot-deploy
|
||||
bind-mounts removed — containers are fully self-contained. Voice
|
||||
recordings use a named volume instead of bind-mount.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 5: Create lightweight deploy.sh
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy.sh`
|
||||
|
||||
- [ ] **Step 1: Write deploy.sh**
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INFRA_DIR="$SCRIPT_DIR/infra/docker"
|
||||
|
||||
: "${VPS_HOST:?required}"
|
||||
: "${VPS_USER:?required}"
|
||||
: "${VPS_SSH_KEY:?required}"
|
||||
|
||||
echo "=== Deploy to $VPS_HOST ==="
|
||||
|
||||
# Copy local .env if it exists (overrides CI env)
|
||||
if [ -f "$INFRA_DIR/.env" ]; then
|
||||
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
|
||||
fi
|
||||
|
||||
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
|
||||
set -eu
|
||||
cd /opt/imphenbot/infra/docker
|
||||
echo "=== Pulling images ==="
|
||||
docker compose pull
|
||||
echo "=== Restarting containers ==="
|
||||
docker compose up -d --remove-orphans
|
||||
echo "=== Cleaning up ==="
|
||||
docker image prune -f
|
||||
echo "=== Active containers ==="
|
||||
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||
REMOTESCRIPT
|
||||
|
||||
echo "=== Deploy complete ==="
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make executable**
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy.sh
|
||||
git commit -m "chore: rewrite deploy.sh as lightweight SSH pull script
|
||||
|
||||
Replaced hot-deploy tar-pipe script with simple SSH-based deploy
|
||||
that pulls latest images from Gitea registry and restarts containers.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 6: Disable old CI files
|
||||
|
||||
**Files:**
|
||||
- Disable: `.github/workflows/deploy-docker.yml`
|
||||
- Keep: `.gitlab-ci.yml` if exists (already may have been removed)
|
||||
|
||||
- [ ] **Step 1: Rename GitHub Actions workflow to .disabled**
|
||||
|
||||
```bash
|
||||
mv .github/workflows/deploy-docker.yml .github/workflows/deploy-docker.yml.disabled
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove docker compose file's old frontend-dist directory from git** (if tracked)
|
||||
|
||||
```bash
|
||||
# Check if frontend-dist is tracked (it should be gitignored, but check)
|
||||
git ls-files infra/docker/frontend-dist 2>/dev/null || echo "Not tracked — OK"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/deploy-docker.yml.disabled
|
||||
git rm --cached .github/workflows/deploy-docker.yml 2>/dev/null || true
|
||||
git commit -m "ci: disable GitHub Actions workflow
|
||||
|
||||
Renamed to .disabled. All CI now goes through Gitea CI (.gitea/workflows/).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 7: Update .gitignore
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: Add .gitea exclusion note and any missing entries**
|
||||
|
||||
Read current `.gitignore`:
|
||||
|
||||
```bash
|
||||
cat .gitignore
|
||||
```
|
||||
|
||||
Then append (only if not already present):
|
||||
|
||||
```
|
||||
# Gitea workflow logs (local runners)
|
||||
.gitea/workflows/*.log
|
||||
```
|
||||
|
||||
The `.gitea/workflows/` YAML files themselves should be tracked in git.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: update gitignore for Gitea CI artifacts
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
### Task 8: Push and verify CI pipeline
|
||||
|
||||
- [ ] **Step 1: Verify all changes**
|
||||
|
||||
```bash
|
||||
git status
|
||||
git log --oneline -10
|
||||
```
|
||||
|
||||
Expected: clean working tree, all 7 commits ready to push.
|
||||
|
||||
- [ ] **Step 2: Push to main**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Monitor CI run**
|
||||
|
||||
Watch Gitea CI at `https://git.imrnes.team/MythEclipse/GMW/actions`.
|
||||
|
||||
Expected outcome:
|
||||
1. `build-and-push` job runs 3 matrix builds (backend, discord-gateway, proxy) in parallel (max 2)
|
||||
2. Each image is pushed to `git.imrnes.team/MythEclipse/GMW/bete-*` with both `:latest` and `:{sha}` tags
|
||||
3. `deploy` job SSHes into VPS, pulls images, restarts containers
|
||||
4. All 3 containers `imphenbot-proxy`, `imphenbot-backend`, `imphenbot-discord-gateway` are running
|
||||
|
||||
- [ ] **Step 4: Verify containers on VPS**
|
||||
|
||||
```bash
|
||||
# SSH into VPS and check
|
||||
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
|
||||
docker ps --filter 'name=imphenbot' --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||
docker compose -f /opt/imphenbot/infra/docker/docker-compose.yml ps
|
||||
"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify no hot-deploy artifacts remain**
|
||||
|
||||
```bash
|
||||
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
|
||||
ls -la /opt/imphenbot/infra/docker/ | grep -E 'dist$' || echo 'No dist dirs — clean'
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
## Rollback
|
||||
|
||||
If the pipeline fails at any point:
|
||||
|
||||
1. **Fix and re-push**: Edit the broken file, commit, push to main — CI re-runs automatically
|
||||
2. **Emergency rollback**: SSH to VPS, run `docker compose up -d` with a known-good IMAGE_TAG:
|
||||
```bash
|
||||
IMAGE_TAG=<last-working-sha> docker compose up -d
|
||||
```
|
||||
3. **Restore old CI**: Move `.github/workflows/deploy-docker.yml.disabled` back and push
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,736 +0,0 @@
|
||||
# Backend & Gateway Refactoring — Phase 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Clean up ~130 lines of dead/duplicate code, consolidate duplicated database initialization, and simplify the MessageStore layering in discord-gateway.
|
||||
|
||||
**Architecture:** Three independent tasks that can be done in any order. Task 1 consolidates database pool/drizzle init into `@bete/shared` so both services use one canonical pattern. Task 2 removes backward-compat function wrappers from `messageStore.ts`. Task 3 deletes dead files and functions.
|
||||
|
||||
**Tech Stack:** TypeScript, Node.js, Drizzle ORM, PostgreSQL, pnpm workspace
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All imports use `.js` extensions (ESM convention)
|
||||
- Follow existing code style (Biome, 2-space indent)
|
||||
- Keep `@bete/shared` as the single source of truth for shared infrastructure
|
||||
- No package.json changes needed — `@bete/shared` already has `drizzle-orm` and `pg` as dependencies
|
||||
- Do not change any business logic — only structural refactoring
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Consolidate Database Initialization into `@bete/shared`
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/shared/src/database/init.ts`
|
||||
- Modify: `packages/shared/src/database/pool.ts` — add `getPool()` export
|
||||
- Modify: `packages/shared/src/index.ts` — export new `./database/init.js`
|
||||
- Modify: `packages/shared/package.json` — add `"./database/init"` export entry
|
||||
- Modify: `services/backend/src/shared/database/index.ts` — re-export from shared
|
||||
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — re-export from shared
|
||||
- Delete: (functions migrate, no file deletion here — both local files stay as thin wrappers)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `@bete/shared/database/init` exports:
|
||||
- `let db: ReturnType<typeof drizzle> | null` (module-level, for getDatabase())
|
||||
- `let rawPool: Pool | null` (module-level, for getPool())
|
||||
- `initializeDatabase(schema?: Record<string, unknown>): Promise<ReturnType<typeof drizzle>>` — creates pool via `createPoolFromConfig`, wraps with `drizzle()`. Accepts optional schema object (gateway needs it, backend doesn't). Reads config from env/config module internally.
|
||||
- `getDatabase(): ReturnType<typeof drizzle>` — throws if not initialized
|
||||
- `getPool(): Pool` — returns raw pool for raw SQL queries, throws if not initialized
|
||||
- `closeDatabase(): Promise<void>` — closes pool and nullifies references
|
||||
- `executeAll(sql: string, params?: unknown[]): Promise<unknown[]>` — raw SQL query, returns all rows
|
||||
- `executeGet(sql: string, params?: unknown[]): Promise<unknown>` — raw SQL query, returns first row or null
|
||||
- `withDatabaseClient<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`
|
||||
|
||||
- [ ] **Step 1: Create `packages/shared/src/database/init.ts`**
|
||||
|
||||
This is the canonical database initialization module. It merges what both services currently do:
|
||||
|
||||
```typescript
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { closePool, createPoolFromConfig } from "@bete/shared/database/pool";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
const logger = createChildLogger("database.init");
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
let rawPool: Pool | null = null;
|
||||
|
||||
export async function initializeDatabase(schema?: Record<string, unknown>) {
|
||||
if (db !== null) return db;
|
||||
|
||||
const pool = config.DATABASE_URL
|
||||
? createPoolFromConfig({
|
||||
url: config.DATABASE_URL,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
})
|
||||
: createPoolFromConfig({
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
user: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
database: config.POSTGRES_DB,
|
||||
min: config.POSTGRES_POOL_MIN,
|
||||
max: config.POSTGRES_POOL_MAX,
|
||||
});
|
||||
|
||||
rawPool = pool;
|
||||
db = drizzle(pool, schema ? { schema } : undefined);
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
logger.info("Database connection successful");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to connect to database");
|
||||
throw err;
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
if (db === null) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getPool() {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
return rawPool;
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
if (rawPool !== null) {
|
||||
await closePool(rawPool);
|
||||
}
|
||||
rawPool = null;
|
||||
db = null;
|
||||
logger.info("Database connection closed");
|
||||
}
|
||||
|
||||
function convertPlaceholdersForPostgres(sql: string) {
|
||||
let i = 0;
|
||||
return sql.replace(/\?/g, () => `$${++i}`);
|
||||
}
|
||||
|
||||
export async function executeAll(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function executeGet(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function withDatabaseClient<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const client = await rawPool.connect();
|
||||
try {
|
||||
return await callback(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This uses `config` from `@bete/shared/config`. The backend's config proxies to that already (`services/backend/src/shared/config/index.ts` re-exports from `@bete/shared/config`). The gateway's config at `services/discord-gateway/src/shared/config/config.ts` has the same field names but is its own Zod schema. Since `@bete/shared/config` doesn't have the PostgreSQL pool config fields currently, we need to check what it exports.
|
||||
|
||||
Actually — `@bete/shared/config` may not have `POSTGRES_HOST` etc. Let me adjust: the `initializeDatabase` function should accept config values as parameters instead of reading from a shared config.
|
||||
|
||||
Revised approach for `packages/shared/src/database/init.ts`:
|
||||
|
||||
```typescript
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { closePool, createPoolFromConfig } from "./pool.js";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
|
||||
const logger = createChildLogger("database.init");
|
||||
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
let rawPool: Pool | null = null;
|
||||
|
||||
export interface DatabaseConfig {
|
||||
DATABASE_URL?: string;
|
||||
POSTGRES_HOST?: string;
|
||||
POSTGRES_PORT?: number;
|
||||
POSTGRES_USER?: string;
|
||||
POSTGRES_PASSWORD?: string;
|
||||
POSTGRES_DB?: string;
|
||||
POSTGRES_POOL_MIN?: number;
|
||||
POSTGRES_POOL_MAX?: number;
|
||||
}
|
||||
|
||||
export async function initializeDatabase(
|
||||
cfg: DatabaseConfig,
|
||||
schema?: Record<string, unknown>,
|
||||
) {
|
||||
if (db !== null) return db;
|
||||
|
||||
const pool = cfg.DATABASE_URL
|
||||
? createPoolFromConfig({
|
||||
url: cfg.DATABASE_URL,
|
||||
min: cfg.POSTGRES_POOL_MIN,
|
||||
max: cfg.POSTGRES_POOL_MAX,
|
||||
})
|
||||
: createPoolFromConfig({
|
||||
host: cfg.POSTGRES_HOST,
|
||||
port: cfg.POSTGRES_PORT,
|
||||
user: cfg.POSTGRES_USER,
|
||||
password: cfg.POSTGRES_PASSWORD,
|
||||
database: cfg.POSTGRES_DB,
|
||||
min: cfg.POSTGRES_POOL_MIN,
|
||||
max: cfg.POSTGRES_POOL_MAX,
|
||||
});
|
||||
|
||||
rawPool = pool;
|
||||
db = drizzle(pool, schema ? { schema } : undefined);
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
logger.info("Database connection successful");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to connect to database");
|
||||
throw err;
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
if (db === null) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getPool() {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
return rawPool;
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
if (rawPool !== null) {
|
||||
await closePool(rawPool);
|
||||
}
|
||||
rawPool = null;
|
||||
db = null;
|
||||
logger.info("Database connection closed");
|
||||
}
|
||||
|
||||
function convertPlaceholdersForPostgres(sql: string) {
|
||||
let i = 0;
|
||||
return sql.replace(/\?/g, () => `$${++i}`);
|
||||
}
|
||||
|
||||
export async function executeAll(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function executeGet(sql: string, params?: unknown[]) {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const query = convertPlaceholdersForPostgres(sql);
|
||||
const result = await rawPool.query(query, params || []);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function withDatabaseClient<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!rawPool) {
|
||||
throw new Error("Database not initialized. Call initializeDatabase() first.");
|
||||
}
|
||||
const client = await rawPool.connect();
|
||||
try {
|
||||
return await callback(client);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add export to `packages/shared/src/index.ts`**
|
||||
|
||||
```typescript
|
||||
export * from "./database/init.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add export to `packages/shared/package.json`**
|
||||
|
||||
```json
|
||||
"./database/init": "./dist/database/init.js",
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build the shared package to verify it compiles**
|
||||
|
||||
```bash
|
||||
cd /home/code/GMW/packages/shared
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Rewrite `services/backend/src/shared/database/index.ts`**
|
||||
|
||||
Change to a thin wrapper that imports from `@bete/shared/database/init` and passes the backend's config:
|
||||
|
||||
```typescript
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, getPool as sharedGetPool, closeDatabase as sharedCloseDb } from "@bete/shared/database/init";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
const logger = createChildLogger("database");
|
||||
|
||||
const dbConfig = {
|
||||
DATABASE_URL: config.DATABASE_URL,
|
||||
POSTGRES_HOST: config.POSTGRES_HOST,
|
||||
POSTGRES_PORT: config.POSTGRES_PORT,
|
||||
POSTGRES_USER: config.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
|
||||
POSTGRES_DB: config.POSTGRES_DB,
|
||||
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
|
||||
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
|
||||
};
|
||||
|
||||
export async function initializeDatabase() {
|
||||
logger.info("Initializing database");
|
||||
return sharedInit(dbConfig);
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
return sharedGetDb();
|
||||
}
|
||||
|
||||
export function getPool() {
|
||||
return sharedGetPool();
|
||||
}
|
||||
|
||||
export async function closeDatabase() {
|
||||
logger.info("Closing database");
|
||||
return sharedCloseDb();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Rewrite `services/discord-gateway/src/shared/database/drizzle.ts`**
|
||||
|
||||
Change to a thin wrapper:
|
||||
|
||||
```typescript
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, closeDatabase as sharedCloseDb, executeAll as sharedExecAll, executeGet as sharedExecGet, withDatabaseClient as sharedWithClient } from "@bete/shared/database/init";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const logger = createChildLogger("drizzle");
|
||||
|
||||
const dbConfig = {
|
||||
DATABASE_URL: config.DATABASE_URL,
|
||||
POSTGRES_HOST: config.POSTGRES_HOST,
|
||||
POSTGRES_PORT: config.POSTGRES_PORT,
|
||||
POSTGRES_USER: config.POSTGRES_USER,
|
||||
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
|
||||
POSTGRES_DB: config.POSTGRES_DB,
|
||||
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
|
||||
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
|
||||
};
|
||||
|
||||
export async function initializeDatabase() {
|
||||
return sharedInit(dbConfig, schema);
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
return sharedGetDb();
|
||||
}
|
||||
|
||||
export { sharedCloseDb as closeDatabase };
|
||||
export { sharedExecAll as executeAll, sharedExecGet as executeGet, sharedWithClient as withDatabaseClient };
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run typecheck on all packages to verify**
|
||||
|
||||
```bash
|
||||
cd /home/code/GMW
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/database/init.ts packages/shared/src/index.ts packages/shared/package.json
|
||||
git add services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
|
||||
git commit -m "refactor: consolidate database initialization into @bete/shared/database/init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove Backward-Compat Function Wrappers from MessageStore
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/modules/message-capture/messageStore.ts` — remove lines 310-398 (backward-compat wrappers), export singleton directly
|
||||
- Modify: `services/discord-gateway/src/modules/message-capture/index.ts` — update re-exports to use `messageStore` singleton
|
||||
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` — update imports to use `messageStore.methodName()`
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts` — update imports
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts` — update imports
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts` — update imports
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts` — update imports
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts` — update imports (uses `getConversationContextBefore` and `updateMessagesAIAnalysisBulk`)
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts` — update imports (uses many functions)
|
||||
- Possibly modify: other files that import the wrapper functions
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Existing `MessageStore` class methods (unchanged signatures)
|
||||
- Produces: Singleton `messageStore` instance as the single export point
|
||||
|
||||
The key insight: the backward-compat wrappers at lines 310-398 of `messageStore.ts` are function-level exports that delegate to `getInstance()`. Every importer can instead import the singleton `messageStore` instance and call methods on it directly.
|
||||
|
||||
Current importers of wrapper functions:
|
||||
|
||||
| File | Functions Used |
|
||||
|------|---------------|
|
||||
| `messageCapture.ts` | `getMessageById`, `insertMessageEdit`, `updateMessageAsEdited`, `updateMessageAsDeleted`, `upsertMessageForCapture` |
|
||||
| `batchProcessor.ts` | `updateMessagesAIAnalysisBulk` |
|
||||
| `batchScheduler.ts` | `getPendingMessagesByConversation` |
|
||||
| `individualFallbackProcessor.ts` | `updateMessagesAIAnalysisBulk` |
|
||||
| `moderationBuilders.ts` | `getMessageById` |
|
||||
| `aiAnalysisWorker.ts` | `getConversationContextBefore`, `updateMessagesAIAnalysisBulk` |
|
||||
| `aiAnalyzer.ts` | `getConversationKeysWithIncompleteAnalysis`, `getIncompleteMessagesByConversation`, `getMessageById`, `getPendingConversationKeys`, `updateMessageAIAnalysis` |
|
||||
|
||||
- [ ] **Step 1: Modify `messageStore.ts`** — replace backward-compat wrappers with a singleton export
|
||||
|
||||
Replace lines 22-33 (lazy singleton pattern) and lines 310-398 (wrapper functions) with:
|
||||
|
||||
```typescript
|
||||
// ─── Singleton instance ─────────────────────────────────────────────────────
|
||||
|
||||
const logger = createChildLogger("message-store");
|
||||
const database = getDatabase() as unknown as NodePgDatabase<typeof schema>;
|
||||
export const messageStore = new MessageStore(database, logger);
|
||||
```
|
||||
|
||||
Then remove everything from line 310 onward (the backward-compat function wrappers section).
|
||||
|
||||
- [ ] **Step 2: Update `message-capture/index.ts`**
|
||||
|
||||
Change the re-exports from individual functions to the `messageStore` singleton:
|
||||
|
||||
```typescript
|
||||
export { messageStore } from "../message-capture/messageStore.js";
|
||||
export {
|
||||
getDisplayContent,
|
||||
getMessageLocation,
|
||||
getMessageMetadata,
|
||||
} from "../message-capture/messageMetadata.js";
|
||||
// ... rest unchanged
|
||||
```
|
||||
|
||||
Also remove the individual function re-exports since they no longer exist.
|
||||
|
||||
- [ ] **Step 3: Update `messageCapture.ts`**
|
||||
|
||||
Change imports from:
|
||||
```typescript
|
||||
import {
|
||||
getMessageById,
|
||||
insertMessageEdit,
|
||||
upsertMessageForCapture,
|
||||
updateMessageAsDeleted,
|
||||
updateMessageAsEdited,
|
||||
} from "./messageStore.js";
|
||||
```
|
||||
To:
|
||||
```typescript
|
||||
import { messageStore } from "./messageStore.js";
|
||||
```
|
||||
|
||||
Then update every call site:
|
||||
- `upsertMessageForCapture(messageRecord)` → `messageStore.upsertMessageForCapture(messageRecord)`
|
||||
- `insertMessageEdit(...)` → `messageStore.insertMessageEdit(...)`
|
||||
- `updateMessageAsEdited(...)` → `messageStore.updateMessageAsEdited(...)`
|
||||
- `updateMessageAsDeleted(...)` → `messageStore.updateMessageAsDeleted(...)`
|
||||
- `getMessageById(...)` → `messageStore.getMessageById(...)`
|
||||
|
||||
- [ ] **Step 4: Update `batchProcessor.ts`**
|
||||
|
||||
Change from:
|
||||
```typescript
|
||||
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
|
||||
```
|
||||
To:
|
||||
```typescript
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
|
||||
Then update call sites:
|
||||
- `updateMessagesAIAnalysisBulk(updates)` → `messageStore.messages.updateMessagesAIAnalysisBulk(updates)`
|
||||
|
||||
Wait — `updateMessagesAIAnalysisBulk` is actually defined in `MessagesAnalysis` class, which is called via `MessageStore` → `MessagesDb` → `MessagesAnalysis`. Let me check the actual delegation chain.
|
||||
|
||||
Looking at the wrapper functions:
|
||||
```typescript
|
||||
export const updateMessagesAIAnalysisBulk = (
|
||||
updates: Array<{ messageId: string; result: AIAnalysisUpdate }>,
|
||||
): Promise<MessageRecord[]> =>
|
||||
getInstance().updateMessagesAIAnalysisBulk(updates);
|
||||
```
|
||||
|
||||
And in the class:
|
||||
```typescript
|
||||
class MessageStore {
|
||||
readonly messages: MessagesDb;
|
||||
// ...
|
||||
}
|
||||
|
||||
class MessagesDb {
|
||||
readonly analysis: MessagesAnalysis;
|
||||
// ...
|
||||
updateMessagesAIAnalysisBulk(...) {
|
||||
return this.analysis.updateMessagesAIAnalysisBulk(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So the call chain is: `messageStore.messages.updateMessagesAIAnalysisBulk()`. But actually, looking at `MessagesDb`, it might have its own `updateMessagesAIAnalysisBulk` that delegates to `this.analysis.updateMessagesAIAnalysisBulk()`. Let me verify...
|
||||
|
||||
Actually, for simplicity and to minimize changes, let me look at whether `MessagesDb` has `updateMessagesAIAnalysisBulk` or if only the wrapper has it.
|
||||
|
||||
Let me check:
|
||||
|
||||
Actually I already read that `MessagesDb` has methods. Let me look at what methods `MessagesDb` exposes vs the wrapper functions.
|
||||
|
||||
Instead of guessing, the safe approach is to keep the thin function wrappers but simplify them. Actually, a better approach for this task:
|
||||
|
||||
**Revised approach:** Instead of making all importers use `messageStore.messages.analysis.methodName()`, add all the forwarded methods directly to the `MessageStore` class (which it already does for most), and just have external files import the singleton and call `messageStore.methodName()`.
|
||||
|
||||
Let me check what methods `MessageStore` already has vs what's only available as backward-compat wrappers:
|
||||
|
||||
Looking at the code:
|
||||
- `insertMessageEdit` — EXISTS in MessageStore class (line 54)
|
||||
- `upsertMessageForCapture` — EXISTS in MessageStore class
|
||||
- `updateMessageAsEdited` — EXISTS in MessageStore class
|
||||
- `updateMessageAsDeleted` — EXISTS in MessageStore class
|
||||
- `getMessagesByChannel` — EXISTS in MessageStore class
|
||||
- `updateMessageAIAnalysis` — EXISTS in MessageStore class
|
||||
- `updateMessagesAIAnalysisBulk` — EXISTS in MessageStore class
|
||||
- `getPendingAIAnalysisMessages` — EXISTS in MessageStore class
|
||||
- `getMessageById` — EXISTS in MessageStore class
|
||||
- `listMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
|
||||
- `listReviewMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
|
||||
- `getConversationContextBefore` — EXISTS in MessageStore class
|
||||
- `getPendingMessagesByConversation` — EXISTS in MessageStore class
|
||||
- `getPendingConversationKeys` — EXISTS in MessageStore class
|
||||
- `getConversationKeysWithIncompleteAnalysis` — EXISTS in MessageStore class
|
||||
- `getIncompleteMessagesByConversation` — EXISTS in MessageStore class
|
||||
|
||||
So every function wrapper has a corresponding method on `MessageStore` class. The change is straightforward.
|
||||
|
||||
Now, after creating the singleton `messageStore`, all importers just do `messageStore.updateMessagesAIAnalysisBulk(...)` instead of calling the bare function.
|
||||
|
||||
But there's one complication: `MessagesDb.updateMessagesAIAnalysisBulk` is actually calling `this.analysis.updateMessagesAIAnalysisBulk()`. Does the `MessageStore` class have its own direct `updateMessagesAIAnalysisBulk`? Let me check the class definition...
|
||||
|
||||
Actually, I already saw from the grep output that `MessageStore` class has `updateMessagesAIAnalysisBulk` — the wrapper says `getInstance().updateMessagesAIAnalysisBulk(updates)`, and the class has that method.
|
||||
|
||||
OK so the mapping is 1:1 between wrapper functions and MessageStore class methods. This is safe.
|
||||
|
||||
- [ ] **Step 5: Update `batchScheduler.ts`**
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
import { getPendingMessagesByConversation } from "../message-capture/messageStore.js";
|
||||
// After:
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
And call: `messageStore.getPendingMessagesByConversation(...)`
|
||||
|
||||
- [ ] **Step 6: Update `individualFallbackProcessor.ts`**
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
|
||||
// After:
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
And call: `messageStore.updateMessagesAIAnalysisBulk(...)`
|
||||
|
||||
- [ ] **Step 7: Update `moderationBuilders.ts`**
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
import { getMessageById } from "../message-capture/messageStore.js";
|
||||
// After:
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
And call: `messageStore.getMessageById(...)`
|
||||
|
||||
- [ ] **Step 8: Update `aiAnalysisWorker.ts`**
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
import { getConversationContextBefore, updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
|
||||
// After:
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
And update all call sites.
|
||||
|
||||
- [ ] **Step 9: Update `aiAnalyzer.ts`**
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
import {
|
||||
getConversationKeysWithIncompleteAnalysis,
|
||||
getIncompleteMessagesByConversation,
|
||||
getMessageById,
|
||||
getPendingConversationKeys,
|
||||
updateMessageAIAnalysis,
|
||||
} from "../message-capture/messageStore.js";
|
||||
// After:
|
||||
import { messageStore } from "../message-capture/messageStore.js";
|
||||
```
|
||||
And update all call sites.
|
||||
|
||||
- [ ] **Step 10: Update `message-capture/index.ts`**
|
||||
|
||||
Remove individual function re-exports, replace with `messageStore`:
|
||||
|
||||
```typescript
|
||||
export { messageStore } from "./messageStore.js";
|
||||
export {
|
||||
getDisplayContent,
|
||||
getMessageLocation,
|
||||
getMessageMetadata,
|
||||
} from "./messageMetadata.js";
|
||||
export type {
|
||||
AIRecommendedAction,
|
||||
AISeverity,
|
||||
AIStatus,
|
||||
AttachmentRecord,
|
||||
MessageRecord,
|
||||
VoiceSegmentRecord,
|
||||
} from "./types.js";
|
||||
export type { TextCaptureTarget } from "./messageCapture.js";
|
||||
export {
|
||||
captureMessage,
|
||||
registerMessageCapture,
|
||||
setEventBroadcaster,
|
||||
} from "./messageCapture.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd /home/code/GMW
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/message-capture/
|
||||
git add services/discord-gateway/src/modules/ai-moderation/
|
||||
git commit -m "refactor: remove backward-compat function wrappers from messageStore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove Dead Code
|
||||
|
||||
**Files:**
|
||||
- Delete: `services/backend/src/modules/response.ts` — empty deprecated file
|
||||
- Delete: `services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts`
|
||||
- Delete: `services/discord-gateway/src/modules/webhook-notifications/index.ts`
|
||||
- Delete: `services/discord-gateway/src/modules/webhook-notifications/` (directory)
|
||||
- Modify: `services/backend/src/ws/server.ts` — remove duplicate `broadcastBinaryToFrontend()` function, keep only `broadcastBinary()`
|
||||
|
||||
**Interfaces:**
|
||||
- None — these are deletions only, no consumer impact
|
||||
|
||||
- [ ] **Step 1: Delete `modules/response.ts`**
|
||||
|
||||
```bash
|
||||
rm /home/code/GMW/services/backend/src/modules/response.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Fix `ws/server.ts`** — remove duplicate `broadcastBinaryToFrontend`
|
||||
|
||||
In `ws/server.ts`, `broadcastBinaryToFrontend` (line 238) and `broadcastBinary` (line 267) do exactly the same thing. Replace the `broadcastBinaryToFrontend(data)` call on line 147 with a call to `broadcastBinary(data)`, then delete the `broadcastBinaryToFrontend` function.
|
||||
|
||||
Edit line 147:
|
||||
```typescript
|
||||
// Before:
|
||||
broadcastBinaryToFrontend(data);
|
||||
// After:
|
||||
broadcastBinary(data);
|
||||
```
|
||||
|
||||
Remove the `broadcastBinaryToFrontend` function (lines 238-248):
|
||||
```typescript
|
||||
// Remove this entire function:
|
||||
function broadcastBinaryToFrontend(data: Buffer) {
|
||||
for (const client of frontendClients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(data);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to send binary to frontend client");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Check if anything imports `webhook-notifications`**
|
||||
|
||||
```bash
|
||||
grep -rn "webhook-notifications\|webhookNotifier\|triggerWebhook" /home/code/GMW/services/ --include='*.ts' | grep -v "node_modules" | grep -v "services/discord-gateway/src/modules/webhook-notifications/"
|
||||
```
|
||||
|
||||
Expected: empty (confirmed earlier)
|
||||
|
||||
- [ ] **Step 4: Delete webhook-notifications module**
|
||||
|
||||
```bash
|
||||
rm -rf /home/code/GMW/services/discord-gateway/src/modules/webhook-notifications/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run typecheck to verify no broken imports**
|
||||
|
||||
```bash
|
||||
cd /home/code/GMW
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/backend/src/modules/response.ts services/backend/src/ws/server.ts
|
||||
git add services/discord-gateway/src/modules/webhook-notifications/
|
||||
git commit -m "chore: remove dead code (response.ts, broadcastBinaryToFrontend, webhook-notifications)"
|
||||
```
|
||||
@@ -1,579 +0,0 @@
|
||||
# Services Refactoring Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Refactor backend (4.2k lines) and discord-gateway (17.9k lines) for consistency, reduced file sizes, deduplication, and pattern uniformity across 11 phases.
|
||||
|
||||
**Architecture:** Phase1-3 target backend unchanged; Phase4-8 split large gateway files; Phase9 deduplicates shared database init; Phase10-11 are minor consolidation. Each phase is independently testable by verifying the service still compiles and runs.
|
||||
|
||||
**Tech Stack:** TypeScript (ESM), Express 5, ws, Discord.js selfbot, Drizzle ORM, Redis (ioredis), pino logger, Biome (formatter)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- All files use ESM (`.js` extensions in imports)
|
||||
- Biome formatter handles formatting — run `pnpm run format` after each phase
|
||||
- TypeScript strict mode — run `pnpm run typecheck` after each phase (for node services)
|
||||
- Logging uses `createChildLogger(context)` from `@bete/shared/logger`
|
||||
- Import via barrel files where available
|
||||
- No logic changes — pure refactoring
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Fix messages.controller.ts pattern (Phase 1)
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/backend/src/modules/messages/messages.controller.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `asyncHandler` from `../../shared/middlewares/index.js`
|
||||
- Produces: Same exported handler functions, but using decorator pattern
|
||||
|
||||
- [ ] **Step 1: Read current messages.controller.ts**
|
||||
|
||||
The file currently uses the convoluted pattern:
|
||||
```ts
|
||||
export function handleListMessages(req, res, next) {
|
||||
return asyncHandler(async (req, res) => {
|
||||
// ...
|
||||
})(req, res, next);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite all handlers to decorator pattern**
|
||||
|
||||
Replace every handler to use the clean decorator pattern:
|
||||
|
||||
```ts
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Request, Response } from "express";
|
||||
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) => {
|
||||
const channelId = String(req.params.channelId ?? "");
|
||||
if (!channelId) {
|
||||
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
const id = String(req.params.id ?? "");
|
||||
if (!id) {
|
||||
res.status(400).json({ error: "MISSING_ID" });
|
||||
return;
|
||||
}
|
||||
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 = String(req.query.guildId ?? "");
|
||||
if (!guildId) {
|
||||
res.status(400).json({ error: "MISSING_GUILD_ID" });
|
||||
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) => {
|
||||
const channelId = String(req.params.channelId ?? "");
|
||||
if (!channelId) {
|
||||
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
|
||||
return;
|
||||
}
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
NOTE: The old pattern used `requireParam` from middlewares to validate params. The new pattern uses simple string checks with early returns. This is equivalent since `requireParam` threw `ValidationError` which the errorHandler middleware catches — but for these handlers the decorator pattern can't throw synchronously in the handler wrapper; the `asyncHandler` catches async rejects. Early return with explicit error response is cleaner.
|
||||
|
||||
- [ ] **Step 3: Verify the module still compiles**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 4: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add services/backend/src/modules/messages/messages.controller.ts
|
||||
git commit -m "refactor(backend): fix messages.controller.ts to use decorator pattern
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Clean up response.ts usage (Phase 2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/backend/src/modules/health/health.controller.ts` (remove `success()` usage, use plain `res.json()`)
|
||||
- Modify: `services/backend/src/modules/response.ts` (deprecate/remove)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all response-producing route files
|
||||
- Produces: consistent plain `res.json()` pattern everywhere
|
||||
|
||||
- [ ] **Step 1: Check all places that import from response.ts**
|
||||
|
||||
Run: `grep -r 'from.*response\.js' services/backend/src/`
|
||||
|
||||
- [ ] **Step 2: Remove `success()` usage from health.controller.ts**
|
||||
|
||||
Replace:
|
||||
```ts
|
||||
import { success } from "../response.js";
|
||||
// ...
|
||||
res.status(status).json(success(result));
|
||||
```
|
||||
With:
|
||||
```ts
|
||||
res.status(status).json({ success: true, data: result });
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run biome format + typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add services/backend/src/modules/health/health.controller.ts services/backend/src/modules/response.ts
|
||||
git commit -m "refactor(backend): remove response.ts helpers, inline health response
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add ws/ barrel (Phase 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `services/backend/src/ws/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create barrel file**
|
||||
|
||||
```ts
|
||||
export { setBroadcastFunctions, clearBroadcastFunctions, broadcastEvent, broadcastBinary } from "./broadcast.js";
|
||||
export { startRedisBridge, stopRedisBridge } from "./redis-bridge.js";
|
||||
export { createWebSocketServer, closeWebSocketServer } from "./server.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/backend/src/ws/index.ts
|
||||
git commit -m "refactor(backend): add ws barrel index
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Split moderationPrompt.ts (Phase 4)
|
||||
|
||||
**Files:**
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/text-analysis.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/media-analysis.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/stickers.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/emojis.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/system.ts`
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts` (become barrel re-export)
|
||||
|
||||
- [ ] **Step 1: Read the full moderationPrompt.ts**
|
||||
|
||||
Read the file to identify all exports and their dependencies.
|
||||
|
||||
- [ ] **Step 2: Create `prompts/system.ts` — system prompt builder + shared helpers**
|
||||
|
||||
Move: `buildSystemPrompt` function, `sanitizeAiContent`, `escapeXml`, `buildCustomEmojiVisionPrompt`, any shared helper functions.
|
||||
|
||||
- [ ] **Step 3: Create `prompts/text-analysis.ts` — text moderation prompts**
|
||||
|
||||
Move: All text-specific prompt strings and builders.
|
||||
|
||||
- [ ] **Step 4: Create `prompts/media-analysis.ts` — image/video prompts**
|
||||
|
||||
Move: `buildGeneralImageVisionPrompt` and related media prompt builders.
|
||||
|
||||
- [ ] **Step 5: Create `prompts/stickers.ts` — sticker prompts**
|
||||
|
||||
Move: `buildStickerVisionPrompt`, `buildStickerTextOnlyWarning`.
|
||||
|
||||
- [ ] **Step 6: Create `prompts/emojis.ts` — emoji prompts**
|
||||
|
||||
Move: `buildCustomEmojiVisionPrompt` if it exists separately.
|
||||
|
||||
- [ ] **Step 7: Replace moderationPrompt.ts with barrel re-exports**
|
||||
|
||||
```ts
|
||||
export { buildSystemPrompt, sanitizeAiContent } from "./prompts/system.js";
|
||||
export { buildGeneralImageVisionPrompt } from "./prompts/media-analysis.js";
|
||||
export { buildStickerVisionPrompt, buildStickerTextOnlyWarning } from "./prompts/stickers.js";
|
||||
export { buildCustomEmojiVisionPrompt } from "./prompts/emojis.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
Expected: No errors. Existing importers continue to work via the barrel.
|
||||
|
||||
- [ ] **Step 9: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/prompts/ services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts
|
||||
git commit -m "refactor(gateway): split moderationPrompt.ts into domain-specific prompt files
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Split moderationOrchestrator.ts (Phase 5)
|
||||
|
||||
**Files:**
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts`
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts` (extract & re-export)
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/index.ts` (update exports if needed)
|
||||
|
||||
- [ ] **Step 1: Read full moderationOrchestrator.ts**
|
||||
|
||||
Map all exports and dependencies.
|
||||
|
||||
- [ ] **Step 2: Extract `runTextOnlyBatch` into `textBatchProcessor.ts`**
|
||||
|
||||
Move the function and its helper `buildCorrectedFewShotExamples`. Export it.
|
||||
|
||||
- [ ] **Step 3: Extract `runMediaBatch` into `mediaBatchProcessor.ts`**
|
||||
|
||||
Move the function and all its dependencies. Export it.
|
||||
|
||||
- [ ] **Step 4: Extract `runSimpleTextFallback` into `simpleFallback.ts`**
|
||||
|
||||
Move the function. Export it.
|
||||
|
||||
- [ ] **Step 5: Update moderationOrchestrator.ts**
|
||||
|
||||
Replace extracted functions with imports:
|
||||
```ts
|
||||
export { runTextOnlyBatch } from "./textBatchProcessor.js";
|
||||
export { runMediaBatch } from "./mediaBatchProcessor.js";
|
||||
export { runSimpleTextFallback } from "./simpleFallback.js";
|
||||
```
|
||||
Keep the `runModerationAnalysis` entry point function which orchestrates text + media + caching.
|
||||
|
||||
- [ ] **Step 6: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 7: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts
|
||||
git commit -m "refactor(gateway): split moderationOrchestrator into dedicated processors
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Split mediaAnalysisClient.ts (Phase 6)
|
||||
|
||||
**Files:**
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaCache.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts`
|
||||
- Create: `services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts`
|
||||
- Modify: `services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts` (become barrel)
|
||||
|
||||
- [ ] **Step 1: Read full mediaAnalysisClient.ts**
|
||||
|
||||
Map all exports and dependencies across the 826 lines.
|
||||
|
||||
- [ ] **Step 2: Extract all cache logic into `mediaCache.ts`**
|
||||
|
||||
Move: LRU cache, phash dedup, `getCachedMediaAnalysis`, `setCachedMediaAnalysis`, `computeImagePhash`, `deleteCachedMediaAnalysis`, `acquireMediaAnalysisLock`.
|
||||
|
||||
- [ ] **Step 3: Extract all download logic into `mediaDownloader.ts`**
|
||||
|
||||
Move: Image download, video download, ffmpeg frame extraction, temporary file handling.
|
||||
|
||||
- [ ] **Step 4: Extract vision LLM logic into `visionAnalyzer.ts`**
|
||||
|
||||
Move: Vision LLM calls, message preparation for vision, `prepareMediaMessage`.
|
||||
|
||||
- [ ] **Step 5: Update mediaAnalysisClient.ts to re-export**
|
||||
|
||||
```ts
|
||||
export { getCachedMediaAnalysis, setCachedMediaAnalysis, computeImagePhash } from "./mediaCache.js";
|
||||
export { downloadAndExtractFrame } from "./mediaDownloader.js";
|
||||
export { prepareMediaMessage, hasMediaContent } from "./visionAnalyzer.js";
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 7: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/mediaCache.ts services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts
|
||||
git commit -m "refactor(gateway): split mediaAnalysisClient into cache, downloader, and vision analyzer
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Extract retention cleanup from bootstrap.ts (Phase 7)
|
||||
|
||||
**Files:**
|
||||
- Create: `services/discord-gateway/src/app/retention.ts`
|
||||
- Modify: `services/discord-gateway/src/app/bootstrap.ts`
|
||||
|
||||
- [ ] **Step 1: Create `app/retention.ts`**
|
||||
|
||||
Move `deleteExpiredRecords` and `startRetentionCleanup` from `bootstrap.ts`:
|
||||
```ts
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { lt, inArray } from "drizzle-orm";
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { config } from "../shared/config/config.js";
|
||||
import { getDatabase } from "../shared/database/drizzle.js";
|
||||
import * as schema from "../shared/database/schema.js";
|
||||
import { messagesTable, attachmentsTable, voiceRecordingsTable } from "../shared/database/schema.js";
|
||||
|
||||
const log = createChildLogger("retention");
|
||||
|
||||
// ... move deleteExpiredRecords here ...
|
||||
|
||||
// ... move startRetentionCleanup here ...
|
||||
|
||||
export { startRetentionCleanup };
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove inline retention code from bootstrap.ts**
|
||||
|
||||
- Remove the `deleteExpiredRecords` function
|
||||
- Remove the `startRetentionCleanup` function
|
||||
- Add: `import { startRetentionCleanup } from "./retention.js";`
|
||||
- Replace the call: call `startRetentionCleanup()` directly
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 4: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/app/retention.ts services/discord-gateway/src/app/bootstrap.ts
|
||||
git commit -m "refactor(gateway): extract retention cleanup from bootstrap into dedicated module
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Consolidate EventBroadcaster (Phase 8)
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts`
|
||||
- Modify: `services/discord-gateway/src/modules/event-broadcaster/index.ts`
|
||||
|
||||
- [ ] **Step 1: Read current eventBroadcaster.ts**
|
||||
|
||||
Identify `RedisEventPublisher` and `EventBroadcaster` classes.
|
||||
|
||||
- [ ] **Step 2: Merge RedisEventPublisher into EventBroadcaster**
|
||||
|
||||
Inline `RedisEventPublisher` as a private detail inside `EventBroadcaster`. Keep the public API unchanged.
|
||||
|
||||
- [ ] **Step 3: Update index.ts if needed**
|
||||
|
||||
Ensure the barrel still exports `EventBroadcaster`.
|
||||
|
||||
- [ ] **Step 4: Run typecheck**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 5: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts services/discord-gateway/src/modules/event-broadcaster/index.ts
|
||||
git commit -m "refactor(gateway): merge RedisEventPublisher into EventBroadcaster
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Cross-cutting database initialization dedup (Phase 9)
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/database/schema.ts` — add database lifecycle helpers
|
||||
- Modify: `services/backend/src/shared/database/index.ts` — use shared helpers
|
||||
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — use shared helpers
|
||||
|
||||
- [ ] **Step 1: Check current shared database setup**
|
||||
|
||||
Read `packages/shared/` structure to see if there's already a database module.
|
||||
|
||||
- [ ] **Step 2: Add pool creation helper in @bete/shared**
|
||||
|
||||
In `packages/shared/src/database/schema.ts` or create `packages/shared/src/database/pool.ts`:
|
||||
|
||||
```ts
|
||||
import { Pool } from "pg";
|
||||
|
||||
export function createPostgresPool(url: string, opts?: { min?: number; max?: number }): Pool {
|
||||
return new Pool({
|
||||
connectionString: url,
|
||||
min: opts?.min ?? 2,
|
||||
max: opts?.max ?? 10,
|
||||
});
|
||||
}
|
||||
|
||||
export interface PoolConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
url?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function createPoolFromConfig(cfg: PoolConfig): Pool {
|
||||
if (cfg.url) return createPostgresPool(cfg.url, { min: cfg.min, max: cfg.max });
|
||||
return new Pool({
|
||||
host: cfg.host,
|
||||
port: cfg.port,
|
||||
user: cfg.user,
|
||||
password: cfg.password,
|
||||
database: cfg.database,
|
||||
min: cfg.min ?? 2,
|
||||
max: cfg.max ?? 10,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Export from `packages/shared/src/database/schema.ts` or create a barrel.
|
||||
|
||||
- [ ] **Step 3: Update backend's shared/database/index.ts**
|
||||
|
||||
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
|
||||
|
||||
- [ ] **Step 4: Update gateway's shared/database/drizzle.ts**
|
||||
|
||||
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
|
||||
|
||||
- [ ] **Step 5: Run typecheck across all services**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run typecheck`
|
||||
|
||||
- [ ] **Step 6: Run biome format**
|
||||
|
||||
Run: `cd /home/code/GMW && pnpm run format`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/database/ services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
|
||||
git commit -m "refactor: extract shared database pool creation into @bete/shared
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Audit moderationState vs conversationState overlap (Phase 10)
|
||||
|
||||
**Files:**
|
||||
- Read: `services/discord-gateway/src/modules/ai-moderation/moderationState.ts`
|
||||
- Read: `services/discord-gateway/src/modules/ai-moderation/conversationState.ts`
|
||||
|
||||
- [ ] **Step 1: Read both files and identify overlap**
|
||||
|
||||
Look for duplicated state management (maps, sets, timers).
|
||||
|
||||
- [ ] **Step 2: If overlap found, merge into one file**
|
||||
|
||||
Otherwise, just add comments documenting the boundary.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add services/discord-gateway/src/modules/ai-moderation/
|
||||
git commit -m "refactor(gateway): consolidate conversattion/moderation state management
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Redis connection audit (Phase 11)
|
||||
|
||||
**Files:**
|
||||
- Read: all Redis connection sites in gateway
|
||||
|
||||
- [ ] **Step 1: Identify all Redis connections**
|
||||
|
||||
Search for `new Redis(` patterns in gateway.
|
||||
|
||||
- [ ] **Step 2: Verify each has a valid reason for a separate connection**
|
||||
|
||||
Document with comments if needed.
|
||||
|
||||
- [ ] **Step 3: Commit (if any changes made)**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,455 +0,0 @@
|
||||
# CI/CD Overhaul: Gitea CI + Container Registry Design
|
||||
|
||||
**Status:** Draft
|
||||
**Last updated:** 2026-07-27
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current CI/CD pipeline has multiple issues:
|
||||
|
||||
1. **Split across 3 CI systems**: GitHub Actions (build + deploy), GitLab CI (build only, no deploy), and `deploy.sh` (hot-deploy bind-mounts)
|
||||
2. **Registry mismatch**: GitHub Actions pushes to `ghcr.io` but `docker-compose.yml` references `registry.gitlab.com` — the deploy route is unclear
|
||||
3. **Hot-deploy complexity**: `deploy.sh` builds locally, tars dist files, SSH pipes, and binds into containers at runtime. Fragile and not reproducible
|
||||
4. **No frontend in Docker**: Frontend is never built into an image — only hot-deployed via bind-mounts
|
||||
5. **Stale Dockerfile**: `Dockerfile.proxy` builds a Rust WASM frontend that no longer exists
|
||||
6. **Dockerfile.frontend is missing**: Frontend image doesn't exist at all
|
||||
7. **Shared package fragility**: The previous refactor added `@bete/shared/database/init` export, but Docker images built from `master` don't have it — containers crash
|
||||
|
||||
## 2. Goal
|
||||
|
||||
Single CI/CD pipeline that:
|
||||
|
||||
- Builds Docker images for all 3 services (backend, discord-gateway, proxy-serving-frontend)
|
||||
- Pushes them to Gitea's built-in Container Registry
|
||||
- On the VPS, only pulls images and restarts containers — no more hot-deploy bind-mounts
|
||||
- All 3 services built in one pipeline, deployed together atomically
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
Developer pushes to main
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ Gitea Runner (server X) │
|
||||
│ │
|
||||
│ Job 1: build-and-push │
|
||||
│ ├── bete-backend:latest │──────────▶ Gitea Container Registry
|
||||
│ ├── bete-discord-gateway │──────────▶ git.imrnes.team/MythEclipse/GMW/
|
||||
│ │ :latest │ bete-backend:{sha,latest}
|
||||
│ └── bete-proxy:latest │──────────▶ bete-discord-gateway:{sha,latest}
|
||||
│ │──────────▶ bete-proxy:{sha,latest}
|
||||
│ Job 2: deploy (SSH) │
|
||||
│ └─── SSH ke VPS ──────────┤
|
||||
└────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ VPS Production │
|
||||
│ /opt/imphenbot/infra/ │
|
||||
│ docker/ │
|
||||
│ │
|
||||
│ docker compose pull │
|
||||
│ docker compose up -d │
|
||||
│ docker image prune -f │
|
||||
│ │
|
||||
│ 3 containers: │
|
||||
│ ┌────────┐ ┌──────────┐ │
|
||||
│ │ proxy │ │ backend │ │
|
||||
│ │ :80 │ │ :3000 │ │
|
||||
│ └───┬────┘ └──────────┘ │
|
||||
│ │ ┌─────────────┐ │
|
||||
│ └────┤discord- │ │
|
||||
│ │gateway │ │
|
||||
│ └─────────────┘ │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.1 Service Images
|
||||
|
||||
| Image | From | Runs |
|
||||
|-------|------|------|
|
||||
| `bete-backend` | `Dockerfile.backend` | Express HTTP/WS on port 3000 |
|
||||
| `bete-discord-gateway` | `Dockerfile.discord-gateway` | Discord client, internal only |
|
||||
| `bete-proxy` | `Dockerfile.proxy` (rewritten) | Nginx serving frontend + proxying `/api` and `/ws` to backend |
|
||||
|
||||
### 3.2 Registry
|
||||
|
||||
Gitea provides a built-in container registry per repository at:
|
||||
```
|
||||
git.imrnes.team/MythEclipse/GMW/<image-name>:<tag>
|
||||
```
|
||||
|
||||
Images are tagged with both `latest` and the commit SHA for traceability.
|
||||
|
||||
## 4. Files to Create / Modify
|
||||
|
||||
### 4.1 Create: `.gitea/workflows/deploy.yml`
|
||||
|
||||
One workflow, two jobs:
|
||||
|
||||
```yaml
|
||||
name: Build & Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
service: [backend, discord-gateway, proxy]
|
||||
max-parallel: 2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.GITEA_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
|
||||
- name: Build & Push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: infra/docker/Dockerfile.${{ matrix.service }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:${{ github.sha }}
|
||||
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: SSH & Deploy
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/imphenbot/infra/docker
|
||||
echo "${{ secrets.ENV_FILE }}" > .env
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
Note: Gitea CI uses GitHub Actions-compatible syntax (Act Runner). The above uses the standard `actions/*` actions and `docker/*` actions that work with both GitHub and Gitea. If Gitea's runner doesn't fully support `docker/build-push-action`, fallback to inline `docker build` and `docker push` commands.
|
||||
|
||||
Sensitive variables: `GITEA_REGISTRY_TOKEN`, `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `ENV_FILE` set in Gitea repo Settings → Actions → Secrets. Non-sensitive: `GITEA_REGISTRY` as a Variable.
|
||||
|
||||
### 4.2 Rewrite: `Dockerfile.proxy`
|
||||
|
||||
Current proxy Dockerfile builds a Rust WASM frontend (stale — no longer exists in codebase). Replace with multi-stage build:
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build frontend (Next.js 16 static export)
|
||||
FROM node:22-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Copy dependency manifests
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY services/frontend/package.json ./services/frontend/package.json
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile --filter './services/frontend' --filter '@bete/shared'
|
||||
|
||||
# Copy source code
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY services/frontend/ ./services/frontend/
|
||||
|
||||
# Build Next.js static export
|
||||
RUN pnpm --filter frontend run build
|
||||
# Result in services/frontend/out/
|
||||
|
||||
# Stage 2: Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Nginx config
|
||||
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Static frontend files
|
||||
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:80/ || exit 1
|
||||
```
|
||||
|
||||
### 4.3 Modify: `Dockerfile.backend`
|
||||
|
||||
Add `VITE_BE_API_URL` and `VITE_BE_WS_URL` build args (already listed in GitHub Actions but not in Dockerfile):
|
||||
|
||||
```dockerfile
|
||||
# Add to existing Dockerfile.backend — after FROM, before WORKDIR
|
||||
ARG VITE_BE_API_URL
|
||||
ARG VITE_BE_WS_URL
|
||||
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
|
||||
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
|
||||
```
|
||||
|
||||
These build args are now consumed at build time for future-proofing even though they were previously only needed for frontend builds (which now lives in the proxy Dockerfile).
|
||||
|
||||
### 4.4 Modify: `Dockerfile.discord-gateway`
|
||||
|
||||
No structural changes needed — verify Drizzle migrations path:
|
||||
|
||||
```dockerfile
|
||||
# COPY drizzle, line in existing Dockerfile.discord-gateway:
|
||||
COPY services/discord-gateway/drizzle/ ./services/discord-gateway/drizzle/
|
||||
# This should work as-is since workspace is copied at /app
|
||||
```
|
||||
|
||||
### 4.5 Rewrite: `deploy.sh`
|
||||
|
||||
From hot-deploy tar-pipe SSH to lightweight SSH exec:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INFRA_DIR="$SCRIPT_DIR/infra/docker"
|
||||
|
||||
: "${VPS_HOST:?required}"
|
||||
: "${VPS_USER:?required}"
|
||||
: "${VPS_SSH_KEY:?required}"
|
||||
|
||||
echo "=== Deploy to $VPS_HOST ==="
|
||||
|
||||
# Copy .env if it exists locally
|
||||
if [ -f "$INFRA_DIR/.env" ]; then
|
||||
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
|
||||
fi
|
||||
|
||||
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
|
||||
set -e
|
||||
cd /opt/imphenbot/infra/docker
|
||||
echo "=== Pulling images ==="
|
||||
docker compose pull
|
||||
echo "=== Restarting containers ==="
|
||||
docker compose up -d --remove-orphans
|
||||
echo "=== Cleaning up ==="
|
||||
docker image prune -f
|
||||
echo "=== Verify ==="
|
||||
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
|
||||
REMOTESCRIPT
|
||||
|
||||
echo "=== Deploy complete ==="
|
||||
```
|
||||
|
||||
### 4.6 Rewrite: `infra/docker/docker-compose.yml`
|
||||
|
||||
Replace all GitLab registry image references with Gitea registry. Remove bind-mounts. Add recordings named volume.
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
proxy:
|
||||
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-proxy:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-proxy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8080:80"
|
||||
networks:
|
||||
- app-shared-net
|
||||
healthcheck:
|
||||
test: wget -qO- http://localhost:80/ || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.imphenbot.rule: "Host(`imphnen.asepharyana.my.id`)"
|
||||
traefik.http.routers.imphenbot.entrypoints: websecure
|
||||
traefik.http.routers.imphenbot.tls: "true"
|
||||
traefik.http.services.imphenbot.loadbalancer.server.port: "80"
|
||||
|
||||
backend:
|
||||
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-backend:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
WEBSERVER_PORT: 3000
|
||||
networks:
|
||||
- app-shared-net
|
||||
healthcheck:
|
||||
test: wget -qO- http://localhost:3000/api/health || exit 1
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
depends_on:
|
||||
- proxy
|
||||
|
||||
discord-gateway:
|
||||
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-discord-gateway:${IMAGE_TAG:-latest}
|
||||
container_name: imphenbot-discord-gateway
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- recordings:/app/recordings
|
||||
networks:
|
||||
- app-shared-net
|
||||
healthcheck:
|
||||
test: sh -c "kill -0 1"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
recordings:
|
||||
|
||||
networks:
|
||||
app-shared-net:
|
||||
external: true
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Image refs: `registry.gitlab.com/mytheclipse-group/gmw/...` → `${GITEA_REGISTRY}/${GITEA_REPO}/...`
|
||||
- **All bind-mounts removed** (`./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`)
|
||||
- `recordings` → named volume (persists across container restarts/recreates)
|
||||
- `proxy` binds to `127.0.0.1:8080` instead of host port 80 (Traefik handles external routing)
|
||||
- Added `depends_on: proxy` to backend for startup ordering
|
||||
|
||||
### 4.7 Remove: GitHub Actions & GitLab CI files
|
||||
|
||||
After Gitea CI is verified working:
|
||||
- Delete `.github/workflows/deploy-docker.yml` (or rename to `.github/workflows/deploy-docker.yml.disabled`)
|
||||
- Delete `.gitlab-ci.yml` (or rename to `.gitlab-ci.yml.disabled`)
|
||||
|
||||
### 4.8 Ensure: `.gitea/workflows/` directory
|
||||
|
||||
The directory must exist in git. Some setups ignore `.gitea/` — verify `.gitignore` does not exclude it.
|
||||
|
||||
## 5. Gitea Registry Integration
|
||||
|
||||
### 5.1 Enable Container Registry in Gitea
|
||||
|
||||
In Gitea Admin Settings:
|
||||
- Go to Settings → Repository → Enable "Container Registry"
|
||||
- Default registry URL format: `gitea.<domain>/<owner>/<repo>`
|
||||
|
||||
### 5.2 Registry Token
|
||||
|
||||
Create a Gitea access token with `read` and `write` access to packages:
|
||||
- Settings → Applications → Generate Token → `registry-token` → scope: `write:packages`
|
||||
|
||||
### 5.3 CI Variables
|
||||
|
||||
Set these in Gitea repo → Settings → Actions → Secrets:
|
||||
|
||||
| Name | Example Value | Notes |
|
||||
|------|---------------|-------|
|
||||
| `GITEA_REGISTRY_TOKEN` | `gitea_token_abc123` | Docker login password |
|
||||
| `VPS_HOST` | `123.123.123.123` | VPS IP/hostname |
|
||||
| `VPS_USER` | `root` | SSH user |
|
||||
| `VPS_SSH_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | Private key |
|
||||
| `ENV_FILE` | full .env content | Written to VPS before compose |
|
||||
|
||||
As Variables (not secrets, visible but non-sensitive):
|
||||
|
||||
| Name | Example Value | Notes |
|
||||
|------|---------------|-------|
|
||||
| `GITEA_REGISTRY` | `git.imrnes.team` | Registry hostname — no protocol prefix |
|
||||
|
||||
### 5.4 VPS Setup (one-time)
|
||||
|
||||
```bash
|
||||
# 1. Docker login to Gitea registry
|
||||
docker login git.imrnes.team
|
||||
# Use Gitea username + access token (with write:packages scope)
|
||||
|
||||
# 2. Create recordings named volume
|
||||
docker volume create imphenbot_recordings
|
||||
|
||||
# 3. Remove old bind-mount directories (after verifying old containers stopped)
|
||||
rm -rf /opt/imphenbot/infra/docker/backend-dist
|
||||
rm -rf /opt/imphenbot/infra/docker/gateway-dist
|
||||
rm -rf /opt/imphenbot/infra/docker/shared-dist
|
||||
rm -rf /opt/imphenbot/infra/docker/frontend-dist
|
||||
|
||||
# 4. Ensure compose file is updated (via git pull)
|
||||
cd /opt/imphenbot && git pull origin main
|
||||
```
|
||||
|
||||
## 6. Migration Plan
|
||||
|
||||
### Phase 1: Prepare (this session)
|
||||
|
||||
1. Write `.gitea/workflows/deploy.yml`
|
||||
2. Rewrite `Dockerfile.proxy` for Next.js
|
||||
3. Modify `infra/docker/docker-compose.yml` for Gitea registry + named volumes
|
||||
4. Rewrite `deploy.sh` to SSH-only
|
||||
5. Mark old CI files as disabled (rename, not delete yet)
|
||||
6. Add VITE_BE_API_URL/VITE_BE_WS_URL build args to backend Dockerfile
|
||||
|
||||
### Phase 2: VPS Preparation (one-time SSH)
|
||||
|
||||
7. User runs `docker login` to Gitea registry on VPS
|
||||
8. User sets CI secrets in Gitea UI
|
||||
9. User creates `imphenbot_recordings` named volume
|
||||
|
||||
### Phase 3: Deploy
|
||||
|
||||
10. Commit and push to `main`
|
||||
11. Gitea CI triggers — builds 3 images, pushes to registry
|
||||
12. Deploy job SSHes into VPS, pulls images, restarts containers
|
||||
13. Verify with `docker ps` and health checks
|
||||
|
||||
### Phase 4: Cleanup
|
||||
|
||||
14. After all services running stably for 1-2 pushes: delete old CI files
|
||||
15. Remove old Dockerfiles if no longer referenced
|
||||
|
||||
## 7. Rollback Plan
|
||||
|
||||
If something goes wrong:
|
||||
|
||||
1. **Quick rollback**: `docker compose up -d` with previous `IMAGE_TAG` (pin to last working SHA)
|
||||
2. **Full rollback**: Revert git changes, push to `main` — Gitea CI will rebuild with old config
|
||||
3. **Emergency**: SSH to VPS, use `docker compose` commands to restart specific containers
|
||||
|
||||
## 8. Future Considerations
|
||||
|
||||
- **Auto-deploy on tag**: Optionally trigger CI only on version tags (`v*`) instead of every `main` push
|
||||
- **Health check notifications**: Add webhook notification on deploy failure
|
||||
- **Multi-architecture builds**: Add `--platform linux/amd64,linux/arm64` for future ARM VPS migration
|
||||
- **Secrets management**: Consider HashiCorp Vault or Gitea's built-in encrypted secrets for larger teams
|
||||
@@ -1,154 +0,0 @@
|
||||
# Frontend Refactor: Cleanup, API Alignment & Rebrand
|
||||
|
||||
## Goal
|
||||
Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "chatbot" to "chatbot".
|
||||
|
||||
## Scope
|
||||
|
||||
### A. Code Quality & Structure
|
||||
1. **Extract inline page components** into dedicated files under `components/<feature>/`
|
||||
2. **Remove dead code** (`live-stats.tsx`, `useSearch`, `Item`, etc.)
|
||||
3. **Remove duplicate code** (merge `extractImage`/`extractFirstImage`, consolidate `WsHook` type, consolidate `isActive` functions)
|
||||
4. **Fix Tailwind v4 dynamic class** (`grid-cols-${columns}`) in `LoadingSkeleton`
|
||||
5. **Fix navigation icon** (Settings should use `Settings`, not `BarChart3`)
|
||||
|
||||
### B. API Layer Separation
|
||||
- Split `voiceApi` into `voiceApi` + `mediaApi`
|
||||
- Keep `chatbot.ts` as is (frontend already uses "chatbot" naming)
|
||||
|
||||
### C. Data Fetching Consistency
|
||||
- `GuildSelector` → use `useGuilds` + `useConfig` React Query hooks
|
||||
- `useVoiceChannels` → convert from manual `useState` to `useQuery`
|
||||
- Chatbot → convert to `useQuery` + `useMutation` (user approved this)
|
||||
|
||||
### D. Rebrand
|
||||
- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments
|
||||
- **chatbot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/chatbot/chat`) stay unchanged on frontend since they reference the actual backend path
|
||||
|
||||
### E. Dead Code Removal
|
||||
- Remove `components/landing/` (including `live-stats.tsx`)
|
||||
- Remove `components/ui/item.tsx` (unused)
|
||||
- Remove `useSearch` from `use-messages.ts`
|
||||
- Remove unused shadcn/ui components (verified by grep)
|
||||
|
||||
## Target Directory Structure
|
||||
|
||||
```
|
||||
src/
|
||||
app/(dashboard)/
|
||||
messages/page.tsx # slim → imports from components/messages/
|
||||
dashboard/page.tsx # slim
|
||||
voice/page.tsx # slim
|
||||
media/page.tsx # slim
|
||||
recordings/page.tsx # slim
|
||||
analysis/page.tsx # slim
|
||||
settings/page.tsx # slim
|
||||
layout.tsx # unchanged
|
||||
app/layout.tsx # update title
|
||||
app/page.tsx # unchanged (redirect)
|
||||
|
||||
components/
|
||||
messages/
|
||||
message-card.tsx # from inline in messages/page.tsx
|
||||
message-detail-view.tsx # from inline DetailView
|
||||
ai-status-badge.tsx # from inline AiStatusBadge
|
||||
images-grid.tsx # images tab content
|
||||
review-list.tsx # review tab content
|
||||
dashboard/
|
||||
stats-section.tsx
|
||||
users-section.tsx
|
||||
user-detail-section.tsx
|
||||
channels-section.tsx
|
||||
channel-detail-section.tsx
|
||||
voice/
|
||||
voice-connection-card.tsx
|
||||
active-speakers-panel.tsx
|
||||
microphone-card.tsx
|
||||
media/
|
||||
music-player.tsx
|
||||
recordings/
|
||||
recording-list.tsx
|
||||
analysis/
|
||||
search-panel.tsx
|
||||
shared/ # existing
|
||||
layout/ # existing
|
||||
chatbot/ # existing
|
||||
ui/ # shadcn — remove unused
|
||||
|
||||
hooks/
|
||||
use-messages.ts # cleaned, use shared WsHook type
|
||||
use-dashboard.ts
|
||||
use-voice.ts # cleaned
|
||||
use-media.ts # cleaned
|
||||
use-recordings.ts # cleaned
|
||||
use-guilds.ts
|
||||
use-config.ts
|
||||
use-mobile.ts
|
||||
index.ts
|
||||
|
||||
lib/
|
||||
ws-hook.ts # NEW: shared WsHook type
|
||||
api/
|
||||
client.ts
|
||||
messages.ts
|
||||
voice.ts # voice-only
|
||||
media.ts # NEW: extracted from voiceApi
|
||||
dashboard.ts
|
||||
recordings.ts
|
||||
config.ts
|
||||
chatbot.ts
|
||||
ui-state.ts
|
||||
index.ts
|
||||
types/ # no structural changes, verify alignment
|
||||
ws/ # no structural changes
|
||||
format.ts
|
||||
navigation.ts
|
||||
utils.ts
|
||||
```
|
||||
|
||||
## Key Changes Detail
|
||||
|
||||
### 1. Component Extraction
|
||||
Each page file that has inline components (messages=689 lines, dashboard=570 lines) will have those components extracted into dedicated files. The page file becomes a thin composition layer.
|
||||
|
||||
### 2. WsHook Type Consolidation
|
||||
Three files define `type WsHook = { on: <E>(eventType: E, handler: ...) => () => void }`. This moves to `lib/ws-hook.ts` and all three hooks import it.
|
||||
|
||||
### 3. LoadingSkeleton Fix
|
||||
Replace dynamic `grid-cols-${columns}` with explicit Tailwind classes or inline style:
|
||||
```tsx
|
||||
const gridCols = columns === 2 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1";
|
||||
```
|
||||
|
||||
### 4. API Separation
|
||||
```typescript
|
||||
// lib/api/voice.ts — voice + guilds only
|
||||
export const voiceApi = {
|
||||
getGuilds, getTextChannels, getVoiceChannels,
|
||||
getStatus, connect, disconnect, sendCommand,
|
||||
};
|
||||
|
||||
// lib/api/media.ts — media player only (NEW)
|
||||
export const mediaApi = {
|
||||
getStatus, queue, skip, stop, volume,
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Data Fetching Consistency
|
||||
`GuildSelector` will use `useGuilds()` and `useConfig()` hooks instead of manual fetch in useEffect.
|
||||
`useVoiceChannels` will use `useQuery` with `enabled: !!guildId`.
|
||||
Chatbot will use `useQuery` for history and `useMutation` for send.
|
||||
|
||||
### 6. Rebrand
|
||||
- `app/layout.tsx`: title → "Discord Automod"
|
||||
- Sidebar brand: keep "DC Automod" (already done)
|
||||
- Settings page: keep "DC Automod" reference
|
||||
- Comments referencing "bete" → update
|
||||
- No changes to package names or external references (backend still "bete" internally)
|
||||
|
||||
### Non-Goals
|
||||
- No changes to backend API paths
|
||||
- No changes to package.json names (pnpm workspace naming)
|
||||
- No changes to Router/App Router structure
|
||||
- No changes to CSS/styling system
|
||||
- No functional changes — visual behavior identical
|
||||
@@ -1,134 +0,0 @@
|
||||
# Refactoring Backend & Discord-Gateway Services
|
||||
|
||||
**Date:** 2026-07-27
|
||||
**Status:** Draft
|
||||
|
||||
## Overview
|
||||
|
||||
Comprehensive refactoring of `services/backend` (4.2k lines) and `services/discord-gateway` (17.9k lines) targeting code consistency, file-size reduction, deduplication, and pattern uniformity.
|
||||
|
||||
## Scope
|
||||
|
||||
### Phase 1 — Backend Controller Consistency
|
||||
|
||||
**Problem:** Two competing controller patterns.
|
||||
|
||||
- `messages.controller.ts`, `chatbot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
|
||||
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
|
||||
|
||||
**Fix:** Convert all controllers to **Gaya B** (decorator pattern).
|
||||
|
||||
Before (Gaya A):
|
||||
```ts
|
||||
export function handleListMessages(req, res, next) {
|
||||
return asyncHandler(async (req, res) => {
|
||||
// ...
|
||||
})(req, res, next);
|
||||
}
|
||||
```
|
||||
|
||||
After (Gaya B):
|
||||
```ts
|
||||
export const handleListMessages = asyncHandler(async (req, res) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
**Files affected:**
|
||||
- `modules/messages/messages.controller.ts`
|
||||
- `modules/chatbot-chat/chatbot-chat.controller.ts`
|
||||
|
||||
### Phase 2 — Backend `response.ts` Cleanup
|
||||
|
||||
**Problem:** `success()`/`error()` helpers exist but are unused (except health controller).
|
||||
|
||||
**Fix:** Apply `success()` consistently to all API responses that are successful data returns. Remove `error()` if unused after audit.
|
||||
|
||||
**Files affected:** All route/service files that `res.json()` data.
|
||||
|
||||
### Phase 3 — Backend `ws/` Barrel
|
||||
|
||||
**Problem:** `ws/broadcast.ts`, `ws/redis-bridge.ts`, `ws/server.ts` — no barrel.
|
||||
|
||||
**Fix:** Add `ws/index.ts` barrel.
|
||||
|
||||
### Phase 4 — Gateway: Split `moderationPrompt.ts` (1015 lines)
|
||||
|
||||
**Problem:** Monolithic prompt file mixing all prompt types.
|
||||
|
||||
**Fix:** Split into:
|
||||
- `prompts/text-analysis.ts` — Text moderation prompts
|
||||
- `prompts/media-analysis.ts` — Image/video analysis prompts
|
||||
- `prompts/stickers.ts` — Sticker analysis prompts
|
||||
- `prompts/emojis.ts` — Custom emoji prompts
|
||||
- `prompts/system.ts` — System prompt builder and shared helpers
|
||||
|
||||
### Phase 5 — Gateway: Split `moderationOrchestrator.ts` (955 lines)
|
||||
|
||||
**Problem:** Entry point that also contains inline text-only batch, media batch, and simple fallback.
|
||||
|
||||
**Fix:** Extract into:
|
||||
- `textBatchProcessor.ts` — All text-only batching logic
|
||||
- `mediaBatchProcessor.ts` — All media batching logic
|
||||
- `simpleFallback.ts` — The `runSimpleTextFallback` function
|
||||
|
||||
### Phase 6 — Gateway: Split `mediaAnalysisClient.ts` (826 lines)
|
||||
|
||||
**Problem:** Cache logic (LRU + phash + DB), download logic (image/video + ffmpeg), and vision LLM in one file.
|
||||
|
||||
**Fix:** Extract into:
|
||||
- `mediaCache.ts` — All caching layers (LRU, phash dedup, DB)
|
||||
- `mediaDownloader.ts` — Image/video download, ffmpeg frame extraction
|
||||
- `visionAnalyzer.ts` — Vision LLM orchestration
|
||||
|
||||
### Phase 7 — Gateway: Consolidate `bootstrap.ts`
|
||||
|
||||
**Problem:** 304-line bootstrap that embeds retention cleanup inline.
|
||||
|
||||
**Fix:** Extract `startRetentionCleanup` into `app/retention.ts`. Leave event registrations in bootstrap as they're inherently app-wide wiring.
|
||||
|
||||
### Phase 8 — Gateway: Simplify EventBroadcaster
|
||||
|
||||
**Problem:** `RedisEventPublisher` wrapping is thin — only adds a `publish` wrapper.
|
||||
|
||||
**Fix:** Merge `RedisEventPublisher` into `EventBroadcaster` as a private inner detail.
|
||||
|
||||
### Phase 9 — Cross-cutting: Database initialization dedup
|
||||
|
||||
**Problem:** Backend (`shared/database/index.ts`) and gateway (`shared/database/drizzle.ts`) have near-identical pool creation and lifecycle code.
|
||||
|
||||
**Fix:** Extract common pool/drizzle lifecycle into `@bete/shared`:
|
||||
```ts
|
||||
// packages/shared/src/database/index.ts
|
||||
export function createDatabasePool(url: string, opts?: PoolOpts): Pool
|
||||
export function createDrizzleClient(pool: Pool): DrizzleClient
|
||||
export function closePool(pool: Pool): Promise<void>
|
||||
```
|
||||
Both services keep their own getDatabase/close wrappers but delegate pool creation to shared.
|
||||
|
||||
### Phase 10 — Gateway: Consolidate `moderationState.ts` / `conversationState.ts`
|
||||
|
||||
**Problem:** Two state files with overlapping concerns.
|
||||
|
||||
**Fix:** Audit both for overlap, merge if significant duplication found.
|
||||
|
||||
### Phase 11 — Gateway: Redis connection usage audit
|
||||
|
||||
**Problem:** Multiple independent Redis connections for EventBroadcaster and CommandHandler.
|
||||
|
||||
**Fix:** Both already need separate connections (Redis pub/sub limits). Document the pattern. No structural change.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| Phase | Files | Type |
|
||||
|-------|-------|------|
|
||||
| 1 | 3 | edit |
|
||||
| 2 | ~15 | edit |
|
||||
| 3 | 1 | create |
|
||||
| 4 | ~6 | split |
|
||||
| 5 | ~4 | split |
|
||||
| 6 | ~4 | split |
|
||||
| 7 | 2 | split |
|
||||
| 8 | 2 | refactor |
|
||||
| 9 | 2 | refactor |
|
||||
| 10 | 1-2 | audit+merge |
|
||||
@@ -1,37 +0,0 @@
|
||||
# Visual Redesign: Discord Automod Dashboard
|
||||
|
||||
## Design Direction
|
||||
|
||||
**Vibe:** "Monitoring hub" — deep, technical, trustworthy. Think security operations center meets modern dev tool.
|
||||
|
||||
## Palette
|
||||
|
||||
**Dark (primary):**
|
||||
| Token | Value | Role |
|
||||
|-------|-------|------|
|
||||
| `--bg` | `oklch(0.09 0.015 245)` | Deeper navy canvas |
|
||||
| `--card` | `oklch(0.13 0.02 245)` | Surface with subtle separation |
|
||||
| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent (shift from sky blue) |
|
||||
| `--accent` | `oklch(0.7 0.18 260)` | Electric blue-purple for secondary highlights |
|
||||
| `--warn` | `oklch(0.7 0.17 75)` | Amber-gold for warnings (distinct from red) |
|
||||
| `--border` | `oklch(1 0 0 / 0.06)` | Softer borders |
|
||||
|
||||
## Typography
|
||||
- Geist Sans (body) + Geist Mono (code/data) — already loaded
|
||||
- H1: `text-lg font-semibold tracking-tight`
|
||||
- Card titles: `text-sm font-semibold tracking-tight`
|
||||
- Labels/captions: `text-xs text-muted-foreground tracking-wide uppercase`
|
||||
|
||||
## Layout Changes
|
||||
|
||||
1. **Background**: Subtle dot-grid pattern (`radial-gradient(circle, oklch(1 0 0 / 0.03) 1px, transparent 1px)`) — monitoring station feel
|
||||
2. **Sidebar**: Slightly wider (w-64), active item gets a glow bar + subtle teal tint background, connection dot with breathing animation
|
||||
3. **Cards**: Hover state adds a thin teal border-top glow, softer shadow
|
||||
4. **Stat cards**: Gradient background per stat type (like live-stats had), with icon in colored bubble
|
||||
5. **Severity indicators**: Colored dot + label instead of just colored border
|
||||
6. **Mobile nav**: Tighter spacing, active indicator as dot above icon
|
||||
7. **Header**: Clean, thin bottom border glow, page title larger
|
||||
|
||||
## Signature Element
|
||||
- **Grid background** + **teal glow** on active/interactive elements
|
||||
- **Gradient accent bar** on sidebar active item (wider, glowing)
|
||||
@@ -1,508 +0,0 @@
|
||||
---
|
||||
name: "Discord Automod — Neo Surveillance Redesign"
|
||||
version: "1.0.0"
|
||||
date: "2026-07-28"
|
||||
status: "approved"
|
||||
inspiration:
|
||||
- "Summit Cloud Migration Platform (glassmorphic, dark premium)"
|
||||
- "AeroNet Visualization (data panels, modular layout)"
|
||||
colors:
|
||||
canvas: "oklch(0.07 0.015 250)"
|
||||
surface: "oklch(0.11 0.02 245 / 0.6)"
|
||||
surface-hover: "oklch(0.15 0.02 245 / 0.7)"
|
||||
border: "oklch(1 0 0 / 0.06)"
|
||||
border-glow: "oklch(0.62 0.17 215 / 0.3)"
|
||||
primary: "oklch(0.62 0.17 215)"
|
||||
primary-glow: "oklch(0.62 0.17 215 / 0.4)"
|
||||
accent-purple: "oklch(0.65 0.2 280)"
|
||||
accent-amber: "oklch(0.7 0.17 75)"
|
||||
text-primary: "oklch(0.93 0.01 245)"
|
||||
text-secondary: "oklch(0.55 0.02 245)"
|
||||
text-mono: "oklch(0.62 0.17 215)"
|
||||
glass-bg: "oklch(1 0 0 / 0.04)"
|
||||
glass-border: "oklch(1 0 0 / 0.08)"
|
||||
glass-shadow: "0 8px 32px oklch(0 0 0 / 0.4)"
|
||||
typography:
|
||||
display: "Inter 28-48px weight 600"
|
||||
body: "Inter 14-16px weight 400"
|
||||
mono: "JetBrains Mono 11-13px weight 500-600"
|
||||
data: "JetBrains Mono 24-36px weight 600, teal tint"
|
||||
radius:
|
||||
card: "16px"
|
||||
panel: "12px"
|
||||
control: "8px"
|
||||
pill: "9999px"
|
||||
---
|
||||
|
||||
# Discord Automod — Neo Surveillance Redesign
|
||||
|
||||
Full frontend redesign for Discord Automod, a Discord moderation watcher dashboard. Complete rewrite of layout, design system, navigation, and page architecture.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Philosophy
|
||||
|
||||
**"Neo Surveillance"** — a Security Operations Center (SOC) inspired dashboard where monitoring feels immersive and powerful. Full-screen glass panels float over a dark animated canvas. No persistent sidebar clutter. The interface disappears into the background, letting live data and alerts take center stage.
|
||||
|
||||
Key pillars:
|
||||
- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content
|
||||
- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts
|
||||
- **Presence** — Live2D vtuber chatbot character as chatbot interface, reacts to server events
|
||||
|
||||
---
|
||||
|
||||
## 2. Layout & Navigation System
|
||||
|
||||
### 2.1 Global Structure
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ● Discord Automod Dashboard Msgs Voice … 🟢 ● │ ← Floating Top Bar (~44px)
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ [Sub-navigation tabs] ← muncul per-page │
|
||||
│━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━│
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────────┐ │
|
||||
│ │ Glass │ │ Content │ │
|
||||
│ │ Panels │ │ Area │ │
|
||||
│ │ │ │ (scroll) │ │
|
||||
│ └─────────┘ └──────────────┘ │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────┤
|
||||
│ 🎵 [Mini-player] ← bottom-left 🎭 [Chatbot] ← BR │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Floating Top Navigation Bar
|
||||
|
||||
- **Style:** Glass (`backdrop-blur-xl`), subtle glow border bottom, `h-11` (44px)
|
||||
- **Left:** App logo "Discord Automod" with teal live dot indicator + current page name
|
||||
- **Center:** Horizontal nav links — Dashboard, Messages, Voice, Recordings, Settings
|
||||
- Icon + label, active state with glow underline (`box-shadow` teal)
|
||||
- Hover: text brighter, no background fill
|
||||
- **Search** has no nav link — triggered globally via Cmd+K or `/` shortcut, opens spotlight
|
||||
- **Right:** Connection status dot (pulse when connected) + theme toggle (sun/moon icon)
|
||||
- **Hover sidebar hotspot:** Left edge 4px trigger → slide-in sidebar with guild selector, bookmarks, recent channels (auto-hide 300ms after mouse leave)
|
||||
|
||||
### 2.3 Sub-navigation
|
||||
|
||||
Each page has its own tab bar below the top nav, also glass-styled:
|
||||
- Dashboard: Stats | Live | Activity
|
||||
- Messages: All | Images | Review
|
||||
- Voice: Connection | Activity
|
||||
- Recordings: Library | Stats
|
||||
- Settings: Connection | Appearance | Config | About
|
||||
|
||||
### 2.4 Hidden Sidebar (Hover-activated)
|
||||
|
||||
- Trigger: 4px hotspot at left screen edge
|
||||
- Slide-in animation (150ms, ease-out-expo)
|
||||
- Contains: guild selector dropdown, bookmarked channels, recent activity shortcuts
|
||||
- Auto-hide on mouse leave with 300ms delay
|
||||
|
||||
### 2.5 Floating Media Player
|
||||
|
||||
- No dedicated Media page — persistent floating mini-player at bottom-left
|
||||
- Visible only when a track is active
|
||||
- Click to expand: full queue management overlay
|
||||
- Controls: play/pause, skip, stop, volume slider, progress bar
|
||||
|
||||
---
|
||||
|
||||
## 3. Design Tokens
|
||||
|
||||
### 3.1 Color Palette
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--canvas` | `oklch(0.07 0.015 250)` | Deep navy background |
|
||||
| `--surface` | `oklch(0.11 0.02 245 / 0.6)` | Glass card base |
|
||||
| `--surface-hover` | `oklch(0.15 0.02 245 / 0.7)` | Card hover state |
|
||||
| `--border` | `oklch(1 0 0 / 0.06)` | Subtle border |
|
||||
| `--border-glow` | `oklch(0.62 0.17 215 / 0.3)` | Active card border glow |
|
||||
| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent, buttons |
|
||||
| `--primary-glow` | `oklch(0.62 0.17 215 / 0.4)` | Active state glow |
|
||||
| `--accent-purple` | `oklch(0.65 0.2 280)` | Moderation flagged items |
|
||||
| `--accent-amber` | `oklch(0.7 0.17 75)` | Warnings |
|
||||
| `--text-primary` | `oklch(0.93 0.01 245)` | Body text |
|
||||
| `--text-secondary` | `oklch(0.55 0.02 245)` | Secondary labels |
|
||||
| `--text-mono` | `oklch(0.62 0.17 215)` | Data metrics (teal tint) |
|
||||
| `--glass-bg` | `oklch(1 0 0 / 0.04)` | Glass base |
|
||||
| `--glass-border` | `oklch(1 0 0 / 0.08)` | Glass border |
|
||||
| `--glass-shadow` | `0 8px 32px oklch(0 0 0 / 0.4)` | Glass shadow |
|
||||
|
||||
### 3.2 Typography
|
||||
|
||||
| Role | Font | Size / Weight |
|
||||
|------|------|--------------|
|
||||
| Display | Inter | 28-48px, weight 600 |
|
||||
| Body | Inter | 14-16px, weight 400 |
|
||||
| Label / Mono | JetBrains Mono | 11-13px, weight 500-600 |
|
||||
| Data metrics | JetBrains Mono | 24-36px, weight 600, teal tint |
|
||||
|
||||
### 3.3 Radius System
|
||||
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| Card | 16px |
|
||||
| Panel | 12px |
|
||||
| Button / Control | 8px |
|
||||
| Pill | 9999px |
|
||||
|
||||
### 3.4 Motion Tokens
|
||||
|
||||
```css
|
||||
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
--duration-slow: 400ms;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Component System
|
||||
|
||||
### 4.1 Glass Card System
|
||||
|
||||
- **Base:** `glass-bg` + `glass-border` + border-radius `16px`
|
||||
- **Elevated:** Deeper shadow + subtle primary glow border
|
||||
- **Interactive:** Hover `scale(1.01)` + border glow intensify
|
||||
- **Danger:** Red-tinted border for critical items
|
||||
- Inner padding: `20px` (card), `16px` (panel), `12px` (dense)
|
||||
|
||||
### 4.2 Button Variants
|
||||
|
||||
| Variant | Style |
|
||||
|---------|-------|
|
||||
| Primary | `bg-primary` + `shadow-[0_0_12px] shadow-primary/40` (glow) |
|
||||
| Secondary | `glass-bg` + `border` |
|
||||
| Ghost | Transparent, hover → subtle glass bg |
|
||||
| Icon | Size 32px, rounded 8px |
|
||||
| Danger | Red-tinted variant for destructive actions |
|
||||
|
||||
### 4.3 Status Indicators
|
||||
|
||||
- **Live dot:** Pulsing ring animation (`pulse-ring` 1.5s)
|
||||
- **AI Badge:** Teal pill with sparkle icon, mono font
|
||||
- **Severity badges:** Clean (green), Warn (amber), Flagged (purple), Critical (red)
|
||||
- **Connection:** Green (connected), Yellow (connecting), Red (disconnected)
|
||||
|
||||
### 4.4 Charts (Recharts)
|
||||
|
||||
Custom theme matching design tokens:
|
||||
- Line/Area: gradient fill (primary → transparent)
|
||||
- Bar: rounded bars, teal-cyan gradient
|
||||
- Heatmap: activity by hour × weekday
|
||||
- Radar: multi-axis for moderation categories
|
||||
|
||||
### 4.5 Live2D Chatbot / Chatbot
|
||||
|
||||
- Replaces the existing `Chatbot` component entirely — chatbot panel is the new chat interface
|
||||
- **Location:** Floating panel, bottom-right corner, draggable
|
||||
- **Default size:** Compact — upper body visible (~200×280px)
|
||||
- **Click character:** Expand with full chat panel
|
||||
- **Dynamic expressions:**
|
||||
- Idle: subtle breathing, blink every 4s
|
||||
- New message: head tilt "listening"
|
||||
- Flagged detected: eyes widen, ! bubble
|
||||
- User click: happy wave
|
||||
- Voice active: ear/head tilt toward audio
|
||||
- Chat reply: mouth sync animation
|
||||
- Disconnect: sad expression
|
||||
- **Technology:** Live2D Cubism SDK (WebGL via pixi.js wrapper), `.model3.json` + `.moc3` format
|
||||
- **Chat panel:** Glass-styled input + message history, context-aware (server context)
|
||||
|
||||
### 4.6 Loading States
|
||||
|
||||
- **Skeleton:** Glass card shape with shimmer gradient (teal → transparent → teal)
|
||||
- **Button loading:** Spinner within button
|
||||
- **Full page:** Glass skeleton grid matching target layout
|
||||
|
||||
---
|
||||
|
||||
## 5. Page Layouts
|
||||
|
||||
### 5.1 Dashboard — "Ops Center"
|
||||
|
||||
Full-viewport command center:
|
||||
- **Stat cards row:** Total Messages, Today, Users, Active 24h, Flagged, Clean — each with micro sparkline chart (Recharts mini area) behind the number
|
||||
- **Live Message Stream:** Auto-scrolling glass panel showing recent messages, fade-in animation, click for detail
|
||||
- **Mod Queue:** Flagged messages with quick action buttons (approve/delete/escalate)
|
||||
- **Message Trend Chart:** 7-day area chart
|
||||
- **Activity Heatmap:** Hour × day-of-week, moderation event density
|
||||
- **Top Channels:** Bar chart with channel names
|
||||
- **Chatbot visible** floating bottom-right
|
||||
|
||||
### 5.2 Messages — Split Pane
|
||||
|
||||
- **Left pane:** Scrollable message list, glass cards with severity badge, channel tag, timestamp
|
||||
- **Right pane:** Detail/preview — full message content, attachments gallery, AI analysis breakdown (severity, flags, confidence, categories)
|
||||
- **Global search bar** in top area: spotlight-style overlay (Cmd+K)
|
||||
- **State in URL params:** `?guild=xxx&channel=yyy&selected=msg123&tab=all`
|
||||
- **Tabs:** All | Images (grid view) | Review (flagged queue)
|
||||
- **Actions:** Reanalyze, Moderate (dropdown: delete/warn/escalate)
|
||||
|
||||
### 5.3 Voice — Connection Center
|
||||
|
||||
- **Connection card:** Guild/channel selectors, status with live dot + duration
|
||||
- **Active Speakers:** Per-user waveform visualization (canvas-based, 100ms update)
|
||||
- **Microphone/Transmit:** Toggle mic, volume slider
|
||||
- **Voice Activity Timeline:** Bar chart showing who spoke and total duration
|
||||
- **Recordings quick link** to Recordings page
|
||||
|
||||
### 5.4 Recordings — Voice Library
|
||||
|
||||
- **Search + filter bar:** By user, channel, date range
|
||||
- **Recording cards:** Glass card, waveform preview (canvas), duration, timestamp
|
||||
- **Inline playback:** Play button, audio player without leaving page
|
||||
- **Actions:** Download, Copy Link
|
||||
|
||||
### 5.5 Settings
|
||||
|
||||
- **Sections:** Connection (WebSocket status, guild info), Appearance (theme toggle), Server Config (read-only), About
|
||||
- All glass cards, mono font for config values
|
||||
- Toggle switches with glass styling
|
||||
|
||||
---
|
||||
|
||||
## 6. Animations & Micro-interactions
|
||||
|
||||
### 6.1 Ambient Background
|
||||
- Gradient mesh with slow-shift (30s cycle)
|
||||
- 2-3 soft color blobs (teal, purple, amber), opacity 0.03-0.06
|
||||
- Grid dot pattern: `radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px)`, 24px spacing
|
||||
|
||||
### 6.2 Page Transitions
|
||||
- Route change: `fade-in-up` 200ms ease-out
|
||||
- Content section: `scale(0.98→1)` + `opacity(0.6→1)`
|
||||
|
||||
### 6.3 Card Interactions
|
||||
- Hover: `scale(1.01)` + border glow intensify + shadow lift
|
||||
- Click: `scale(0.98)` brief (100ms)
|
||||
- Panel enter: `translateY(-4px)` + `opacity` fade-in
|
||||
- Stat counter: count-up animation (JS tween, 400ms)
|
||||
|
||||
### 6.4 Live Data
|
||||
- Message stream: fade-in from top, slide down as new arrive
|
||||
- Voice waveform: real-time canvas draw, 100ms interval
|
||||
- Recording: pulsing dot + ring expansion (1.5s loop)
|
||||
- Connection: slow pulse when connected
|
||||
- Flagged: brief red/purple border flash on new flagged message
|
||||
|
||||
### 6.5 Micro-interactions
|
||||
- Toggle: slide with glow
|
||||
- Scrollbar: custom thin (6px), auto-hide, rounded
|
||||
- Drag handle: subtle dot grip for split pane
|
||||
- Copy: brief "Copied!" toast
|
||||
- Reanalyze: 360° icon rotation
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Flow & State Management
|
||||
|
||||
### 7.1 Architecture
|
||||
|
||||
```
|
||||
WS Provider (auto-reconnect, typed events, event buffer)
|
||||
↓
|
||||
TanStack Query (fetches + cache)
|
||||
↓
|
||||
Query invalidation on WS events
|
||||
↓
|
||||
Optimistic cache updates for real-time data
|
||||
```
|
||||
|
||||
### 7.2 WS → Cache Strategy
|
||||
|
||||
| WS Event | Action |
|
||||
|----------|--------|
|
||||
| `message_created` | Optimistic insert to message list + dashboard stats |
|
||||
| `message_analyzed` | Update AI fields in message cache |
|
||||
| `message_deleted` | Remove from cache + update counters |
|
||||
| `voice_recording_started` | Update voice status |
|
||||
| `voice_pcm_data` | Buffer to waveform canvas (bypass React) |
|
||||
| `voice_active_user` | Update speakers cache |
|
||||
| `analysis_queue_status` | Update queue progress |
|
||||
|
||||
### 7.3 Query Config
|
||||
|
||||
- `staleTime: 10_000` (10s)
|
||||
- `gcTime: 5 * 60 * 1000` (5 min)
|
||||
- `refetchOnWindowFocus: false`
|
||||
|
||||
### 7.4 Global State (React Context)
|
||||
|
||||
- `useMediaPlayer()` — current track, queue, play/skip/stop/volume
|
||||
- `useChatbot()` — expression, minimized, chatHistory, setExpression
|
||||
- Externally triggerable: `chatbot.setExpression("surprise")` on flagged message, `("listening")` on voice activity
|
||||
|
||||
### 7.5 URL State
|
||||
|
||||
Persistent page state via search params (not React state):
|
||||
```
|
||||
/messages?guild=xxx&channel=yyy&selected=msg123&tab=all
|
||||
```
|
||||
|
||||
### 7.6 Error Boundaries
|
||||
|
||||
Each page has its own error boundary. One page failure doesn't affect others.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technology Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router, static export)
|
||||
- **Language:** TypeScript strict
|
||||
- **Styling:** Tailwind v4 + CSS custom properties
|
||||
- **UI Base:** shadcn/ui components (adapted for glass theme)
|
||||
- **Icons:** lucide-react
|
||||
- **State/data:** @tanstack/react-query v5
|
||||
- **Charts:** Recharts 3.8 (with custom theme)
|
||||
- **3D/Chatbot:** Live2D Cubism SDK WebGL (pixi.js wrapper)
|
||||
- **Audio:** Web Audio API for waveform visualization
|
||||
- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed)
|
||||
|
||||
---
|
||||
|
||||
## 9. File Structure (New)
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout (fonts, theme script, Toaster)
|
||||
│ ├── page.tsx # Redirect → /dashboard
|
||||
│ ├── globals.css # Complete redesign CSS (tokens, glass, animations)
|
||||
│ └── (dashboard)/
|
||||
│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, chatbot)
|
||||
│ ├── dashboard/
|
||||
│ │ └── page.tsx # Ops Center
|
||||
│ ├── messages/
|
||||
│ │ └── page.tsx # Split pane messages
|
||||
│ ├── voice/
|
||||
│ │ └── page.tsx # Voice connection center
|
||||
│ ├── recordings/
|
||||
│ │ └── page.tsx # Recording library
|
||||
│ └── settings/
|
||||
│ └── page.tsx # Settings page
|
||||
│
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ ├── top-nav.tsx # Floating top navigation bar
|
||||
│ │ ├── sub-nav.tsx # Per-page sub-navigation tabs
|
||||
│ │ ├── hidden-sidebar.tsx # Hover-activated guild sidebar
|
||||
│ │ └── mobile-nav.tsx # Mobile bottom nav (updated design)
|
||||
│ │
|
||||
│ ├── glass/
|
||||
│ │ ├── card.tsx # Glass card component (base, elevated, interactive)
|
||||
│ │ ├── panel.tsx # Glass panel wrapper
|
||||
│ │ └── divider.tsx # Glass-styled separator
|
||||
│ │
|
||||
│ ├── dashboard/
|
||||
│ │ ├── stat-card.tsx # Stat card with micro sparkline
|
||||
│ │ ├── live-stream.tsx # Auto-scrolling message stream
|
||||
│ │ ├── mod-queue.tsx # Moderation queue with quick actions
|
||||
│ │ ├── message-trend-chart.tsx # 7-day area chart
|
||||
│ │ ├── activity-heatmap.tsx # Hour × day heatmap
|
||||
│ │ └── top-channels-chart.tsx # Top channels bar chart
|
||||
│ │
|
||||
│ ├── messages/
|
||||
│ │ ├── message-list.tsx # Left pane — scrollable message list
|
||||
│ │ ├── message-card.tsx # Individual message card (redesigned)
|
||||
│ │ ├── message-detail.tsx # Right pane — full detail
|
||||
│ │ ├── attachments-grid.tsx # Attachments gallery
|
||||
│ │ ├── ai-analysis-panel.tsx # AI analysis breakdown
|
||||
│ │ └── search-overlay.tsx # Cmd+K search spotlight
|
||||
│ │
|
||||
│ ├── voice/
|
||||
│ │ ├── connection-card.tsx # Guild/channel selector + status
|
||||
│ │ ├── speaker-waveform.tsx # Canvas waveform per speaker
|
||||
│ │ ├── mic-control.tsx # Mic toggle + volume
|
||||
│ │ └── activity-timeline.tsx # Voice activity bar chart
|
||||
│ │
|
||||
│ ├── recordings/
|
||||
│ │ ├── recording-card.tsx # Glass card with waveform preview
|
||||
│ │ └── recording-player.tsx # Inline audio player
|
||||
│ │
|
||||
│ ├── chatbot/
|
||||
│ │ ├── chatbot-container.tsx # Floating L2D container
|
||||
│ │ ├── chatbot-canvas.tsx # WebGL canvas for L2D rendering
|
||||
│ │ ├── chat-panel.tsx # Chat input + history
|
||||
│ │ └── chatbot-context.tsx # Context provider
|
||||
│ │
|
||||
│ ├── media/
|
||||
│ │ └── mini-player.tsx # Floating mini media player
|
||||
│ │
|
||||
│ ├── shared/
|
||||
│ │ ├── error-state.tsx # Error boundary fallback
|
||||
│ │ ├── loading-skeleton.tsx # Glass shimmer skeleton
|
||||
│ │ └── empty-state.tsx # Empty state illustration
|
||||
│ │
|
||||
│ └── ui/ # shadcn/ui components (adapted to glass)
|
||||
│ ├── button.tsx, badge.tsx, dialog.tsx, ...
|
||||
│
|
||||
├── lib/
|
||||
│ ├── api/ # Existing API client (unchanged)
|
||||
│ ├── ws/
|
||||
│ │ ├── context.tsx # WS provider (unchanged)
|
||||
│ │ └── types.ts # WS event types
|
||||
│ ├── hooks/ # Existing hooks + new ones
|
||||
│ │ ├── use-media-player.ts # Global media state
|
||||
│ │ ├── use-chatbot.ts # Chatbot context hook
|
||||
│ │ └── use-heatmap.ts # Heatmap data hook
|
||||
│ ├── types/ # Existing types (unchanged)
|
||||
│ ├── navigation.ts # Nav items (updated)
|
||||
│ └── format.ts # Format utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Order
|
||||
|
||||
### Phase 1 — Foundation
|
||||
1. Update `globals.css` with new design tokens (colors, glass, radius, typography, animations)
|
||||
2. Rewrite root `layout.tsx` with theme system
|
||||
3. Build glass component system (`card.tsx`, `panel.tsx`)
|
||||
4. Build `top-nav.tsx`, `sub-nav.tsx`, `hidden-sidebar.tsx`
|
||||
5. Update dashboard layout with new nav
|
||||
|
||||
### Phase 2 — Dashboard Ops Center
|
||||
6. Build `stat-card.tsx` with micro sparkline
|
||||
7. Build `live-stream.tsx`
|
||||
8. Build `mod-queue.tsx`
|
||||
9. Build charts: `message-trend-chart.tsx`, `activity-heatmap.tsx`, `top-channels-chart.tsx`
|
||||
10. Rewrite dashboard page
|
||||
|
||||
### Phase 3 — Messages (Split Pane)
|
||||
11. Build `message-list.tsx`, `message-card.tsx` (redesigned)
|
||||
12. Build `message-detail.tsx`, `ai-analysis-panel.tsx`, `attachments-grid.tsx`
|
||||
13. Build `search-overlay.tsx`
|
||||
14. Rewrite messages page with split-pane layout
|
||||
|
||||
### Phase 4 — Voice, Recordings, Settings
|
||||
15. Build voice components and rewrite voice page
|
||||
16. Build recording components and rewrite recordings page
|
||||
17. Rewrite settings page
|
||||
|
||||
### Phase 5 — Floating Elements
|
||||
18. Build `mini-player.tsx` for media
|
||||
19. Build chatbot components (L2D integration)
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing
|
||||
|
||||
- Visual regression checks per component
|
||||
- WS integration tests for cache updates
|
||||
- Responsive breakpoint testing (mobile bottom nav)
|
||||
- L2D chatbot load + expression trigger
|
||||
|
||||
---
|
||||
|
||||
## 12. Non-Goals (Out of Scope)
|
||||
|
||||
- Authentication — remains public
|
||||
- Backend API changes — only frontend redesign
|
||||
- Database changes — no schema modifications
|
||||
- New backend WebSocket events — reuse existing
|
||||
- L2D model creation — integration only (model file provided separately)
|
||||
@@ -7,10 +7,32 @@
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Source filter: `path:` literals do NOT respect .gitignore by default,
|
||||
# so a dirty local out/ (stale chunks from previous builds) leaks into
|
||||
# the sandbox. Filter out build artifacts explicitly.
|
||||
filterSource = { dir, ignore }: builtins.path {
|
||||
path = dir;
|
||||
name = "source";
|
||||
filter = (path: type: let base = baseNameOf path; in !(builtins.elem base ignore));
|
||||
};
|
||||
frontendSrc = filterSource {
|
||||
dir = ./services/frontend;
|
||||
ignore = [ "out" ".next" "node_modules" "pnpm-lock.yaml" ];
|
||||
};
|
||||
|
||||
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
|
||||
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
|
||||
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
|
||||
# into one tree so FindOpenSSL resolves both via OPENSSL_ROOT_DIR.
|
||||
opensslDevEnv = pkgs.symlinkJoin {
|
||||
name = "openssl-dev-env";
|
||||
paths = [ pkgs.pkgsStatic.openssl.out pkgs.openssl.dev ];
|
||||
};
|
||||
|
||||
# ---- Shared build tools ----
|
||||
nodejs = pkgs.nodejs_22;
|
||||
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
|
||||
@@ -26,8 +48,8 @@
|
||||
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||
|
||||
# pnpm uses node-gyp for native addons — provide build tools
|
||||
export npm_config_build_from_source=true
|
||||
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
||||
|
||||
@@ -37,6 +59,36 @@
|
||||
pnpm rebuild 2>&1 || true
|
||||
'';
|
||||
|
||||
# Shrink the shipped node_modules to production deps only. The full
|
||||
# install's .pnpm virtual store carries dev-only packages (biome,
|
||||
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
|
||||
# needed at runtime, so we delete every .pnpm dir that is not part of
|
||||
# the resolved production graph (`pnpm list --prod`).
|
||||
#
|
||||
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
||||
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
||||
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
||||
# are only reachable through it), silently breaking voice.
|
||||
# Instead we keep the full install's symlink layout and only prune
|
||||
# orphaned package dirs + broken symlinks.
|
||||
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
||||
pruneProd = ''
|
||||
echo "=== Pruning devDependencies (production-only node_modules) ==="
|
||||
pnpm list --prod --depth 999 --parseable 2>/dev/null \
|
||||
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
|
||||
( cd node_modules/.pnpm \
|
||||
&& for d in */; do \
|
||||
d="''${d%/}"; \
|
||||
[ "$d" = "node_modules" ] && continue; \
|
||||
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
||||
done ) || true
|
||||
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
||||
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
||||
# which would otherwise fail the fixupPhase.
|
||||
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
|
||||
du -sh node_modules
|
||||
'';
|
||||
|
||||
# ---- Backend ----
|
||||
backend = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-backend";
|
||||
@@ -49,33 +101,10 @@
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Compiling TypeScript ==="
|
||||
npx tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||
node -e "
|
||||
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 "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
'';
|
||||
'' + pruneProd;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/gmw-backend
|
||||
@@ -84,7 +113,8 @@
|
||||
mkdir -p $out/bin
|
||||
cat > $out/bin/gmw-backend << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
exec ${nodejs}/bin/node $out/lib/gmw-backend/dist/index.js
|
||||
cd $out/lib/gmw-backend
|
||||
exec ${nodejs}/bin/node dist/index.js
|
||||
WRAPPER
|
||||
chmod +x $out/bin/gmw-backend
|
||||
'';
|
||||
@@ -104,43 +134,55 @@ WRAPPER
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs pnpm
|
||||
pkgs.python3 pkgs.gnumake pkgs.gcc
|
||||
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
|
||||
pkgs.rustc pkgs.cargo
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
pkgs.openssl.dev
|
||||
pkgs.git # for any FetchContent-based deps during native builds
|
||||
pkgs.cacert
|
||||
];
|
||||
|
||||
# Runtime tools for the voice pipeline: ffmpeg (mic transmit encode,
|
||||
# music stream decode, segment muxing) and yt-dlp (YouTube/Spotify/
|
||||
# search media resolution). Must be on PATH inside the wrapper below.
|
||||
buildInputs = [ pkgs.ffmpeg-headless pkgs.yt-dlp ];
|
||||
|
||||
# cmake is only needed for node-datachannel's postinstall build —
|
||||
# do NOT let stdenv run its own cmake configure phase on the source.
|
||||
dontUseCmakeConfigure = true;
|
||||
|
||||
# The gateway bundles native node_modules (.node addons plus .o/.a
|
||||
# object files left in prebuilt dirs). stdenv's fixupPhase walks
|
||||
# $out/node_modules and runs patchELF + shrinkELF over every ELF it
|
||||
# finds, choking on the non-ET_DYN files (.o/.a) and the prebuilt
|
||||
# .node addons — emitting hundreds of harmless "patchelf: wrong ELF
|
||||
# type" lines per build. The real binary is node (external, already
|
||||
# RPATH-fixed in its own derivation) and the .node addons are
|
||||
# self-contained prebuilts loaded via dlopen, so Nix's fixup pass is
|
||||
# neither needed nor wanted here. Skip it entirely.
|
||||
dontFixup = true;
|
||||
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Compiling TypeScript ==="
|
||||
echo "=== Building native voice deps ==="
|
||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||
# from the wrong cwd — build each native dep explicitly with its own
|
||||
# install script. Each failure is tolerated (|| true); the packages
|
||||
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
|
||||
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
|
||||
# instead of compiling C++ from source. With build_from_source unset
|
||||
# (above), `pnpm rebuild` runs the package's own install script which
|
||||
# fetches the matching prebuilt; it only falls back to a source build
|
||||
# if the download fails. This keeps voice working without a per-build
|
||||
# native compile.
|
||||
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
|
||||
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||
echo "=== Compiling TypeScript ===="
|
||||
npx tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||
node -e "
|
||||
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 "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
'';
|
||||
'' + pruneProd;
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/gmw-discord-gateway
|
||||
@@ -152,7 +194,9 @@ WRAPPER
|
||||
mkdir -p $out/bin
|
||||
cat > $out/bin/gmw-discord-gateway << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
exec ${nodejs}/bin/node $out/lib/gmw-discord-gateway/dist/index.js
|
||||
cd $out/lib/gmw-discord-gateway
|
||||
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||
exec ${nodejs}/bin/node dist/index.js
|
||||
WRAPPER
|
||||
chmod +x $out/bin/gmw-discord-gateway
|
||||
'';
|
||||
@@ -163,39 +207,57 @@ WRAPPER
|
||||
};
|
||||
};
|
||||
|
||||
# ---- Frontend (Next.js static export) ----
|
||||
# ---- Frontend (Next.js SSR standalone) ----
|
||||
frontend = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-frontend";
|
||||
version = "1.0.0";
|
||||
|
||||
src = ./services/frontend;
|
||||
src = frontendSrc;
|
||||
|
||||
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
||||
|
||||
buildPhase = pnpmInstall + ''
|
||||
echo "=== Building Next.js static export ==="
|
||||
# Build args are provided as env vars
|
||||
echo "=== Building Next.js SSR (standalone) ==="
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
export GMW_BACKEND_URL=http://127.0.0.1:4001
|
||||
npx next build 2>&1
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/share/gmw-frontend
|
||||
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
|
||||
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
|
||||
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
|
||||
echo "=== Packaging standalone server ==="
|
||||
mkdir -p $out/lib/gmw-frontend/standalone
|
||||
# The standalone server bundles its own minimal node_modules but
|
||||
# needs the build assets + public copied INSIDE its tree.
|
||||
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
|
||||
mkdir -p $out/lib/gmw-frontend/standalone/.next
|
||||
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
|
||||
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
|
||||
|
||||
# Copy node_modules for standalone mode if it exists
|
||||
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
|
||||
# Remove dangling symlinks left by pnpm's hoisted .pnpm layout
|
||||
# (e.g. node_modules/.pnpm/node_modules/...). The standalone server
|
||||
# never resolves those at runtime — it bundles its own node_modules
|
||||
# — and they trip stdenv's noBrokenSymlinks check.
|
||||
find $out/lib/gmw-frontend/standalone -type l \
|
||||
! -exec test -e {} \; -delete 2>/dev/null || true
|
||||
|
||||
mkdir -p $out/bin
|
||||
cat > $out/bin/gmw-frontend << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
cd $out/lib/gmw-frontend/standalone
|
||||
export PORT=''${GMW_FRONTEND_PORT:-4017}
|
||||
export HOSTNAME=127.0.0.1
|
||||
exec ${nodejs}/bin/node server.js
|
||||
WRAPPER
|
||||
chmod +x $out/bin/gmw-frontend
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "GMW Frontend — Next.js static dashboard";
|
||||
description = "GMW Frontend — Next.js SSR dashboard";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
# ---- Proxy (nginx serving frontend) ----
|
||||
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
|
||||
proxy = pkgs.stdenv.mkDerivation {
|
||||
pname = "gmw-proxy";
|
||||
version = "1.0.0";
|
||||
@@ -210,11 +272,10 @@ WRAPPER
|
||||
mkdir -p $out/bin $out/etc $out/share
|
||||
|
||||
# Substitute placeholders in nginx template
|
||||
sed \
|
||||
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
|
||||
${./infra/nix/nginx.conf.template} \
|
||||
> $out/etc/nginx.conf
|
||||
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||
-e "s|@NEXT_PORT@|4017|g" \
|
||||
${./infra/nix/nginx.conf.template} \
|
||||
> $out/etc/nginx.conf
|
||||
|
||||
cat > $out/bin/gmw-proxy << WRAPPER
|
||||
#!${pkgs.runtimeShell}
|
||||
@@ -224,7 +285,7 @@ WRAPPER
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "GMW Proxy — nginx serving frontend";
|
||||
description = "GMW Proxy — nginx -> Next.js + backend";
|
||||
platforms = pkgs.lib.platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,9 +33,9 @@ COPY --from=builder --chown=node:node /build/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /build/package.json ./
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
EXPOSE 4001
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/health',r=>process.exit(r.statusCode===200?0:1))"
|
||||
CMD node -e "require('http').get('http://localhost:4001/api/health',r=>process.exit(r.statusCode===200?0:1))"
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -33,9 +33,9 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
WEBSERVER_PORT: 3000
|
||||
WEBSERVER_PORT: 4001
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:4001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 15s
|
||||
|
||||
@@ -11,24 +11,44 @@ http {
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Next.js standalone SSR server (backend-fetching on every render).
|
||||
# Not for hand-editing: @NEXT_PORT@ is substituted at build time.
|
||||
upstream gmw_next {
|
||||
server 127.0.0.1:@NEXT_PORT@;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream gmw_backend {
|
||||
server 127.0.0.1:4001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8080;
|
||||
listen 4009;
|
||||
server_name _;
|
||||
|
||||
# Use relative redirects (Location: /dashboard/) instead of absolute
|
||||
# URLs that leak the internal listen port (4009) through the reverse proxy.
|
||||
absolute_redirect off;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# ── Backend REST ───────────────────────────────────────────────
|
||||
location ^~ /api {
|
||||
proxy_pass http://127.0.0.1:3001$uri$is_args$args;
|
||||
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection ""; # keepalive to backend
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
|
||||
location ^~ /ws {
|
||||
proxy_pass http://127.0.0.1:3001$uri$is_args$args;
|
||||
proxy_pass http://gmw_backend$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
@@ -41,16 +61,49 @@ http {
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
root @FRONTEND_ROOT@;
|
||||
# ── 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 ───────────
|
||||
location ^~ /_next/static/ {
|
||||
proxy_pass http://gmw_next$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# ── Everything else → Next.js server (SSR) ──
|
||||
location / {
|
||||
root @FRONTEND_ROOT@;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
proxy_pass http://gmw_next$uri$is_args$args;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Next-Prefetch $http_x_next_prefetch;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration: add ai_analysis_duration_ms to messages
|
||||
-- Tracks how long the AI moderation LLM call took, per message (ms).
|
||||
-- Idempotent: safe to re-run.
|
||||
--
|
||||
-- Run against the production GMW database, e.g.:
|
||||
-- PGPASSWORD=*** psql -h 100.121.180.82 -p 6432 -U asephs -d dcbot \
|
||||
-- -f scripts/add-ai-analysis-duration.sql
|
||||
|
||||
ALTER TABLE "messages"
|
||||
ADD COLUMN IF NOT EXISTS "ai_analysis_duration_ms" BIGINT;
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Fix: missing messages and attachments tables on VPS
|
||||
-- Run: PGPASSWORD=hunterz psql -h 100.108.1.124 -U asephs -d hub -f scripts/fix-missing-tables.sql
|
||||
-- Run: PGPASSWORD=hunterz psql -h 100.121.180.82 -U asephs -d hub -f scripts/fix-missing-tables.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
||||
@@ -225,21 +225,21 @@ All config via environment variables (`.env`), validated with Zod in `shared/con
|
||||
|
||||
```env
|
||||
# Server
|
||||
WEBSERVER_PORT=3001
|
||||
WEBSERVER_PORT=4001
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost:5432/discord_moderation
|
||||
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/discord_moderation
|
||||
# OR
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_HOST=100.121.180.82
|
||||
DATABASE_PORT=6432
|
||||
DATABASE_NAME=discord_moderation
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=secret
|
||||
|
||||
# Redis (optional, for pub/sub)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_URL=redis://100.121.180.82:6379
|
||||
|
||||
# Discord
|
||||
MONITOR_GUILD_ID=123456789
|
||||
@@ -263,7 +263,7 @@ Use Vitest with mocked database and services.
|
||||
2. **Implement repository queries** for each module using Drizzle ORM
|
||||
3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener
|
||||
4. **Create Discord Gateway service** in `services/discord-gateway/` (separate microservice)
|
||||
5. **Add Docker & CI/CD** for multi-service deployment
|
||||
5. **Add Nix & CI/CD** for multi-service deployment (flake.nix + GitHub Actions → nix copy → systemd)
|
||||
6. **Write integration tests** for full request flow
|
||||
|
||||
## Circular Dependency Check
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@orpc/server": "1.15.0",
|
||||
"axios": "^1.16.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -31,10 +32,10 @@
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.22.2",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"vitest": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
// 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("@/")) {
|
||||
const target = join("dist", spec.slice(2)) + ".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,10 +1,10 @@
|
||||
/**
|
||||
* E2E API tests — runs against a running backend instance.
|
||||
* Usage: API_BASE=http://localhost:3001 vitest run
|
||||
* Usage: API_BASE=http://localhost:4001 vitest run
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const BASE = process.env.API_BASE ?? "http://localhost:3001/api";
|
||||
const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
|
||||
|
||||
async function api(path: string, init?: RequestInit) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/node";
|
||||
import express, {
|
||||
type Express,
|
||||
type NextFunction,
|
||||
@@ -6,19 +7,18 @@ import express, {
|
||||
type Response,
|
||||
} from "express";
|
||||
import helmet from "helmet";
|
||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
||||
import { createConfigRouter } from "../modules/config/index.js";
|
||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { createHealthRouter } from "../modules/health/index.js";
|
||||
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
||||
import { createMediaRouter } from "../modules/media/index.js";
|
||||
import { createMessagesRouter } from "../modules/messages/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 { appRouter } from "../orpc/router";
|
||||
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");
|
||||
|
||||
@@ -32,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.urlencoded({ extended: true }));
|
||||
|
||||
@@ -58,23 +58,38 @@ export function createHttpApp(): Express {
|
||||
next();
|
||||
});
|
||||
|
||||
// All routes are public
|
||||
// Infra-only HTTP endpoints
|
||||
app.use("/api", createHealthRouter());
|
||||
app.use("/api", createConfigRouter());
|
||||
app.use("/api", createDashboardRouter());
|
||||
app.use("/api", createMessagesRouter());
|
||||
app.use("/api", createAnalysisRouter());
|
||||
app.use("/api", createChatbotRouter());
|
||||
app.use("/api", createRecordingsRouter());
|
||||
app.use("/api", createUiStateRouter());
|
||||
app.use("/api", createMediaRouter());
|
||||
app.use("/api", createVoiceRouter());
|
||||
|
||||
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
|
||||
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
|
||||
// response itself; if no procedure matched we fall through to the 404 below.
|
||||
const orpcHandler = new RPCHandler(appRouter, {
|
||||
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
|
||||
});
|
||||
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
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
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({
|
||||
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 { createChildLogger } from "@/shared/logger/index";
|
||||
import { createORPCWebSocketServer } from "../orpc/ws.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { initializeDatabase } from "../shared/database/index.js";
|
||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||
@@ -16,8 +17,9 @@ export async function startHttpServer(): Promise<Server> {
|
||||
|
||||
const server = createServer(app);
|
||||
|
||||
// Attach WebSocket server to the same HTTP server
|
||||
createWebSocketServer(server);
|
||||
// Attach WebSocket servers to the same HTTP 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
|
||||
await startRedisBridge();
|
||||
|
||||
@@ -24,6 +24,15 @@ async function main() {
|
||||
async function shutdown(signal: string) {
|
||||
logger.info({ signal }, "Shutting down gracefully");
|
||||
|
||||
// Failsafe: graceful shutdown must never hang the process forever.
|
||||
// httpServer.close() waits for ALL open connections (including lingering
|
||||
// WebSocket/keep-alive sockets), so on a stuck connection the process would
|
||||
// otherwise sit zombie and systemd (Restart=always) can never revive it.
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
logger.error({ signal }, "Graceful shutdown timed out; forcing exit");
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
// 1. Stop accepting new HTTP connections
|
||||
if (httpServer) {
|
||||
@@ -54,9 +63,11 @@ async function shutdown(signal: string) {
|
||||
);
|
||||
|
||||
logger.info("Graceful shutdown completed");
|
||||
clearTimeout(forceExitTimer);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error during graceful shutdown");
|
||||
clearTimeout(forceExitTimer);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pgMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { and, desc, eq, ilike, type SQL } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { pgMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import {
|
||||
type MappedMessage,
|
||||
mapMessageRow,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
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,84 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response } from "express";
|
||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||
import { chatbotService } from "./chatbot.service.js";
|
||||
|
||||
const logger = createChildLogger("chatbot.controller");
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
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 auth middleware (if available)
|
||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
|
||||
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 = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
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 = (req as AuthenticatedRequest).userId || "anonymous";
|
||||
|
||||
await chatbotService.clearChatHistory(userId);
|
||||
|
||||
res.status(200).json({
|
||||
message: "Chat history cleared successfully",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { pgChatbotMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("chatbot.repository");
|
||||
|
||||
@@ -31,13 +31,6 @@ export interface ChatbotHistoryRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServerInsights {
|
||||
total_messages: number;
|
||||
active_users: number;
|
||||
flagged: number;
|
||||
warned: number;
|
||||
}
|
||||
|
||||
export class ChatbotRepository {
|
||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||
const db = getDatabase();
|
||||
@@ -83,56 +76,6 @@ export class ChatbotRepository {
|
||||
"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();
|
||||
|
||||
@@ -1,22 +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,6 +6,8 @@ import type {
|
||||
SaveConversationInput,
|
||||
} from "./chatbot.repository.js";
|
||||
import { chatbotRepository } from "./chatbot.repository.js";
|
||||
import { tools } from "./chatbot.toolDefs.js";
|
||||
import { executeTool } from "./chatbot.tools.js";
|
||||
|
||||
const logger = createChildLogger("chatbot.service");
|
||||
|
||||
@@ -16,22 +18,26 @@ class ChatbotService {
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
logger.info(
|
||||
{ userId, messageLength: message.length },
|
||||
{ userId, messageLength: message.length, context },
|
||||
"processMessage called",
|
||||
);
|
||||
const recentContext = await this.getRecentConversationContext(userId);
|
||||
const serverInsights = await chatbotRepository.getServerInsights(
|
||||
context?.guildId,
|
||||
context?.channelId,
|
||||
);
|
||||
// Scope the agent to the server/channel the user is chatting in. We no
|
||||
// longer bake server stats into the prompt — the model must pull current
|
||||
// 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(serverInsights);
|
||||
const systemPrompt = this.buildSystemPrompt(scope);
|
||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
||||
const llmResponse = await this.callLLM(
|
||||
systemPrompt,
|
||||
conversationHistory,
|
||||
message,
|
||||
scope,
|
||||
);
|
||||
|
||||
return llmResponse;
|
||||
@@ -65,27 +71,29 @@ class ChatbotService {
|
||||
]);
|
||||
}
|
||||
|
||||
private buildSystemPrompt(insights: {
|
||||
total_messages: number;
|
||||
active_users: number;
|
||||
flagged: number;
|
||||
warned: number;
|
||||
private buildSystemPrompt(scope: {
|
||||
guildId?: string;
|
||||
channelId?: 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:
|
||||
- Pesan: ${insights.total_messages}
|
||||
- User aktif: ${insights.active_users}
|
||||
- Flagged: ${insights.flagged}
|
||||
- Warning: ${insights.warned}
|
||||
${scopeLine}
|
||||
|
||||
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
||||
- 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:
|
||||
- Santai, hangat, kayak ngobrol sama temen
|
||||
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
||||
- Sesekali pake emoji wajar aja, ga berlebihan
|
||||
- Kalo ditanya sesuatu yang kamu tau dari data server, jawab pake data itu
|
||||
- Kalo ga tau atau ga nyambung, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||
- Jangan sebut "rule", "instruksi", "prompt" atau apapun soal cara kamu berpikir
|
||||
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
|
||||
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
||||
}
|
||||
|
||||
@@ -105,6 +113,7 @@ Gaya ngobrol:
|
||||
systemPrompt: string,
|
||||
history: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
userMessage: string,
|
||||
scope: { guildId?: string; channelId?: string },
|
||||
): Promise<string> {
|
||||
const apiKey = config.AI_LLM_API_KEY;
|
||||
const baseUrl = config.AI_LLM_BASE_URL;
|
||||
@@ -118,41 +127,122 @@ Gaya ngobrol:
|
||||
try {
|
||||
const { default: axios } = await import("axios");
|
||||
|
||||
// Gateway tidak handle role system — gabung konteks ke user message
|
||||
// Gateway tidak handle role system — gabung konteks ke user message.
|
||||
// The system section stays visible to the model as the first user turn.
|
||||
const contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`;
|
||||
|
||||
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
|
||||
...history,
|
||||
{ role: "user", content: contextPrefixed },
|
||||
];
|
||||
// Seed conversation: prior turns + current question.
|
||||
const messages: Array<
|
||||
| { role: "user" | "assistant"; content: string }
|
||||
| {
|
||||
role: "assistant";
|
||||
content: string | null;
|
||||
tool_calls: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
}
|
||||
| { role: "tool"; tool_call_id: string; content: string }
|
||||
> = [...history, { role: "user", content: contextPrefixed }];
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 500,
|
||||
temperature: 0.4,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
// ── Agentic tool loop ─────────────────────────────────────────
|
||||
const MAX_TOOL_ROUNDS = 4;
|
||||
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
max_tokens: 600,
|
||||
temperature: 0.4,
|
||||
// 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",
|
||||
},
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 45_000,
|
||||
responseType: "text",
|
||||
},
|
||||
);
|
||||
|
||||
const result = response.data as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const content = result?.choices?.[0]?.message?.content?.trim();
|
||||
// Parse the body into content + tool_calls. 9router may return either
|
||||
// a single JSON object (stream:false honored) or SSE text (stream
|
||||
// implied) — parseResponse handles both.
|
||||
const { content, toolCalls } = this.parseResponse(
|
||||
response.data as string,
|
||||
);
|
||||
|
||||
if (content) {
|
||||
return content;
|
||||
logger.debug(
|
||||
{
|
||||
round,
|
||||
hasToolCalls: toolCalls.length > 0,
|
||||
toolNames: toolCalls.map((t) => t.name),
|
||||
},
|
||||
"LLM round parsed",
|
||||
);
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
// Execute each tool, append tool results, continue loop.
|
||||
for (const tc of toolCalls) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: { name: tc.name, arguments: tc.arguments },
|
||||
},
|
||||
],
|
||||
});
|
||||
// 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 = "";
|
||||
try {
|
||||
result = await executeTool(tc.name, scopedArgs);
|
||||
} catch (e) {
|
||||
result = `Tool error: ${(e as Error).message}`;
|
||||
}
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: result,
|
||||
});
|
||||
}
|
||||
if (round === MAX_TOOL_ROUNDS) {
|
||||
logger.warn("Hit max tool rounds; returning what we have");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content?.trim()) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
logger.warn("LLM returned empty response (no tools, no content)");
|
||||
return this.fallbackResponse(userMessage);
|
||||
}
|
||||
|
||||
logger.warn({ response: result }, "LLM returned empty response");
|
||||
logger.warn("Tool loop exhausted without final content");
|
||||
return this.fallbackResponse(userMessage);
|
||||
} catch (error) {
|
||||
logger.warn({ error }, "LLM call failed, using fallback response");
|
||||
@@ -160,6 +250,147 @@ 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.
|
||||
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
||||
* even when stream is only implied; we must collect deltas manually.
|
||||
*/
|
||||
private parseSse(body: string): {
|
||||
content: string;
|
||||
toolCalls: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
} {
|
||||
const contentParts: string[] = [];
|
||||
const toolById = new Map<
|
||||
string,
|
||||
{ id: string; name: string; arguments: string }
|
||||
>();
|
||||
|
||||
const lines = body.split("\n");
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
const json = JSON.parse(payload) as {
|
||||
choices?: Array<{
|
||||
delta?: {
|
||||
content?: string;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
index?: number;
|
||||
type?: string;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason?: string | null;
|
||||
}>;
|
||||
};
|
||||
const delta = json.choices?.[0]?.delta;
|
||||
if (!delta) continue;
|
||||
if (delta.content) contentParts.push(delta.content);
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = String(tc.index ?? 0);
|
||||
const cur = toolById.get(idx) ?? {
|
||||
id: tc.id ?? "",
|
||||
name: "",
|
||||
arguments: "",
|
||||
};
|
||||
// Keep the first non-empty id for this call index.
|
||||
if (tc.id && !cur.id) cur.id = tc.id;
|
||||
if (tc.function?.name) cur.name += tc.function.name;
|
||||
if (tc.function?.arguments) cur.arguments += tc.function.arguments;
|
||||
toolById.set(idx, cur);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines (keepalives, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
// Build a de-duplicated id for any call the stream never assigned one.
|
||||
let fallbackId = 0;
|
||||
const toolCalls = Array.from(toolById.values()).map((tc) => {
|
||||
const id = tc.id || `tool_${fallbackId++}_${Date.now()}`;
|
||||
return {
|
||||
id,
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
args: this.safeJsonParse(tc.arguments),
|
||||
};
|
||||
});
|
||||
|
||||
return { content: contentParts.join(""), toolCalls };
|
||||
}
|
||||
|
||||
private safeJsonParse(s: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(s) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackResponse(input: string): string {
|
||||
const lower = input.toLowerCase();
|
||||
|
||||
|
||||
@@ -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)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,448 @@
|
||||
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import {
|
||||
pgChannelCulturesTable,
|
||||
pgCorrectedModerationsTable,
|
||||
pgMessageReviewsTable,
|
||||
pgMessagesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "../../shared/index.js";
|
||||
|
||||
/**
|
||||
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
||||
* live in chatbot.toolDefs.ts (no DB import); this file implements each one
|
||||
* against the real database.
|
||||
*
|
||||
* 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;
|
||||
/** Executes a tool call against the real DB and returns a readable result. */
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const guildId =
|
||||
typeof args.guildId === "string" && args.guildId ? args.guildId : undefined;
|
||||
const channelId =
|
||||
typeof args.channelId === "string" && args.channelId
|
||||
? args.channelId
|
||||
: undefined;
|
||||
const userId =
|
||||
typeof args.userId === "string" && args.userId ? args.userId : undefined;
|
||||
const limitRaw =
|
||||
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
||||
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case "get_server_stats":
|
||||
return await serverStats(guildId, channelId);
|
||||
case "get_top_channels":
|
||||
return await topChannels(guildId, limit);
|
||||
case "get_recent_activity":
|
||||
return await recentActivity(guildId, channelId, limit);
|
||||
case "get_top_flagged":
|
||||
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:
|
||||
return `Unknown tool: ${name}`;
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort: if a tool fails, return readable error instead of crashing
|
||||
return `Terjadi kesalahan saat ambil data: ${(error as Error).message ?? "unknown"}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 ──────────────────────────────────────────
|
||||
|
||||
async function serverStats(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
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`,
|
||||
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||
})
|
||||
.from(pgMessagesTable)
|
||||
.where(scopeMessages(guildId, channelId));
|
||||
|
||||
const r = result ?? {
|
||||
total_messages: 0,
|
||||
active_users: 0,
|
||||
flagged: 0,
|
||||
warned: 0,
|
||||
clean: 0,
|
||||
};
|
||||
return JSON.stringify(r);
|
||||
}
|
||||
|
||||
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
channel_id: pgMessagesTable.channel_id,
|
||||
count: sql<number>`COUNT(*)::int`,
|
||||
})
|
||||
.from(pgMessagesTable)
|
||||
.where(scopeMessages(guildId))
|
||||
.groupBy(pgMessagesTable.channel_id)
|
||||
.orderBy(desc(sql`COUNT(*)`))
|
||||
.limit(limit);
|
||||
return JSON.stringify(rows);
|
||||
}
|
||||
|
||||
async function recentActivity(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
limit = 5,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: pgMessagesTable.id,
|
||||
username: pgMessagesTable.username,
|
||||
user_id: pgMessagesTable.user_id,
|
||||
channel_id: pgMessagesTable.channel_id,
|
||||
content: pgMessagesTable.content,
|
||||
created_at: pgMessagesTable.created_at,
|
||||
ai_status: pgMessagesTable.ai_status,
|
||||
})
|
||||
.from(pgMessagesTable)
|
||||
.where(scopeMessages(guildId, channelId))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit);
|
||||
return JSON.stringify(rows);
|
||||
}
|
||||
|
||||
async function topFlagged(
|
||||
guildId?: string,
|
||||
channelId?: string,
|
||||
limit = 5,
|
||||
): Promise<string> {
|
||||
const db = getDatabase();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: pgMessagesTable.id,
|
||||
username: pgMessagesTable.username,
|
||||
channel_id: pgMessagesTable.channel_id,
|
||||
content: pgMessagesTable.content,
|
||||
ai_status: pgMessagesTable.ai_status,
|
||||
ai_severity: pgMessagesTable.ai_severity,
|
||||
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||
ai_analysis: pgMessagesTable.ai_analysis,
|
||||
created_at: pgMessagesTable.created_at,
|
||||
})
|
||||
.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";
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import {
|
||||
pgChannelCulturesTable,
|
||||
pgMessagesTable,
|
||||
@@ -5,9 +8,6 @@ import {
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "../../shared/index.js";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||
|
||||
export class DashboardRepository {
|
||||
@@ -82,6 +82,52 @@ export class DashboardRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getActivity(days: number) {
|
||||
const db = getDatabase();
|
||||
const sinceMs = Date.now() - days * 86400000;
|
||||
const dayAgoMs = Date.now() - 86400000;
|
||||
|
||||
// Daily buckets (last N days)
|
||||
const daily = await db.execute(sql`
|
||||
SELECT
|
||||
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
|
||||
COUNT(*)::int AS messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||
COUNT(DISTINCT user_id)::int AS active_users
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE created_at >= ${sinceMs}
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
`);
|
||||
|
||||
// Hourly distribution (last 24h)
|
||||
const hourly = await db.execute(sql`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS messages,
|
||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
|
||||
FROM ${pgMessagesTable}
|
||||
WHERE created_at >= ${dayAgoMs}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`);
|
||||
|
||||
return {
|
||||
days,
|
||||
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
|
||||
day: String(r.day),
|
||||
messages: Number(r.messages),
|
||||
flagged: Number(r.flagged),
|
||||
active_users: Number(r.active_users),
|
||||
})),
|
||||
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
|
||||
hour: Number(r.hour),
|
||||
messages: Number(r.messages),
|
||||
flagged: Number(r.flagged),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listUsers(query: ListUsersQuery) {
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 20;
|
||||
@@ -285,6 +331,100 @@ export class DashboardRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getTopReactions(limit: number) {
|
||||
const db = getDatabase();
|
||||
const cap = Math.min(Math.max(limit || 20, 1), 50);
|
||||
|
||||
// Top messages by net reactions (adds minus removes), joined to message content
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
m.id AS message_id,
|
||||
m.content,
|
||||
m.username,
|
||||
m.channel_id,
|
||||
m.created_at,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
r.reaction_count::int
|
||||
FROM (
|
||||
SELECT message_id,
|
||||
(COUNT(*) FILTER (WHERE reaction_type = 'add')
|
||||
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count
|
||||
FROM message_reactions
|
||||
GROUP BY message_id
|
||||
) r
|
||||
JOIN messages m ON m.id = r.message_id
|
||||
WHERE r.reaction_count > 0
|
||||
ORDER BY r.reaction_count DESC
|
||||
LIMIT ${cap}
|
||||
`);
|
||||
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Top emoji per message (adds only) for the breakdown
|
||||
const ids = rows.map((r) => String(r.message_id));
|
||||
const emojiResult = await db.execute(sql`
|
||||
SELECT message_id, emoji, COUNT(*)::int AS c
|
||||
FROM message_reactions
|
||||
WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)})
|
||||
GROUP BY message_id, emoji
|
||||
ORDER BY message_id, c DESC
|
||||
`);
|
||||
|
||||
const emojiByMessage = new Map<
|
||||
string,
|
||||
Array<{ emoji: string; count: number }>
|
||||
>();
|
||||
for (const e of emojiResult.rows as Record<string, unknown>[]) {
|
||||
const mid = String(e.message_id);
|
||||
const list = emojiByMessage.get(mid) ?? [];
|
||||
list.push({ emoji: String(e.emoji), count: Number(e.c) });
|
||||
emojiByMessage.set(mid, list);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
message_id: String(r.message_id),
|
||||
content: r.content ? String(r.content) : "",
|
||||
username: r.username ? String(r.username) : null,
|
||||
channel_id: String(r.channel_id),
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
created_at: r.created_at ? Number(r.created_at) : null,
|
||||
reaction_count: Number(r.reaction_count),
|
||||
top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3),
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopReactors(limit: number) {
|
||||
const db = getDatabase();
|
||||
const cap = Math.min(Math.max(limit || 20, 1), 50);
|
||||
|
||||
// Top users by net reactions given (adds minus removes)
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
user_id,
|
||||
username,
|
||||
(COUNT(*) FILTER (WHERE reaction_type = 'add')
|
||||
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count,
|
||||
COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count,
|
||||
COUNT(DISTINCT message_id)::int AS messages_reacted,
|
||||
COUNT(DISTINCT emoji)::int AS emojis_used
|
||||
FROM message_reactions
|
||||
GROUP BY user_id, username
|
||||
ORDER BY net_count DESC
|
||||
LIMIT ${cap}
|
||||
`);
|
||||
|
||||
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
|
||||
user_id: String(r.user_id),
|
||||
username: String(r.username ?? "unknown"),
|
||||
net_count: Number(r.net_count),
|
||||
adds_count: Number(r.adds_count),
|
||||
messages_reacted: Number(r.messages_reacted),
|
||||
emojis_used: Number(r.emojis_used),
|
||||
}));
|
||||
}
|
||||
|
||||
async getUserDetail(userId: string) {
|
||||
const db = getDatabase();
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
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/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);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -15,6 +15,11 @@ export class DashboardService {
|
||||
return dashboardRepository.getStats();
|
||||
}
|
||||
|
||||
async getActivity(days: number) {
|
||||
logger.debug({ days }, "Fetching dashboard activity");
|
||||
return dashboardRepository.getActivity(days);
|
||||
}
|
||||
|
||||
async listUsers(query: ListUsersQuery) {
|
||||
logger.debug({ query }, "Listing dashboard users");
|
||||
return dashboardRepository.listUsers(query);
|
||||
@@ -38,6 +43,16 @@ export class DashboardService {
|
||||
logger.debug({ channelId }, "Fetching channel detail");
|
||||
return dashboardRepository.getChannelDetail(channelId);
|
||||
}
|
||||
|
||||
async getTopReactions(limit: number) {
|
||||
logger.debug({ limit }, "Fetching top reactions");
|
||||
return dashboardRepository.getTopReactions(limit);
|
||||
}
|
||||
|
||||
async getTopReactors(limit: number) {
|
||||
logger.debug({ limit }, "Fetching top reactors");
|
||||
return dashboardRepository.getTopReactors(limit);
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardService = new DashboardService();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createDashboardRouter } from "./dashboard.routes.js";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("health.repository");
|
||||
|
||||
@@ -67,9 +67,9 @@ export const moderationErrors = new Counter({
|
||||
labelNames: ["type"] as const,
|
||||
});
|
||||
|
||||
export const searxngCalls = new Counter({
|
||||
name: "moderation_searxng_calls_total",
|
||||
help: "SearXNG search calls",
|
||||
export const webSearchCalls = new Counter({
|
||||
name: "moderation_websearch_calls_total",
|
||||
help: "Wikipedia web-search calls",
|
||||
labelNames: ["status"] as const,
|
||||
});
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createMediaRouter } from "./media.routes.js";
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
|
||||
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
|
||||
|
||||
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/volume
|
||||
router.post(
|
||||
"/media/volume",
|
||||
validateBody(mediaVolumeSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { volume } = req.body as { volume: number };
|
||||
logger.debug({ volume }, "Media volume requested");
|
||||
const state = await setVolume(volume);
|
||||
res.json(state);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ export const mediaQueueSchema = z.object({
|
||||
mode: z.enum(["music", "screen"]).default("music"),
|
||||
});
|
||||
|
||||
export const mediaVolumeSchema = z.object({
|
||||
volume: z.number().min(0).max(1).default(1.0),
|
||||
export const mediaLoopSchema = z.object({
|
||||
loop: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
|
||||
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>;
|
||||
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
MEDIA_STATUS_KEY,
|
||||
} from "../../shared/index.js";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import {
|
||||
COMMAND_MEDIA_LOOP,
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
MEDIA_STATUS_KEY,
|
||||
} from "../../shared/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
|
||||
const logger = createChildLogger("media.service");
|
||||
@@ -28,7 +28,10 @@ export interface MediaItem {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
/** null/absent when idle; "music" | "screen" while a track is active. */
|
||||
activeMode?: "music" | "screen" | null;
|
||||
musicVolume: number;
|
||||
loop: boolean;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -41,7 +44,9 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
|
||||
|
||||
const DEFAULT_STATE: MediaState = {
|
||||
playing: false,
|
||||
musicVolume: 1.0,
|
||||
activeMode: null,
|
||||
musicVolume: 0.3,
|
||||
loop: false,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
@@ -56,9 +61,14 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
|
||||
rawPlaying === true ||
|
||||
rawPlaying === "playing" ||
|
||||
rawPlaying === "buffering";
|
||||
const mode = raw.activeMode;
|
||||
const activeMode: "music" | "screen" | null =
|
||||
mode === "music" || mode === "screen" ? mode : null;
|
||||
return {
|
||||
playing,
|
||||
musicVolume: Number(raw.musicVolume ?? 1.0),
|
||||
activeMode,
|
||||
musicVolume: Number(raw.musicVolume ?? 0.3),
|
||||
loop: Boolean(raw.loop ?? false),
|
||||
current: (raw.current as MediaItem | null) ?? null,
|
||||
queue: (raw.queue as MediaItem[]) ?? [],
|
||||
};
|
||||
@@ -99,7 +109,9 @@ export async function queue(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
{ source, mode },
|
||||
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
|
||||
// keep the field name aligned or playback silently no-ops.
|
||||
{ url: source, mode },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
@@ -142,18 +154,18 @@ export async function stop(): Promise<MediaState> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume via Redis command to discord-gateway.
|
||||
* Toggle loop mode (replay current track on natural end) via Redis command.
|
||||
*/
|
||||
export async function setVolume(volume: number): Promise<MediaState> {
|
||||
logger.info({ volume }, "setVolume called");
|
||||
export async function setLoop(loop: boolean): Promise<MediaState> {
|
||||
logger.info({ loop }, "setLoop called");
|
||||
return tryCommandThenFallback(
|
||||
() =>
|
||||
publishCommand<MediaState>(
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
{ volume },
|
||||
COMMAND_MEDIA_LOOP,
|
||||
{ loop },
|
||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
),
|
||||
() => readStatusFallback(),
|
||||
"setVolume",
|
||||
"setLoop",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response } from "express";
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,3 @@
|
||||
import type { PageResult } from "../../shared/index.js";
|
||||
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
@@ -9,13 +6,16 @@ import {
|
||||
isNull,
|
||||
like,
|
||||
lt,
|
||||
ne,
|
||||
notInArray,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { config } from "../../shared/config/index.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import type { PageResult } from "../../shared/index.js";
|
||||
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||
import type {
|
||||
MessageCreate,
|
||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
||||
|
||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
@@ -115,6 +114,27 @@ export class MessagesRepository {
|
||||
return mapMessageRow(row as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit history for a message: previous content snapshots (newest first).
|
||||
* Stored in message_edits by the gateway's message-capture module.
|
||||
*/
|
||||
async getEditHistory(
|
||||
messageId: string,
|
||||
): Promise<Array<{ old_content: string; edited_at: number }>> {
|
||||
const db = getDatabase();
|
||||
const result = await db.execute(sql`
|
||||
SELECT old_content, edited_at
|
||||
FROM message_edits
|
||||
WHERE message_id = ${messageId}
|
||||
ORDER BY edited_at DESC
|
||||
LIMIT 50
|
||||
`);
|
||||
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
|
||||
old_content: String(r.old_content ?? ""),
|
||||
edited_at: Number(r.edited_at ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
async findByChannel(
|
||||
channelId: string,
|
||||
query: MessageQuery,
|
||||
@@ -129,12 +149,11 @@ export class MessagesRepository {
|
||||
|
||||
// Exclude spam threads (NULL-safe)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
conditions.push(
|
||||
or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
@@ -153,6 +172,71 @@ export class MessagesRepository {
|
||||
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) {
|
||||
const db = getDatabase();
|
||||
const id = crypto.randomUUID();
|
||||
@@ -223,58 +307,6 @@ export class MessagesRepository {
|
||||
return mapMessageRow(row as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-reset ai_status from 'error' to 'pending' so the DG recovery worker
|
||||
* picks them up on its next poll cycle.
|
||||
*
|
||||
* Accepts optional scope filters (guildId, channelId) or a list of explicit
|
||||
* message IDs. Returns the count of rows that were actually updated.
|
||||
*/
|
||||
async reanalyzeErrorBatch(opts: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
}): Promise<number> {
|
||||
const db = getDatabase();
|
||||
const conditions: SQL[] = [eq(pgMessagesTable.ai_status, "error")];
|
||||
|
||||
if (opts.messageIds && opts.messageIds.length > 0) {
|
||||
conditions.push(inArray(pgMessagesTable.id, opts.messageIds));
|
||||
}
|
||||
if (opts.guildId) {
|
||||
conditions.push(eq(pgMessagesTable.guild_id, opts.guildId));
|
||||
}
|
||||
if (opts.channelId) {
|
||||
conditions.push(eq(pgMessagesTable.channel_id, opts.channelId));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(pgMessagesTable)
|
||||
.set({ ai_status: "pending" })
|
||||
.where(and(...conditions));
|
||||
|
||||
const count = result.rowCount ?? 0;
|
||||
logger.info({ count, ...opts }, "Batch reanalyze triggered");
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single message for re-analysis by resetting ai_status to 'pending'.
|
||||
* Skips messages already in 'pending' state to avoid write amplification.
|
||||
*/
|
||||
async markForReanalysis(id: string): Promise<void> {
|
||||
const db = getDatabase();
|
||||
await db
|
||||
.update(pgMessagesTable)
|
||||
.set({ ai_status: "pending" })
|
||||
.where(
|
||||
and(
|
||||
eq(pgMessagesTable.id, id),
|
||||
ne(pgMessagesTable.ai_status, "pending"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
|
||||
* Optionally filtered by channelId, with configurable limit.
|
||||
@@ -347,12 +379,13 @@ export class MessagesRepository {
|
||||
like(pgAttachmentsTable.type, "image/%"),
|
||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||
...(EXCLUDED_THREAD_IDS.length > 0
|
||||
? [
|
||||
or(
|
||||
? (() => {
|
||||
const excludeThreads = or(
|
||||
isNull(pgAttachmentsTable.thread_id),
|
||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
)!,
|
||||
]
|
||||
);
|
||||
return excludeThreads ? [excludeThreads] : [];
|
||||
})()
|
||||
: []),
|
||||
),
|
||||
)
|
||||
@@ -385,6 +418,12 @@ export class MessagesRepository {
|
||||
const limit = query.limit ?? 50;
|
||||
const conditions: SQL[] = [eq(pgAttachmentsTable.channel_id, channelId)];
|
||||
|
||||
// Detail view: narrow to the selected message so we don't show
|
||||
// everyone else's images from the same channel.
|
||||
if (query.messageId) {
|
||||
conditions.push(eq(pgAttachmentsTable.message_id, query.messageId));
|
||||
}
|
||||
|
||||
if (query.cursor) {
|
||||
conditions.push(lt(pgAttachmentsTable.created_at, Number(query.cursor)));
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||
import {
|
||||
handleGetAttachmentsByChannel,
|
||||
handleGetImageMessages,
|
||||
handleGetMessageById,
|
||||
handleGetMessagesByChannel,
|
||||
handleListMessages,
|
||||
} from "./messages.controller.js";
|
||||
import { reanalyzeBatchSchema } from "./messages.schema.js";
|
||||
import { messagesService } from "./messages.service.js";
|
||||
|
||||
const logger = createChildLogger("messages.routes");
|
||||
|
||||
/**
|
||||
* Per-message in-flight guard for the single reanalyze endpoint.
|
||||
* Prevents concurrent spam-clicks from issuing duplicate UPDATE + recovery
|
||||
* worker triggers for the same message.
|
||||
*/
|
||||
const reanalyzeInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Per-scope in-flight guard for the batch reanalyze endpoint.
|
||||
* Scope key = "guildId:channelId" (empty string used for undefined parts).
|
||||
* Two concurrent batch-reanalyze requests for the same scope are rejected
|
||||
* with 409 so the recovery worker is not triggered multiple times for the
|
||||
* same set of error messages.
|
||||
*/
|
||||
const reanalyzeBatchInFlight = new Set<string>();
|
||||
|
||||
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);
|
||||
|
||||
// POST /api/messages/reanalyze-batch — Bulk retry all errored messages
|
||||
// MUST be registered BEFORE /messages/:id/reanalyze so "reanalyze-batch"
|
||||
// is not captured as an :id param.
|
||||
router.post(
|
||||
"/messages/reanalyze-batch",
|
||||
validateBody(reanalyzeBatchSchema),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { guildId, channelId, messageIds } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
};
|
||||
|
||||
// Idempotency guard: one concurrent batch-reanalyze per scope.
|
||||
// Prevents two admin sessions clicking simultaneously from each
|
||||
// triggering the recovery worker for the same set of messages.
|
||||
const scopeKey = `${guildId ?? ""}:${channelId ?? ""}`;
|
||||
if (reanalyzeBatchInFlight.has(scopeKey)) {
|
||||
res
|
||||
.status(409)
|
||||
.json({ error: "REANALYZE_BATCH_IN_PROGRESS", scope: scopeKey });
|
||||
return;
|
||||
}
|
||||
|
||||
reanalyzeBatchInFlight.add(scopeKey);
|
||||
let count = 0;
|
||||
try {
|
||||
count = await messagesService.reanalyzeErrorBatch({
|
||||
guildId,
|
||||
channelId,
|
||||
messageIds,
|
||||
});
|
||||
} finally {
|
||||
reanalyzeBatchInFlight.delete(scopeKey);
|
||||
}
|
||||
|
||||
logger.info({ count, guildId, channelId }, "Batch reanalyze completed");
|
||||
res.status(200).json({ ok: true, count });
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /api/messages/:id/reanalyze - Mark single message for re-analysis
|
||||
router.post(
|
||||
"/messages/:id/reanalyze",
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const id = String(req.params.id ?? "");
|
||||
if (!id) {
|
||||
res.status(400).json({ error: "MISSING_ID" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency guard: reject concurrent duplicate requests for the same ID.
|
||||
if (reanalyzeInFlight.has(id)) {
|
||||
res.status(409).json({ error: "REANALYZE_IN_PROGRESS", messageId: id });
|
||||
return;
|
||||
}
|
||||
|
||||
reanalyzeInFlight.add(id);
|
||||
try {
|
||||
await messagesService.markForReanalysis(id);
|
||||
} finally {
|
||||
reanalyzeInFlight.delete(id);
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export const messageQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
cursor: z.string().optional(),
|
||||
// Filter attachments to a single message (used by the message detail view)
|
||||
messageId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const messageCreateSchema = z.object({
|
||||
@@ -36,13 +38,14 @@ export const messageUpdateSchema = z.object({
|
||||
aiConfidence: z.number().optional(),
|
||||
});
|
||||
|
||||
export const reanalyzeBatchSchema = z.object({
|
||||
guildId: z.string().optional(),
|
||||
channelId: z.string().optional(),
|
||||
messageIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||
export type ReanalyzeBatchInput = z.infer<typeof reanalyzeBatchSchema>;
|
||||
|
||||
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 { createChildLogger } from "@/shared/logger/index";
|
||||
import { embedQuery } from "./embed.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");
|
||||
|
||||
@@ -15,6 +17,14 @@ export class MessagesService {
|
||||
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) {
|
||||
if (!channelId) {
|
||||
throw new ValidationError("channelId is required");
|
||||
@@ -34,7 +44,12 @@ export class MessagesService {
|
||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return message;
|
||||
const editHistory = await messagesRepository.getEditHistory(id);
|
||||
return {
|
||||
...message,
|
||||
edit_count: editHistory.length,
|
||||
edit_history: editHistory,
|
||||
};
|
||||
}
|
||||
|
||||
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
|
||||
@@ -58,15 +73,6 @@ export class MessagesService {
|
||||
return messagesRepository.getImageMessages(guildId, limit);
|
||||
}
|
||||
|
||||
async markForReanalysis(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
throw new ValidationError("message ID is required");
|
||||
}
|
||||
|
||||
logger.debug({ id }, "Marking message for re-analysis");
|
||||
await messagesRepository.markForReanalysis(id);
|
||||
}
|
||||
|
||||
async getReviewMessages(
|
||||
channelId?: string,
|
||||
limit?: number,
|
||||
@@ -75,24 +81,39 @@ export class MessagesService {
|
||||
return messagesRepository.getReviewMessages(channelId, limit);
|
||||
}
|
||||
|
||||
async reanalyzeErrorBatch(opts: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
}) {
|
||||
if (
|
||||
!opts.guildId &&
|
||||
!opts.channelId &&
|
||||
(!opts.messageIds || opts.messageIds.length === 0)
|
||||
) {
|
||||
throw new ValidationError(
|
||||
"At least one of guildId, channelId, or messageIds[] is required",
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
logger.info(opts, "Batch reanalyzing errored messages");
|
||||
return messagesRepository.reanalyzeErrorBatch(opts);
|
||||
const hits = await searchArchive(vector, input.limit, 0.6);
|
||||
const results = hits.map((h) => mapSearchHit(h));
|
||||
return { results, nextCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
export interface ListModerationQuery {
|
||||
status?: string;
|
||||
actionType?: string;
|
||||
limit?: number;
|
||||
cursor?: number;
|
||||
}
|
||||
|
||||
const ACTION_TYPES = [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
] 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 {
|
||||
async getStats() {
|
||||
const db = getDatabase();
|
||||
const result = await db.execute(sql`
|
||||
SELECT action_type, status, COUNT(*)::int AS c
|
||||
FROM moderation_actions
|
||||
GROUP BY action_type, status
|
||||
`);
|
||||
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
let executed = 0;
|
||||
let failed = 0;
|
||||
let pending = 0;
|
||||
|
||||
const byAction: Record<
|
||||
string,
|
||||
{ executed: number; failed: number; pending: number }
|
||||
> = {};
|
||||
|
||||
for (const r of rows) {
|
||||
const actionType = String(r.action_type ?? "unknown");
|
||||
const status = String(r.status ?? "unknown");
|
||||
const count = Number(r.c ?? 0);
|
||||
byAction[actionType] ??= { executed: 0, failed: 0, pending: 0 };
|
||||
if (status === "executed") {
|
||||
executed += count;
|
||||
byAction[actionType].executed += count;
|
||||
} else if (status === "failed") {
|
||||
failed += count;
|
||||
byAction[actionType].failed += count;
|
||||
} else {
|
||||
pending += count;
|
||||
byAction[actionType].pending += count;
|
||||
}
|
||||
}
|
||||
|
||||
const total = executed + failed + pending;
|
||||
|
||||
return {
|
||||
total,
|
||||
executed,
|
||||
failed,
|
||||
pending,
|
||||
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
|
||||
by_action: byAction,
|
||||
};
|
||||
}
|
||||
|
||||
async listActions(query: ListModerationQuery) {
|
||||
const db = getDatabase();
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (
|
||||
query.status &&
|
||||
(STATUSES as readonly string[]).includes(query.status)
|
||||
) {
|
||||
conditions.push(`a.status = '${query.status}'`);
|
||||
}
|
||||
if (
|
||||
query.actionType &&
|
||||
(ACTION_TYPES as readonly string[]).includes(query.actionType)
|
||||
) {
|
||||
conditions.push(`a.action_type = '${query.actionType}'`);
|
||||
}
|
||||
if (query.cursor) {
|
||||
conditions.push(`a.created_at < ${Number(query.cursor)}`);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
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.executed_by,
|
||||
a.status,
|
||||
a.error,
|
||||
a.created_at,
|
||||
a.executed_at,
|
||||
a.flags,
|
||||
a.categories,
|
||||
a.severity,
|
||||
a.confidence,
|
||||
a.score,
|
||||
a.evidence,
|
||||
a.policy_version,
|
||||
m.username,
|
||||
LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
${whereClause}
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ${limit + 1}
|
||||
`),
|
||||
);
|
||||
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
const data = rows.slice(0, limit).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,
|
||||
executed_by: r.executed_by ? String(r.executed_by) : null,
|
||||
status: String(r.status ?? "unknown"),
|
||||
error: r.error ? String(r.error) : null,
|
||||
created_at: r.created_at ? Number(r.created_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,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
|
||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||
const nextCursor =
|
||||
rows.length > limit ? String(lastRow?.created_at ?? "") : null;
|
||||
|
||||
return { data, nextCursor };
|
||||
}
|
||||
}
|
||||
|
||||
export const moderationRepository = new ModerationRepository();
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import {
|
||||
type ListModerationQuery,
|
||||
moderationRepository,
|
||||
} from "./moderation.repository.js";
|
||||
|
||||
const logger = createChildLogger("moderation.service");
|
||||
|
||||
export class ModerationService {
|
||||
async getStats() {
|
||||
logger.debug("Fetching moderation stats");
|
||||
return moderationRepository.getStats();
|
||||
}
|
||||
|
||||
async listActions(query: ListModerationQuery) {
|
||||
logger.debug({ query }, "Listing moderation actions");
|
||||
return moderationRepository.listActions(query);
|
||||
}
|
||||
}
|
||||
|
||||
export const moderationService = new ModerationService();
|
||||
@@ -1 +0,0 @@
|
||||
export { createRecordingsRouter } from "./recordings.routes.js";
|
||||
@@ -1,41 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
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,7 +1,7 @@
|
||||
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { and, desc, eq, lt, type SQL } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
|
||||
const logger = createChildLogger("recordings.service");
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface RecordingRow {
|
||||
upload_error: string | null;
|
||||
created_at: number;
|
||||
uploaded_at: number | null;
|
||||
duration_bytes: number;
|
||||
}
|
||||
|
||||
export interface PaginatedRecordings {
|
||||
@@ -69,7 +68,6 @@ export class RecordingsService {
|
||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||
created_at: pgVoiceRecordingsTable.created_at,
|
||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||
duration_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||
})
|
||||
.from(pgVoiceRecordingsTable)
|
||||
.where(where)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createUiStateRouter } from "./ui-state.routes.js";
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
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,5 +1,5 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
const logger = createChildLogger("ui-state.service");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { createVoiceRouter } from "./voice.routes.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Authoritative live-voice store.
|
||||
*
|
||||
* Single source of truth for who is present / speaking in voice. The backend
|
||||
* WebSocket server is the one relay every frontend client connects to, so it
|
||||
* is the correct place to aggregate the gateway's `voice_active_user` deltas
|
||||
* into a shared snapshot. A late-joining browser must be able to see the same
|
||||
* state as everyone else — this store makes that possible (seeded into the WS
|
||||
* initial states and served via GET /api/voice/status).
|
||||
*/
|
||||
|
||||
export interface LiveSpeaker {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
/** Epoch ms of the most recent activity (start OR end of speech). */
|
||||
lastActiveAt: number;
|
||||
}
|
||||
|
||||
const speakers = new Map<string, LiveSpeaker>();
|
||||
|
||||
const MAX_SPEAKERS = 200;
|
||||
|
||||
/**
|
||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||
* active; `speaking: false` marks them inactive while keeping them for the
|
||||
* activity timeline.
|
||||
*/
|
||||
/**
|
||||
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
|
||||
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
|
||||
* keeping them for the activity timeline.
|
||||
*/
|
||||
export function recordSpeaker(data: {
|
||||
userId: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
}): void {
|
||||
const { userId, speaking } = data;
|
||||
const existing = speakers.get(userId);
|
||||
const speaker: LiveSpeaker = {
|
||||
userId,
|
||||
username: data.username ?? existing?.username ?? "Unknown",
|
||||
avatar: data.avatar ?? existing?.avatar ?? null,
|
||||
speaking,
|
||||
lastActiveAt: Date.now(),
|
||||
};
|
||||
|
||||
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
||||
// Drop the least-recently-active non-speaking speaker to stay bounded.
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Infinity;
|
||||
for (const [id, s] of speakers) {
|
||||
if (!s.speaking && s.lastActiveAt < oldestTs) {
|
||||
oldestTs = s.lastActiveAt;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
if (oldestId) speakers.delete(oldestId);
|
||||
else return;
|
||||
}
|
||||
|
||||
speakers.set(userId, speaker);
|
||||
}
|
||||
|
||||
/** All known speakers, most recently active first. */
|
||||
export function getActiveSpeakers(): LiveSpeaker[] {
|
||||
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/** Only speakers currently flagged as speaking. */
|
||||
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
||||
return [...speakers.values()]
|
||||
.filter((s) => s.speaking)
|
||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||
}
|
||||
|
||||
/** Drop all tracked speakers (used on backend restart). */
|
||||
export function resetLiveSpeakers(): void {
|
||||
speakers.clear();
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response } from "express";
|
||||
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 { createChildLogger } from "@/shared/logger/index";
|
||||
import type { Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import {
|
||||
COMMAND_GUILDS_LIST,
|
||||
COMMAND_GUILDS_TEXT_CHANNELS,
|
||||
@@ -8,13 +14,8 @@ import {
|
||||
pgMessagesTable,
|
||||
VOICE_STATUS_KEY,
|
||||
} from "../../shared/index.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createChildLogger,
|
||||
tryCommandThenFallback,
|
||||
} from "../../shared/commandHelper.js";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
|
||||
|
||||
const logger = createChildLogger("voice.service");
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "voice" | "text";
|
||||
/** Whether the selfbot account can actually join this voice channel. */
|
||||
joinable?: boolean;
|
||||
}
|
||||
|
||||
export interface GuildVoiceEntry {
|
||||
@@ -43,6 +46,12 @@ export interface VoiceStatus {
|
||||
activeChannelId: string | null;
|
||||
activeChannelName: string | null;
|
||||
connections: GuildVoiceEntry[];
|
||||
/**
|
||||
* Authoritative shared voice snapshot — who is present / speaking right
|
||||
* now, aggregated server-side from the gateway's `voice_active_user`
|
||||
* deltas. All browsers converge on this same list.
|
||||
*/
|
||||
activeSpeakers: LiveSpeaker[];
|
||||
}
|
||||
|
||||
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||
@@ -51,8 +60,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
connections: [],
|
||||
activeSpeakers: [],
|
||||
};
|
||||
|
||||
/** Attach the live speaker snapshot to any voice status payload. */
|
||||
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
|
||||
status: T,
|
||||
): T & { activeSpeakers: LiveSpeaker[] } {
|
||||
return { ...status, activeSpeakers: getActiveSpeakers() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
|
||||
* Attempts a Redis command first; on failure, falls back to the provided function.
|
||||
@@ -66,8 +83,10 @@ async function withFallback<T>(
|
||||
}
|
||||
|
||||
function readVoiceStatusFallback(): Promise<VoiceStatus> {
|
||||
return readRedisStatus(VOICE_STATUS_KEY).then(
|
||||
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
|
||||
withActiveSpeakers(
|
||||
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,7 +156,9 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
logger.debug("getVoiceStatus called");
|
||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
||||
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS;
|
||||
return withActiveSpeakers(
|
||||
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
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";
|
||||
// ── Service imports ──────────────────────────────────────────────
|
||||
import { dashboardService } from "../modules/dashboard/dashboard.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)),
|
||||
};
|
||||
|
||||
// ── 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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
// ── 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 };
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Config (public dashboard config snapshot) ──────────────────────
|
||||
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,
|
||||
};
|
||||
|
||||
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"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
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_IMAGE_MAX_DIMENSION: z.coerce
|
||||
.number()
|
||||
@@ -160,8 +161,6 @@ export const configSchema = z
|
||||
.default(30000)
|
||||
.describe("Timeout for individual LLM moderation calls"),
|
||||
|
||||
|
||||
|
||||
// ── AI Analysis Timing ──────────────────────────────────────────────
|
||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
||||
@@ -210,6 +209,11 @@ export const configSchema = z
|
||||
.default("https://api.openai.com/v1"),
|
||||
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_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import { closePool, createPoolFromConfig } from "./pool.js";
|
||||
|
||||
const logger = createChildLogger("database.init");
|
||||
|
||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||
}),
|
||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||
ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", {
|
||||
mode: "number",
|
||||
}),
|
||||
ai_error: pgText("ai_error"),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -592,5 +595,4 @@ export type DbRetentionPolicyInsert =
|
||||
|
||||
// Chatbot Messages
|
||||
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||
export type ChatbotMessageInsert =
|
||||
typeof chatbotMessagesTable.$inferInsert;
|
||||
export type ChatbotMessageInsert = typeof chatbotMessagesTable.$inferInsert;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AppError, ValidationError } from "@/shared/errors/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { ZodSchema } from "zod";
|
||||
import { AppError, ValidationError } from "@/shared/errors/index";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
|
||||
const logger = createChildLogger("middleware");
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface MessageRecord {
|
||||
ai_confidence?: number | null;
|
||||
ai_recommended_action?: AIRecommendedAction | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
ai_analysis_duration_ms?: number | null;
|
||||
ai_error?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue";
|
||||
export const COMMAND_MEDIA_SKIP = "media:skip";
|
||||
export const COMMAND_MEDIA_STOP = "media:stop";
|
||||
export const COMMAND_MEDIA_VOLUME = "media:volume";
|
||||
export const COMMAND_MEDIA_LOOP = "media:loop";
|
||||
export const COMMAND_MODERATION_ACTION = "moderation:action";
|
||||
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../config/index.js";
|
||||
import {
|
||||
BACKEND_COMMAND,
|
||||
BACKEND_COMMAND_REPLY_PREFIX,
|
||||
@@ -6,8 +8,6 @@ import {
|
||||
type CommandReply,
|
||||
} from "../index.js";
|
||||
import { createChildLogger } from "../logger/index.js";
|
||||
import Redis from "ioredis";
|
||||
import { config } from "../config/index.js";
|
||||
|
||||
const logger = createChildLogger("redis.command-channel");
|
||||
|
||||
|
||||
@@ -108,5 +108,5 @@ export async function retryWithBackoff<T>(
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
throw lastError ?? new Error("Request failed after all retries");
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface MappedMessage {
|
||||
ai_confidence: number | null;
|
||||
ai_recommended_action: string | null;
|
||||
ai_analyzed_at: number | null;
|
||||
ai_analysis_duration_ms: number | null;
|
||||
ai_error: string | null;
|
||||
is_reply: boolean | null;
|
||||
is_forward: boolean | null;
|
||||
@@ -58,6 +59,8 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||
ai_analysis_duration_ms:
|
||||
(row.ai_analysis_duration_ms as number | null) ?? null,
|
||||
ai_error: (row.ai_error as string | null) ?? null,
|
||||
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
||||
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DISCORD_CHANNEL_TO_WS_EVENT, DISCORD_VOICE_PCM } from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import Redis from "ioredis";
|
||||
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import {
|
||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||
DISCORD_VOICE_ACTIVE_USER,
|
||||
DISCORD_VOICE_PCM,
|
||||
} from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
||||
|
||||
const logger = createChildLogger("ws.redis-bridge");
|
||||
@@ -59,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate live-voice state authoritatively BEFORE broadcasting.
|
||||
// Every browser hears the same `voice_active_user` deltas, so the backend
|
||||
// can maintain the single shared snapshot for late-joining clients.
|
||||
if (channel === DISCORD_VOICE_ACTIVE_USER) {
|
||||
const speaker = data as {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
avatar?: string | null;
|
||||
speaking?: boolean;
|
||||
};
|
||||
if (speaker?.userId) {
|
||||
recordSpeaker({
|
||||
userId: speaker.userId,
|
||||
username: speaker.username,
|
||||
avatar: speaker.avatar,
|
||||
speaking: Boolean(speaker.speaking),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||
broadcastEvent(eventType, data);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { messagesService } from "../modules/messages/messages.service.js";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||
import { createChildLogger } from "../shared/logger/index.js";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { config } from "../shared/config/index.js";
|
||||
import { setBroadcastFunctions } from "./broadcast.js";
|
||||
|
||||
const logger = createChildLogger("ws.server");
|
||||
@@ -66,6 +68,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send initial media_state");
|
||||
}
|
||||
|
||||
// Send initial live-voice snapshot (shared authoritative state — a browser
|
||||
// joining mid-call sees the same speakers as everyone else, not an empty DB).
|
||||
try {
|
||||
const { getActiveSpeakers } = await import(
|
||||
"../modules/voice/live-speaker.js"
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "voice_state",
|
||||
state: { activeSpeakers: getActiveSpeakers() },
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Failed to send initial voice_state");
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWebSocketServer(): void {
|
||||
@@ -80,9 +98,20 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
||||
const frontendClients = 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;
|
||||
|
||||
// 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
|
||||
const jsonHandlers = new Map<string, MessageHandler>();
|
||||
|
||||
@@ -112,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) => {
|
||||
// Parse auth token from query string
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
// ─── Shared Error Classes ────────────────────────────────────────────────────
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AppError,
|
||||
ConfigError,
|
||||
@@ -6,7 +8,9 @@ import {
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "@bete/shared/errors";
|
||||
} from "../src/shared/errors/index.js";
|
||||
// ─── Backend middleware ──────────────────────────────────────────────────────
|
||||
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
||||
// ─── Shared utilities ─────────────────────────────────────────────────────────
|
||||
import {
|
||||
decodeCursor,
|
||||
@@ -14,11 +18,7 @@ import {
|
||||
encodeCursor,
|
||||
pageResult,
|
||||
retryWithBackoff,
|
||||
} from "@bete/shared/utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ─── Backend middleware ──────────────────────────────────────────────────────
|
||||
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
||||
} from "../src/shared/utils/index.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 1. AppError / Error Hierarchy Tests
|
||||
|
||||
@@ -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,10 +1,16 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
testTimeout: 15000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,172 +1,143 @@
|
||||
# Discord Gateway — Architecture
|
||||
|
||||
Pure event-driven microservice (no HTTP server). Captures Discord
|
||||
messages/voice/attachments/reactions/threads/presence, runs LLM-based AI
|
||||
moderation, and publishes everything to Redis pub/sub for the backend to
|
||||
consume. The backend serves the HTTP/WS API to the frontend.
|
||||
|
||||
> NOTE: this doc is the source of truth for the module layout. The older
|
||||
> `MODULE_STRUCTURE.md` was stale (referenced `winston`, `mock-crc.ts`,
|
||||
> `indonesianTextNormalizer.ts`, and `aiAnalysisWorker.ts`/`llmModerationClient.ts`
|
||||
> which were renamed/merged). If they disagree, this file wins.
|
||||
|
||||
## Top-level layout
|
||||
|
||||
```
|
||||
services/discord-gateway/
|
||||
├── src/
|
||||
│ ├── index.ts # Entry point → initializeDiscordGateway()
|
||||
│ ├── app/
|
||||
│ │ ├── bootstrap.ts # Discord Gateway initialization (no HTTP server)
|
||||
│ │ └── shutdown.ts # Graceful shutdown handler
|
||||
│ │ ├── bootstrap.ts # Wires client, DB, Redis, workers, schedulers
|
||||
│ │ ├── shutdown.ts # Graceful shutdown (SIGINT/SIGTERM + transient errors)
|
||||
│ │ └── retention.ts # Expired-record cleanup scheduler
|
||||
│ ├── shared/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.ts # Environment configuration (Zod validated)
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── schema.ts # Drizzle ORM schema
|
||||
│ │ │ ├── drizzle.ts # Database connection
|
||||
│ │ │ ├── migrate.ts # Migration runner
|
||||
│ │ │ └── voiceRecordingRepo.ts
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── errors.ts # Custom error classes
|
||||
│ │ ├── logger/
|
||||
│ │ │ ├── logger.ts # Winston logger wrapper
|
||||
│ │ │ └── serialization.ts # Log value serialization
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── retry.ts # Retry with backoff utility
|
||||
│ │ └── discord/
|
||||
│ │ └── clientOptions.ts # Discord.js client configuration
|
||||
│ ├── modules/
|
||||
│ │ ├── message-capture/ # Modular MVC: Message capture & storage
|
||||
│ │ │ ├── messageCapture.ts # Controller: Discord event listeners
|
||||
│ │ │ ├── messageStore.ts # Repository: Database operations
|
||||
│ │ │ ├── messageMetadata.ts # Service: Message metadata extraction
|
||||
│ │ │ ├── types.ts # Domain types
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── ai-moderation/ # Modular MVC: AI analysis & moderation
|
||||
│ │ │ ├── aiAnalyzer.ts # Controller: Analysis orchestration
|
||||
│ │ │ ├── llmModerationClient.ts # Service: LLM API client
|
||||
│ │ │ ├── aiAnalysisWorker.ts # Service: Worker pool management
|
||||
│ │ │ ├── indonesianTextNormalizer.ts # Service: Text normalization
|
||||
│ │ │ ├── moderationPrompt.ts # Service: Prompt generation
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── voice-recording/ # Modular MVC: Voice recording & streaming
|
||||
│ │ │ ├── voiceController.ts # Controller: Voice connection management
|
||||
│ │ │ ├── recorder.ts # Service: Recording orchestration
|
||||
│ │ │ ├── recorder/
|
||||
│ │ │ │ ├── audioStream.ts # Service: Audio stream subscription
|
||||
│ │ │ │ ├── decoder.ts # Service: Opus decoding
|
||||
│ │ │ │ ├── segment.ts # Service: OGG segment rotation
|
||||
│ │ │ │ ├── metadata.ts # Service: Segment metadata
|
||||
│ │ │ │ ├── sessionRecording.ts # Service: Session management
|
||||
│ │ │ │ └── uploader.ts # Service: Segment upload
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ ├── attachment-upload/ # Modular MVC: Attachment handling
|
||||
│ │ │ ├── attachmentUploader.ts # Service: Upload orchestration
|
||||
│ │ │ ├── imageResizer.ts # Service: Image resizing
|
||||
│ │ │ └── index.ts # Module exports
|
||||
│ │ └── event-broadcaster/ # Event-driven: Redis pub/sub
|
||||
│ │ ├── eventBroadcaster.ts # Service: Event publishing
|
||||
│ │ ├── eventTypes.ts # Domain: Event type definitions
|
||||
│ │ └── index.ts # Module exports
|
||||
│ ├── mock-crc.ts # CRC polyfill for discord.js
|
||||
│ └── index.ts # Service entry point
|
||||
├── package.json # Service dependencies
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
|
||||
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
|
||||
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
|
||||
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
|
||||
│ │ ├── logger/ # pino wrapper + createChildLogger()
|
||||
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
|
||||
│ │ ├── utils/ # retry, pagination
|
||||
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
|
||||
│ │ ├── uploader.ts # Shared attachment upload helper
|
||||
│ │ ├── redis-channels.ts # Redis channel-name constants
|
||||
│ │ └── moderation-types.ts # Shared AI analysis domain types
|
||||
│ └── modules/
|
||||
│ ├── message-capture/ # Discord event listeners + DB store
|
||||
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
|
||||
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
|
||||
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
|
||||
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
|
||||
│ ├── attachment-upload/ # Download + (sharp) resize + upload
|
||||
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
|
||||
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||
│ ├── channel-topic/ guild-member-events/
|
||||
│ └── gateway-metrics/ # Prometheus /metrics endpoint (port 4016)
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
## AI moderation pipeline (`ai-moderation/`)
|
||||
|
||||
### Modular MVC Structure
|
||||
Each module follows Controller-Service-Repository pattern:
|
||||
- **Controller**: Discord event listeners (messageCapture, aiAnalyzer, voiceController)
|
||||
- **Service**: Business logic (messageStore, llmModerationClient, recorder)
|
||||
- **Repository**: Data access (messageStore, voiceRecordingRepo)
|
||||
LLM-only judge — no regex/heuristic classification. One orchestrator call
|
||||
handles a whole batch (text + media split internally, parallel paths).
|
||||
|
||||
### Event-Driven Design
|
||||
- **Redis Pub/Sub**: All events published to Redis channels
|
||||
- **Event Channels**:
|
||||
- `discord:message:created` — New message captured
|
||||
- `discord:message:updated` — Message edited
|
||||
- `discord:message:deleted` — Message deleted
|
||||
- `discord:message:analyzed` — AI analysis complete
|
||||
- `discord:attachment:created` — Attachment detected
|
||||
- `discord:attachment:uploaded` — Attachment uploaded to storage
|
||||
- `discord:voice:started` — Voice recording started
|
||||
- `discord:voice:stopped` — Voice recording stopped
|
||||
- `discord:voice:uploaded` — Voice segment uploaded
|
||||
- `discord:analysis:queue_status` — Analysis queue status update
|
||||
- `aiAnalyzer.ts` — public API: `queueMessageAnalysis`, `getAnalysisQueueStatus`,
|
||||
`startPendingAIAnalysisWorker` (recovery worker + cache-prune).
|
||||
- `batchScheduler.ts` — per-conversation debounce → `processBatch`.
|
||||
- `batchProcessor.ts` — batch lock/circuit-breaker, fans failed targets to
|
||||
individual fallback.
|
||||
- `individualFallbackProcessor.ts` — one-message-at-a-time retry path, own CB.
|
||||
- `conversationState.ts` / `circuitBreaker.ts` — per-conversation state,
|
||||
Piscina `workerPool`, `getConversationKey`.
|
||||
- `ai-analysis-worker.ts` — Piscina entry point (`batch` / `individual` jobs).
|
||||
Runs `runModerationAnalysis` off the main thread.
|
||||
- `moderationOrchestrator.ts` — exact-hash cache → batched semantic (Qdrant)
|
||||
cache → LLM. Text and media paths run in parallel.
|
||||
- `textBatchProcessor.ts` / `mediaBatchProcessor.ts` — actual LLM calls
|
||||
(one call per sub-batch, not per message).
|
||||
- `llmClient.ts` — central OpenAI-compatible chat client (streaming, retries,
|
||||
thinking-disable injection). `visionAnalyzer.ts` / `mediaAnalysisClient.ts`
|
||||
share the same router/base URL (different model alias for vision).
|
||||
- `embeddingClient.ts` + `qdrantClient.ts` — semantic cache (one embed call +
|
||||
one batched Qdrant search for all uncached targets).
|
||||
- `textCacheStore.ts` / `channelCultureStore.ts` / `userProfileStore.ts` /
|
||||
`userReputationStore.ts` — caches & learned per-channel/user state.
|
||||
|
||||
### Shared Infrastructure
|
||||
- **Config**: Zod-validated environment variables
|
||||
- **Logger**: Winston logger with context support
|
||||
- **Database**: Drizzle ORM with PostgreSQL
|
||||
- **Errors**: Custom error classes with codes and status codes
|
||||
- **Utils**: Retry logic with exponential backoff
|
||||
### Concurrency model
|
||||
|
||||
### No HTTP Server
|
||||
- Discord Gateway service is **event-driven only**
|
||||
- No Express, WebSocket, or HTTP routes
|
||||
- All communication via Redis pub/sub
|
||||
- Backend service consumes events and serves HTTP API
|
||||
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
||||
`llmClient.withLlmConcurrency`.
|
||||
- Piscina pool (`PISCINA_MAX_THREADS`, default 4) runs the heavy LLM work off
|
||||
the event loop; **each worker thread initializes its own pg Pool** (min 0,
|
||||
grows to `POSTGRES_POOL_MAX`). See "Memory & connections" below.
|
||||
|
||||
## Initialization Flow
|
||||
## Memory & DB connections
|
||||
|
||||
1. Load environment config (Zod validation)
|
||||
2. Initialize database connection
|
||||
3. Run pending migrations
|
||||
4. Create Discord client with optimized cache settings
|
||||
5. Initialize Redis event broadcaster
|
||||
6. Register Discord event listeners (messageCapture, aiAnalyzer)
|
||||
7. Login to Discord
|
||||
8. Listen for graceful shutdown signals (SIGINT, SIGTERM)
|
||||
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
||||
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
||||
|
||||
## Graceful Shutdown
|
||||
`POSTGRES_POOL_MIN=0` (default). The gateway = main process + up to 4 Piscina
|
||||
worker threads, each with its own pg Pool. With min:0 the pools stay empty
|
||||
until a query runs and drop idle clients afterward, instead of holding
|
||||
`(1 main + 4 workers) × 2 = 10` permanently-open idle connections against
|
||||
PgBouncer. The pool still grows on demand up to `POSTGRES_POOL_MAX`.
|
||||
|
||||
On shutdown signal:
|
||||
1. Close database connection
|
||||
2. Disconnect from voice channels
|
||||
3. Close Redis connection
|
||||
4. Destroy Discord client
|
||||
5. Exit process
|
||||
## Event channels (Redis pub/sub)
|
||||
|
||||
## Dependencies
|
||||
`discord:message:{created,updated,deleted,analyzed}`,
|
||||
`discord:attachment:{created,uploaded}`,
|
||||
`discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}`,
|
||||
`discord:analysis:queue_status`,
|
||||
`discord:reaction:{added,removed}`,
|
||||
`discord:thread:{created,deleted,updated}`,
|
||||
`discord:channel_topic:updated`,
|
||||
`discord:presence:updated`,
|
||||
`discord:guild_member:{added,removed}`.
|
||||
See `src/shared/redis-channels.ts` for the canonical names.
|
||||
|
||||
**Core Discord**:
|
||||
- discord.js-selfbot-v13
|
||||
- @discordjs/voice
|
||||
- @discordjs/opus
|
||||
## Initialization flow
|
||||
|
||||
**Audio Processing**:
|
||||
- prism-media (Opus encoding/decoding)
|
||||
- opusscript (Opus fallback)
|
||||
- sharp (Image resizing)
|
||||
1. Validate env (Zod). Refuse to start if `AI_ANALYSIS_ENABLED` but no key.
|
||||
2. `AUTO_MIGRATE_ON_STARTUP` → run pending Drizzle migrations.
|
||||
3. `initializeDatabase()` (pg Pool, min 0).
|
||||
4. Create discord.js-selfbot-v13 client; register listeners on `ready`.
|
||||
5. Start `gmw-discord-gateway` metrics server (port `METRICS_PORT`, default 4016).
|
||||
6. `client.login(token)`.
|
||||
|
||||
**Data & Config**:
|
||||
- drizzle-orm (ORM)
|
||||
- pg (PostgreSQL driver)
|
||||
- zod (Config validation)
|
||||
- ioredis (Redis client)
|
||||
## Graceful shutdown
|
||||
|
||||
**Logging & Utilities**:
|
||||
- winston (Structured logging)
|
||||
- p-retry (Retry logic)
|
||||
- p-limit (Concurrency limiting)
|
||||
- piscina (Worker pool)
|
||||
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
|
||||
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
|
||||
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
|
||||
close command handler → close DB → destroy client → exit.
|
||||
|
||||
## Event Flow Example
|
||||
## Observability
|
||||
|
||||
### Message Capture Flow
|
||||
1. Discord emits `messageCreate` event
|
||||
2. `messageCapture.ts` listener receives event
|
||||
3. Extract metadata (user, channel, content, timestamp)
|
||||
4. `messageStore.ts` inserts into database
|
||||
5. `eventBroadcaster.messageCreated()` publishes to Redis
|
||||
6. Backend service subscribes to `discord:message:created` channel
|
||||
7. Backend processes and stores in its own database
|
||||
Prometheus scrapes `127.0.0.1:4016/metrics` (`bete_*` prefix). Collectors run
|
||||
per-scrape and expose: process memory/uptime, and (when AI analysis is on) live
|
||||
pipeline gauges — `ai_analysis_queued_conversations`,
|
||||
`ai_analysis_active_batch_requests`, `ai_analysis_active_individual_requests`,
|
||||
`ai_analysis_individual_in_flight`, `ai_analysis_individual_circuit_breaker_active`,
|
||||
`ai_analysis_worker_threads`, `ai_analysis_worker_threads_active`.
|
||||
|
||||
### Voice Recording Flow
|
||||
1. `voiceController.connect()` joins voice channel
|
||||
2. `recorder.ts` subscribes to user audio streams
|
||||
3. For each speaking user:
|
||||
- Create audio stream subscription
|
||||
- Decode Opus packets to PCM
|
||||
- Rotate OGG segments (5s default)
|
||||
- Collect user metadata
|
||||
4. On silence (3s):
|
||||
- Finalize segment
|
||||
- Create metadata JSON
|
||||
- Upload segment to storage
|
||||
- Publish `discord:voice:uploaded` event
|
||||
5. Backend service receives event and indexes recording
|
||||
## Key invariants (do not break)
|
||||
|
||||
## No Breaking Changes
|
||||
|
||||
- Original `src/` remains untouched for now
|
||||
- Discord Gateway is a **new service** in `services/discord-gateway/`
|
||||
- Can run alongside existing monolith during transition
|
||||
- Backend service will consume Redis events
|
||||
- Frontend continues to use Backend HTTP API
|
||||
- **LLM is the only judge.** Failed LLM → `status:"error"` + recovery retry.
|
||||
Never reintroduce regex/heuristic content classification.
|
||||
- **Discord tokens are sanitized** (`discordTokens.ts`: `<:emoji:id>` →
|
||||
`[emoji:name]`, `<@id>` → `@user`, etc.) before content reaches the LLM, so
|
||||
numeric snowflake IDs never trigger false positives.
|
||||
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
||||
not N sequential round-trips. `ensureQdrantCollection` is memoized.
|
||||
- **Streaming is mandatory** against the 9router base URL (non-stream waits for
|
||||
the full body and times out). `llmClient` aggregates SSE chunks.
|
||||
|
||||
@@ -1,408 +1,80 @@
|
||||
# Discord Gateway Service - Module Structure
|
||||
# Discord Gateway Service — Module Structure
|
||||
|
||||
## Complete Directory Tree
|
||||
> Kept as a compact module map. For the authoritative layout, design
|
||||
> decisions, and invariants, see `ARCHITECTURE.md`. This file was rewritten
|
||||
> on 2026-08-16 to fix stale references (`winston` → pino,
|
||||
> `mock-crc.ts`/`indonesianTextNormalizer.ts` removed,
|
||||
> `aiAnalysisWorker.ts` → `ai-analysis-worker.ts`,
|
||||
> `llmModerationClient.ts` → `llmClient.ts`).
|
||||
|
||||
## Top-level
|
||||
|
||||
```
|
||||
services/discord-gateway/
|
||||
├── src/
|
||||
│ ├── app/
|
||||
│ │ ├── bootstrap.ts
|
||||
│ │ │ └── Initializes Discord client, database, Redis broadcaster
|
||||
│ │ │ Registers event listeners, handles graceful shutdown
|
||||
│ │ └── shutdown.ts
|
||||
│ │ └── Graceful shutdown handler for SIGINT/SIGTERM/exceptions
|
||||
│ │
|
||||
│ ├── shared/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.ts
|
||||
│ │ │ └── Zod-validated environment configuration
|
||||
│ │ │ - Discord token, database URL, Redis URL
|
||||
│ │ │ - AI LLM settings, recording parameters
|
||||
│ │ │ - Attachment upload settings, retention policies
|
||||
│ │ │
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── schema.ts
|
||||
│ │ │ │ └── Drizzle ORM schema definitions
|
||||
│ │ │ ├── drizzle.ts
|
||||
│ │ │ │ └── PostgreSQL connection and initialization
|
||||
│ │ │ ├── migrate.ts
|
||||
│ │ │ │ └── Database migration runner
|
||||
│ │ │ ├── migrateCli.ts
|
||||
│ │ │ │ └── CLI for programmatic migrations
|
||||
│ │ │ ├── voiceRecordingRepo.ts
|
||||
│ │ │ │ └── Voice recording repository
|
||||
│ │ │ └── migrations/
|
||||
│ │ │ └── Database migration files
|
||||
│ │ │
|
||||
│ │ ├── errors/
|
||||
│ │ │ └── errors.ts
|
||||
│ │ │ └── Custom error classes
|
||||
│ │ │ - AppError (base)
|
||||
│ │ │ - ConfigError
|
||||
│ │ │ - AudioError
|
||||
│ │ │ - VoiceConnectionError
|
||||
│ │ │ - ValidationError
|
||||
│ │ │
|
||||
│ │ ├── logger/
|
||||
│ │ │ ├── logger.ts
|
||||
│ │ │ │ └── Winston logger wrapper with context support
|
||||
│ │ │ └── serialization.ts
|
||||
│ │ │ └── Log value serialization utilities
|
||||
│ │ │
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── retry.ts
|
||||
│ │ │ └── Retry with exponential backoff utility
|
||||
│ │ │
|
||||
│ │ └── discord/
|
||||
│ │ └── clientOptions.ts
|
||||
│ │ └── Discord.js client configuration
|
||||
│ │
|
||||
│ ├── modules/
|
||||
│ │ │
|
||||
│ │ ├── message-capture/
|
||||
│ │ │ ├── messageCapture.ts
|
||||
│ │ │ │ └── CONTROLLER: Discord event listeners
|
||||
│ │ │ │ - messageCreate, messageUpdate, messageDelete
|
||||
│ │ │ │ - Validates capture target, publishes events
|
||||
│ │ │ │
|
||||
│ │ │ ├── messageStore.ts
|
||||
│ │ │ │ └── REPOSITORY: Database CRUD operations
|
||||
│ │ │ │ - upsertMessageForCapture
|
||||
│ │ │ │ - updateMessageAsEdited
|
||||
│ │ │ │ - updateMessageAsDeleted
|
||||
│ │ │ │ - insertAttachment
|
||||
│ │ │ │ - getMessageById
|
||||
│ │ │ │
|
||||
│ │ │ ├── messageMetadata.ts
|
||||
│ │ │ │ └── SERVICE: Message metadata extraction
|
||||
│ │ │ │ - getMessageMetadata
|
||||
│ │ │ │ - getMessageLocation
|
||||
│ │ │ │ - getDisplayContent
|
||||
│ │ │ │
|
||||
│ │ │ ├── types.ts
|
||||
│ │ │ │ └── Domain types
|
||||
│ │ │ │ - MessageRecord
|
||||
│ │ │ │ - AttachmentRecord
|
||||
│ │ │ │ - VoiceSegmentRecord
|
||||
│ │ │ │ - AIStatus, AISeverity, AIRecommendedAction
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── ai-moderation/
|
||||
│ │ │ ├── aiAnalyzer.ts
|
||||
│ │ │ │ └── CONTROLLER: Analysis orchestration
|
||||
│ │ │ │ - startPendingAIAnalysisWorker
|
||||
│ │ │ │ - queueMessageAnalysis
|
||||
│ │ │ │ - Manages analysis queue and worker pool
|
||||
│ │ │ │
|
||||
│ │ │ ├── llmModerationClient.ts
|
||||
│ │ │ │ └── SERVICE: LLM API integration
|
||||
│ │ │ │ - Calls LLM for text/image moderation
|
||||
│ │ │ │ - Parses responses, handles errors
|
||||
│ │ │ │ - Retry logic with backoff
|
||||
│ │ │ │
|
||||
│ │ │ ├── aiAnalysisWorker.ts
|
||||
│ │ │ │ └── SERVICE: Worker pool management
|
||||
│ │ │ │ - Piscina worker pool for parallel analysis
|
||||
│ │ │ │ - Conversation context batching
|
||||
│ │ │ │
|
||||
│ │ │ ├── indonesianTextNormalizer.ts
|
||||
│ │ │ │ └── SERVICE: Text preprocessing
|
||||
│ │ │ │ - Normalize Indonesian text
|
||||
│ │ │ │ - Handle diacritics, abbreviations
|
||||
│ │ │ │
|
||||
│ │ │ ├── moderationPrompt.ts
|
||||
│ │ │ │ └── SERVICE: Prompt generation
|
||||
│ │ │ │ - Generate LLM prompts for moderation
|
||||
│ │ │ │ - Include context and policy
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── voice-recording/
|
||||
│ │ │ ├── voiceController.ts
|
||||
│ │ │ │ └── CONTROLLER: Voice connection management
|
||||
│ │ │ │ - connect(guildId, channelId)
|
||||
│ │ │ │ - disconnect()
|
||||
│ │ │ │ - listGuilds(), listVoiceChannels()
|
||||
│ │ │ │ - getStatus()
|
||||
│ │ │ │
|
||||
│ │ │ ├── recorder.ts
|
||||
│ │ │ │ └── SERVICE: Recording orchestration
|
||||
│ │ │ │ - startRecording(client, channel)
|
||||
│ │ │ │ - stopRecording(guildId)
|
||||
│ │ │ │ - Manages active recording sessions
|
||||
│ │ │ │
|
||||
│ │ │ ├── recorder/
|
||||
│ │ │ │ ├── audioStream.ts
|
||||
│ │ │ │ │ └── SERVICE: Audio stream subscription
|
||||
│ │ │ │ │ - subscribeToAudioStream
|
||||
│ │ │ │ │ - Opus packet handling
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── decoder.ts
|
||||
│ │ │ │ │ └── SERVICE: Opus decoding
|
||||
│ │ │ │ │ - OpusDecoder class
|
||||
│ │ │ │ │ - Decode Opus to PCM
|
||||
│ │ │ │ │ - Rotation and cooldown logic
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── segment.ts
|
||||
│ │ │ │ │ └── SERVICE: OGG segment rotation
|
||||
│ │ │ │ │ - SegmentManager class
|
||||
│ │ │ │ │ - Rotate segments (5s default)
|
||||
│ │ │ │ │ - Write OGG files
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── metadata.ts
|
||||
│ │ │ │ │ └── SERVICE: Segment metadata
|
||||
│ │ │ │ │ - collectUserMetadata
|
||||
│ │ │ │ │ - createSegmentMetadata
|
||||
│ │ │ │ │ - User info, roles, timestamps
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├── sessionRecording.ts
|
||||
│ │ │ │ │ └── SERVICE: Session management
|
||||
│ │ │ │ │ - createRecordingSession
|
||||
│ │ │ │ │ - finalizeRecordingSession
|
||||
│ │ │ │ │ - Track active sessions
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └── uploader.ts
|
||||
│ │ │ │ └── SERVICE: Segment upload
|
||||
│ │ │ │ - uploadRecordingSegment
|
||||
│ │ │ │ - Upload to external storage
|
||||
│ │ │ │ - Retry logic
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ ├── attachment-upload/
|
||||
│ │ │ ├── attachmentUploader.ts
|
||||
│ │ │ │ └── SERVICE: Upload orchestration
|
||||
│ │ │ │ - processAttachmentUpload
|
||||
│ │ │ │ - Download from Discord
|
||||
│ │ │ │ - Upload to external storage
|
||||
│ │ │ │ - Retry with backoff
|
||||
│ │ │ │
|
||||
│ │ │ ├── imageResizer.ts
|
||||
│ │ │ │ └── SERVICE: Image processing
|
||||
│ │ │ │ - resizeImage
|
||||
│ │ │ │ - Resize to max dimension
|
||||
│ │ │ │ - Preserve aspect ratio
|
||||
│ │ │ │
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── Module exports
|
||||
│ │
|
||||
│ │ └── event-broadcaster/
|
||||
│ │ ├── eventBroadcaster.ts
|
||||
│ │ │ └── SERVICE: Redis pub/sub publisher
|
||||
│ │ │ - EventBroadcaster class
|
||||
│ │ │ - RedisEventPublisher class
|
||||
│ │ │ - Publish to Redis channels
|
||||
│ │ │ - Methods:
|
||||
│ │ │ - messageCreated()
|
||||
│ │ │ - messageUpdated()
|
||||
│ │ │ - messageDeleted()
|
||||
│ │ │ - messageAnalyzed()
|
||||
│ │ │ - attachmentCreated()
|
||||
│ │ │ - attachmentUploaded()
|
||||
│ │ │ - voiceRecordingStarted()
|
||||
│ │ │ - voiceRecordingStopped()
|
||||
│ │ │ - voiceRecordingUploaded()
|
||||
│ │ │ - analysisQueueStatus()
|
||||
│ │ │
|
||||
│ │ ├── eventTypes.ts
|
||||
│ │ │ └── Domain types
|
||||
│ │ │ - DiscordGatewayEvent interface
|
||||
│ │ │ - EventChannels constants
|
||||
│ │ │ - Event channel names
|
||||
│ │ │
|
||||
│ │ └── index.ts
|
||||
│ └── Module exports
|
||||
│
|
||||
│ ├── mock-crc.ts
|
||||
│ │ └── CRC polyfill for discord.js compatibility
|
||||
│ │
|
||||
│ └── index.ts
|
||||
│ └── Service entry point
|
||||
│ - Initialize Discord Gateway
|
||||
│ - Handle startup errors
|
||||
│
|
||||
├── ARCHITECTURE.md
|
||||
│ └── Detailed architecture documentation
|
||||
│
|
||||
├── README.md
|
||||
│ └── Complete service documentation
|
||||
│
|
||||
├── MODULE_STRUCTURE.md
|
||||
│ └── This file - module structure reference
|
||||
│
|
||||
└── package.json
|
||||
└── Service dependencies and scripts
|
||||
│ ├── index.ts # Entry point
|
||||
│ ├── app/ # bootstrap, shutdown, retention
|
||||
│ ├── shared/ # config, database, logger, errors, utils, discord, uploader
|
||||
│ └── modules/
|
||||
│ ├── message-capture/ # Discord listeners + DB store + metadata
|
||||
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
|
||||
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
|
||||
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
|
||||
│ ├── attachment-upload/ # Download + sharp resize + upload
|
||||
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||
│ ├── command-handler/ # Backend→gateway Redis commands
|
||||
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||
│ ├── channel-topic/ guild-member-events/
|
||||
│ └── gateway-metrics/ # Prometheus /metrics (port 4016)
|
||||
├── tests/ # Vitest suites (129 tests)
|
||||
├── drizzle/ # Drizzle migration SQL + journal
|
||||
├── ARCHITECTURE.md README.md package.json tsconfig.json vitest.config.ts
|
||||
```
|
||||
|
||||
## Module Responsibilities
|
||||
## Module responsibilities (summary)
|
||||
|
||||
### message-capture
|
||||
**Purpose**: Capture Discord messages (create, update, delete)
|
||||
**Pattern**: Controller-Service-Repository
|
||||
- **Controller** (messageCapture.ts): Listens to Discord events
|
||||
- **Service** (messageMetadata.ts): Extracts metadata
|
||||
- **Repository** (messageStore.ts): Database operations
|
||||
- **Events Published**:
|
||||
- `discord:message:created`
|
||||
- `discord:message:updated`
|
||||
- `discord:message:deleted`
|
||||
Captures `messageCreate`/`messageUpdate`/`messageDelete`, extracts metadata,
|
||||
stores to Postgres, publishes to Redis. Controller–Service–Repository split:
|
||||
`messageCapture.ts` (listener) → `messageStore.ts` (DB) + `messageMetadata.ts`
|
||||
(service).
|
||||
|
||||
### ai-moderation
|
||||
**Purpose**: Analyze messages with LLM for moderation
|
||||
**Pattern**: Controller-Service-Service-Service
|
||||
- **Controller** (aiAnalyzer.ts): Orchestrates analysis workflow
|
||||
- **Service** (llmModerationClient.ts): LLM API integration
|
||||
- **Service** (aiAnalysisWorker.ts): Worker pool management
|
||||
- **Service** (indonesianTextNormalizer.ts): Text preprocessing
|
||||
- **Service** (moderationPrompt.ts): Prompt generation
|
||||
- **Events Published**:
|
||||
- `discord:message:analyzed`
|
||||
- `discord:analysis:queue_status`
|
||||
LLM-only moderation. Entry: `aiAnalyzer.ts` (`queueMessageAnalysis`,
|
||||
`startPendingAIAnalysisWorker`, `getAnalysisQueueStatus`). Scheduling:
|
||||
`batchScheduler.ts` → `batchProcessor.ts` (batch lock + circuit breaker) →
|
||||
`individualFallbackProcessor.ts` (per-message retry). Heavy work runs in the
|
||||
Piscina pool via `ai-analysis-worker.ts` (jobs `batch` / `individual`).
|
||||
Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched
|
||||
semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
|
||||
(one LLM call per sub-batch), `llmClient.ts` (central streaming client),
|
||||
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
|
||||
`channelCultureStore.ts` / `userProfileStore.ts` / `userReputationStore.ts`.
|
||||
|
||||
### voice-recording
|
||||
**Purpose**: Record voice channel audio
|
||||
**Pattern**: Controller-Service-SubServices
|
||||
- **Controller** (voiceController.ts): Voice connection management
|
||||
- **Service** (recorder.ts): Recording orchestration
|
||||
- **Sub-services** (recorder/*): Audio processing pipeline
|
||||
- audioStream.ts: Opus packet subscription
|
||||
- decoder.ts: Opus to PCM decoding
|
||||
- segment.ts: OGG file rotation
|
||||
- metadata.ts: User metadata collection
|
||||
- sessionRecording.ts: Session lifecycle
|
||||
- uploader.ts: Segment upload
|
||||
- **Events Published**:
|
||||
- `discord:voice:started`
|
||||
- `discord:voice:stopped`
|
||||
- `discord:voice:uploaded`
|
||||
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
|
||||
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
|
||||
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
|
||||
|
||||
### attachment-upload
|
||||
**Purpose**: Upload message attachments to external storage
|
||||
**Pattern**: Service-Service
|
||||
- **Service** (attachmentUploader.ts): Upload orchestration
|
||||
- **Service** (imageResizer.ts): Image processing
|
||||
- **Events Published**:
|
||||
- `discord:attachment:created`
|
||||
- `discord:attachment:uploaded`
|
||||
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
|
||||
(sharp resize). Emits `discord:attachment:*`.
|
||||
|
||||
### event-broadcaster
|
||||
**Purpose**: Publish events to Redis pub/sub
|
||||
**Pattern**: Service-Domain
|
||||
- **Service** (eventBroadcaster.ts): Redis publisher
|
||||
- **Domain** (eventTypes.ts): Event type definitions
|
||||
- **Channels**:
|
||||
- discord:message:* (message events)
|
||||
- discord:attachment:* (attachment events)
|
||||
- discord:voice:* (voice events)
|
||||
- discord:analysis:* (analysis events)
|
||||
`RedisEventPublisher` (ioredis publish) + `EventBroadcaster` (typed methods).
|
||||
Channel names in `src/shared/redis-channels.ts`.
|
||||
|
||||
## Shared Infrastructure
|
||||
### gateway-metrics
|
||||
`metrics.ts` Prometheus HTTP server on `METRICS_PORT` (4016). Collectors run
|
||||
per scrape; live pipeline gauges registered in `bootstrap.ts`.
|
||||
|
||||
### config
|
||||
- Zod-validated environment variables
|
||||
- Type-safe configuration access
|
||||
- Sensible defaults
|
||||
## Shared infrastructure
|
||||
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
|
||||
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
|
||||
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
|
||||
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
|
||||
|
||||
### database
|
||||
- Drizzle ORM schema
|
||||
- PostgreSQL connection
|
||||
- Migration management
|
||||
- Voice recording repository
|
||||
|
||||
### logger
|
||||
- Winston logger wrapper
|
||||
- Context-aware logging
|
||||
- Log serialization utilities
|
||||
|
||||
### errors
|
||||
- Custom error classes
|
||||
- Error codes and HTTP status codes
|
||||
- Proper error hierarchy
|
||||
|
||||
### utils
|
||||
- Retry with exponential backoff
|
||||
- Configurable retry parameters
|
||||
|
||||
### discord
|
||||
- Discord.js client configuration
|
||||
- Cache optimization
|
||||
- Partial handling
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Discord Events
|
||||
↓
|
||||
message-capture (Controller)
|
||||
↓
|
||||
messageStore (Repository) → PostgreSQL
|
||||
↓
|
||||
eventBroadcaster (Service)
|
||||
↓
|
||||
Redis Pub/Sub
|
||||
↓
|
||||
Backend Service (Subscriber)
|
||||
↓
|
||||
HTTP API / WebSocket
|
||||
↓
|
||||
Frontend Application
|
||||
```
|
||||
|
||||
## No HTTP Server
|
||||
|
||||
- ✅ No Express
|
||||
- ✅ No WebSocket server
|
||||
- ✅ No HTTP routes
|
||||
- ✅ No middleware
|
||||
- ✅ Pure event-driven service
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
1. Close PostgreSQL connection
|
||||
2. Disconnect from voice channels
|
||||
3. Close Redis connection
|
||||
4. Destroy Discord client
|
||||
5. Exit process
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Discord**:
|
||||
- discord.js-selfbot-v13
|
||||
- @discordjs/voice
|
||||
- @discordjs/opus
|
||||
|
||||
**Audio**:
|
||||
- prism-media
|
||||
- opusscript
|
||||
- sharp
|
||||
|
||||
**Data**:
|
||||
- drizzle-orm
|
||||
- pg
|
||||
- zod
|
||||
- ioredis
|
||||
|
||||
**Logging**:
|
||||
- winston
|
||||
- p-retry
|
||||
- p-limit
|
||||
- piscina
|
||||
|
||||
## Summary
|
||||
|
||||
The Discord Gateway service is a **pure event-driven microservice** that:
|
||||
- Captures Discord messages, voice, and attachments
|
||||
- Performs AI moderation analysis
|
||||
- Publishes events to Redis pub/sub
|
||||
- Has no HTTP server or WebSocket
|
||||
- Follows Modular MVC pattern
|
||||
- Maintains clean module boundaries
|
||||
- Provides type-safe configuration
|
||||
- Includes structured logging
|
||||
- Handles graceful shutdown
|
||||
|
||||
The service is designed to run alongside the Backend service, which consumes Redis events and serves the HTTP API to the Frontend.
|
||||
## Notes
|
||||
- No HTTP server (other than the metrics endpoint). Pure event-driven.
|
||||
- `MODULE_STRUCTURE.md` is intentionally a sketch; `ARCHITECTURE.md` is the
|
||||
detailed reference. When they diverge, `ARCHITECTURE.md` wins.
|
||||
|
||||
@@ -243,10 +243,10 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
||||
- Connect to Backend HTTP API
|
||||
- Subscribe to WebSocket events
|
||||
|
||||
3. **Docker & CI/CD**
|
||||
- Dockerfile for Discord Gateway
|
||||
- Docker Compose for multi-service setup
|
||||
- GitHub Actions for build/deploy
|
||||
3. **Nix & CI/CD**
|
||||
- flake.nix package for Discord Gateway
|
||||
- systemd services (gmw-backend, gmw-discord-gateway)
|
||||
- GitHub Actions for build/deploy (nix copy → systemctl restart)
|
||||
|
||||
4. **Documentation**
|
||||
- API documentation
|
||||
|
||||
@@ -7,6 +7,6 @@ export default defineConfig({
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL ||
|
||||
"postgresql://postgres:postgres@localhost:5432/bete",
|
||||
"postgresql://asephs:***@100.121.180.82:6432/dcbot",
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user