Compare commits
210
Commits
903e7c4aeb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b784d6d796 | ||
|
|
00e8d68ce5 | ||
|
|
2a8f6d9062 | ||
|
|
9b3134d767 | ||
|
|
36363fa3db | ||
|
|
d133cc3271 | ||
|
|
5a70a685b4 | ||
|
|
100b62800c | ||
|
|
1ae19074ee | ||
|
|
d68f6b653a | ||
|
|
29baba3a72 | ||
|
|
217ecc1aa1 | ||
|
|
0cb0b82fb1 | ||
|
|
95f2903067 | ||
|
|
d78d7a0181 | ||
|
|
ff8a9c50ae | ||
|
|
6bd6ddcdca | ||
|
|
b38616e051 | ||
|
|
479f4719ba | ||
|
|
2825250804 | ||
|
|
aa280c48b7 | ||
|
|
4cf5b87f2b | ||
|
|
0dff7770a1 | ||
|
|
e3dd6a3427 | ||
|
|
55fdcfaae3 | ||
|
|
cf1ec25c71 | ||
|
|
0ace758c79 | ||
|
|
c89288191e | ||
|
|
4655125541 | ||
|
|
ba60448d05 | ||
|
|
1d809b2c95 | ||
|
|
38bda66933 | ||
|
|
726a8e116b | ||
|
|
2fa1827f17 | ||
|
|
d8552a9fb8 | ||
|
|
a4abe3abea | ||
|
|
b67856462f | ||
|
|
30828a5534 | ||
|
|
a3e5a8c1b9 | ||
|
|
f82b5caae4 | ||
|
|
9e2b107fcd | ||
|
|
d2e97ae11d | ||
|
|
6244e307a3 | ||
|
|
2d7c7f2c35 | ||
|
|
416c690ebc | ||
|
|
c590a8be27 | ||
|
|
9c83ec86cc | ||
|
|
17a4fbd73d | ||
|
|
c04c410fad | ||
|
|
5e5f4ae208 | ||
|
|
e2013988ff | ||
|
|
0164444dd7 | ||
|
|
9ae26b8ec9 | ||
|
|
7ebee7559d | ||
|
|
66c33a2657 | ||
|
|
25b220b7f9 | ||
|
|
1c4f28c5f2 | ||
|
|
392db8eba1 | ||
|
|
3c2c1c3b15 | ||
|
|
1b56212d1a | ||
|
|
b98101c576 | ||
|
|
84757bdcf4 | ||
|
|
6c9a91dad4 | ||
|
|
bcb563ea7f | ||
|
|
589fd38fd8 | ||
|
|
da02bfff9b | ||
|
|
a66db8d702 | ||
|
|
d65dc11c73 | ||
|
|
8b281c7feb | ||
|
|
5bbf75a65b | ||
|
|
5816e94a63 | ||
|
|
11f2ad5f23 | ||
|
|
6e188f81d6 | ||
|
|
7c376ea66a | ||
|
|
8ee32b8df8 | ||
|
|
c285a4c813 | ||
|
|
f156fc0c9e | ||
|
|
89f1097729 | ||
|
|
df24c756a0 | ||
|
|
add31d3561 | ||
|
|
6e7c4901c9 | ||
|
|
60faaa9304 | ||
|
|
5505983dbd | ||
|
|
3d57e9c102 | ||
|
|
d3cb5f6756 | ||
|
|
37787cc4f0 | ||
|
|
f849a87f2f | ||
|
|
deb5dedf2c | ||
|
|
3b221823e7 | ||
|
|
9f7ce7dbd5 | ||
|
|
bd292fdf3d | ||
|
|
7a7f433988 | ||
|
|
84c5c36672 | ||
|
|
c5898f7cf0 | ||
|
|
354e378e74 | ||
|
|
392bc35a0d | ||
|
|
196cb1d3af | ||
|
|
00fc852a32 | ||
|
|
d9f5592e6e | ||
|
|
f1aa08cdf6 | ||
|
|
f70a92880e | ||
|
|
88b13225cd | ||
|
|
67ab289caa | ||
|
|
ef9e243609 | ||
|
|
42a503c206 | ||
|
|
652974e23a | ||
|
|
407e003399 | ||
|
|
968a43b0f4 | ||
|
|
91c7a67d2f | ||
|
|
8615383829 | ||
|
|
10d7ecd405 | ||
|
|
ff554fcff2 | ||
|
|
f8b253ba5e | ||
|
|
c8473b0610 | ||
|
|
3deca91ffe | ||
|
|
edec2edf82 | ||
|
|
17013fe1e5 | ||
|
|
3acb03391a | ||
|
|
9109d3c898 | ||
|
|
9139e225f4 | ||
|
|
9ae230d047 | ||
|
|
a1a6d8b418 | ||
|
|
4f06c30c05 | ||
|
|
2203dd5771 | ||
|
|
a53d7b71da | ||
|
|
c18431bdbf | ||
|
|
4f4c43555f | ||
|
|
50371bd2d1 | ||
|
|
0792ff4dc0 | ||
|
|
eb89bb79ed | ||
|
|
7d6c741bb2 | ||
|
|
4cb4904517 | ||
|
|
4ee295bd29 | ||
|
|
65c9c2cd9e | ||
|
|
0a5254bf20 | ||
|
|
4a51f3055c | ||
|
|
185d81f0e0 | ||
|
|
ecbb538c9f | ||
|
|
4049ab4201 | ||
|
|
5d094829c4 | ||
|
|
abbd78f42b | ||
|
|
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 |
+5
-5
@@ -39,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
|||||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||||
|
|
||||||
# === Webserver ===
|
# === Webserver ===
|
||||||
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001)
|
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||||
|
|
||||||
# === Connection ===
|
# === Connection ===
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
||||||
@@ -55,7 +55,7 @@ VERBOSE=false # Enable verbose/debug logging (default:
|
|||||||
|
|
||||||
# === Database (PostgreSQL) ===
|
# === Database (PostgreSQL) ===
|
||||||
# Option 1: Connection string (overrides individual params)
|
# 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
|
# Option 2: Individual connection parameters
|
||||||
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
|
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
|
||||||
@@ -67,11 +67,11 @@ POSTGRES_POOL_MIN=2 # Minimum pool connections (default: 2)
|
|||||||
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
||||||
|
|
||||||
# === Redis ===
|
# === 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 WebSocket (direct gateway→backend, bypasses Redis) ===
|
||||||
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
|
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
|
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
|
||||||
|
|
||||||
# === Attachments ===
|
# === Attachments ===
|
||||||
@@ -90,7 +90,7 @@ 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_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_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)
|
# 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_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_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation)
|
||||||
# QDRANT_API_KEY= # Qdrant API key (optional)
|
# QDRANT_API_KEY= # Qdrant API key (optional)
|
||||||
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
|
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
|
||||||
|
|||||||
+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,
|
# 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
|
# but the database name must clearly be a test database so destructive test setup
|
||||||
# cannot touch production data.
|
# cannot touch production data.
|
||||||
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.108.1.124:5432/hub_test
|
DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
name: Build & Deploy (Nix)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
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 }}
|
|
||||||
# Declarative env: source of truth = Gitea secrets, CI writes the
|
|
||||||
# service env files on the VPS. Update env by editing the secret,
|
|
||||||
# never by SSH-ing into the VPS by hand.
|
|
||||||
BACKEND_ENV: ${{ secrets.BACKEND_ENV }}
|
|
||||||
GATEWAY_ENV: ${{ secrets.GATEWAY_ENV }}
|
|
||||||
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
|
|
||||||
|
|
||||||
# --- Deploy env (declarative, from Gitea secrets) ---
|
|
||||||
ENV_TARGET=""
|
|
||||||
ENV_VALUE=""
|
|
||||||
case "$SERVICE" in
|
|
||||||
backend) ENV_TARGET="/etc/gmw/backend.env"; ENV_VALUE="$BACKEND_ENV" ;;
|
|
||||||
discord-gateway) ENV_TARGET="/etc/gmw/discord-gateway.env"; ENV_VALUE="$GATEWAY_ENV" ;;
|
|
||||||
proxy) ;; # nginx proxy has no env file
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -n "$ENV_VALUE" ] && [ -n "$ENV_TARGET" ]; then
|
|
||||||
echo "=== Deploying env: $ENV_TARGET ==="
|
|
||||||
printf '%s\n' "$ENV_VALUE" | ssh -i "$key_file" -o StrictHostKeyChecking=no \
|
|
||||||
"${VPS_USER}@${VPS_HOST}" "
|
|
||||||
set -eu
|
|
||||||
mkdir -p /etc/gmw
|
|
||||||
cat > $ENV_TARGET
|
|
||||||
chown gmw:gmw $ENV_TARGET
|
|
||||||
chmod 600 $ENV_TARGET
|
|
||||||
echo \"env file lines: \$(grep -c '=' $ENV_TARGET)\"
|
|
||||||
"
|
|
||||||
else
|
|
||||||
echo "=== No env secret for $SERVICE — skipping env deploy ==="
|
|
||||||
fi
|
|
||||||
|
|
||||||
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/
|
.worktrees/
|
||||||
services/frontend/frontend/dist/
|
services/frontend/frontend/dist/
|
||||||
target/
|
target/
|
||||||
|
nix/
|
||||||
# Gitea CI runner logs
|
# Gitea CI runner logs
|
||||||
.gitea/workflows/*.log
|
.gitea/workflows/*.log
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
# GMW Frontend — Greenfield Rebuild (Visual-System Overhaul + Custom UI + Motion/3D)
|
||||||
|
|
||||||
|
> **For Hermes:** Execute with `subagent-driven-development` (one fresh subagent per task, two-stage review). Each task is 1–3 min, atomic, independently verifiable, committed after each. Reuse `src/lib/api/*`, `src/lib/ws/*`, `src/lib/types/*`, hooks verbatim. Never invent endpoints.
|
||||||
|
|
||||||
|
**Goal:** Rebuild the GMW Discord-automod dashboard frontend from scratch — drop shadcn/ui + glass/teal/purple aesthetic entirely, replace with a custom, distinctive design system where every page has its own visual metaphor (no uniform bordered-card grid), and push the presentation layer with **Framer Motion (`motion`) choreography + signature Three.js scenes**. Re-integrate to the EXISTING backend API + WebSocket contract (do NOT touch the backend).
|
||||||
|
|
||||||
|
**Architecture:** Next.js 16 App Router (SSR) + React 19 + TS strict + Tailwind v4. Keep the *plumbing* (data contract), rebuild the *skin + primitives + motion*. Each route = one self-contained `page.tsx` (server fetch + client view in the same file via a `"use client"` sibling export). Custom SVG charts (no recharts). Custom micro-primitives (no shadcn/base-ui). New token system in `globals.css`. **Motion:** `motion/react` app-wide for page transitions, spring micro-interactions, layout animation. **3D:** raw `three` (no react-three-fiber — leaner) in exactly TWO signature scenes, lazy-loaded client-only with graceful fallback. Deployed unchanged via existing flake + `gmw-proxy` nginx (`:4009` → Next `:4017`).
|
||||||
|
|
||||||
|
**Tech Stack:** next@16, react@19, tailwindcss@4 (`@import "tailwindcss"`), `next/font/google` (Bricolage Grotesque + Inter + JetBrains Mono), `swr` (data revalidation), `lucide-react` (icons only), `motion` (Framer Motion successor — `motion/react`), `three` + `@types/three` (signature scenes only), `clsx` + `tailwind-merge`. **Removed:** `@shadcn/react`, `@base-ui/react`, `recharts`, `shadcn` CLI, `cmdk`, `sonner`, `react-day-picker`, `embla-carousel-react`, `react-resizable-panels`, `input-otp`, all 50 `components/ui/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Design System (the creative core — read before coding)
|
||||||
|
|
||||||
|
**Persona:** Controlled UX Designer + a tactical "ops console" voice. Material honesty: hierarchy via **scale/weight/tonal blocks**, NOT borders/shadows. Per `frontend-design` skill principle — spend the boldness in ONE signature place per page, keep the rest disciplined. Motion is choreography, not confetti: **one orchestrated moment per page**, everything else quiet.
|
||||||
|
|
||||||
|
### Palette (warm, signal-driven — NO teal/cyan/purple/blue gradients)
|
||||||
|
Light mode (`:root`):
|
||||||
|
```
|
||||||
|
--canvas: oklch(0.96 0.012 80) /* warm off-white, not cream */
|
||||||
|
--surface: oklch(0.92 0.014 80) /* tonal block, replaces bordered card */
|
||||||
|
--surface-2: oklch(0.88 0.016 80)
|
||||||
|
--ink: oklch(0.22 0.02 70) /* primary text */
|
||||||
|
--ink-soft: oklch(0.46 0.02 70) /* secondary text */
|
||||||
|
--hairline: oklch(0.22 0.02 70 / 0.10) /* structural rules ONLY, sparse */
|
||||||
|
--signal: oklch(0.78 0.17 125) /* lime — OK / live / primary accent */
|
||||||
|
--signal-ink: oklch(0.20 0.03 70) /* text ON signal */
|
||||||
|
--amber: oklch(0.80 0.15 70) /* WARN */
|
||||||
|
--vermilion: oklch(0.62 0.21 25) /* FLAGGED / destructive */
|
||||||
|
--ring: var(--signal)
|
||||||
|
```
|
||||||
|
Dark mode (`.dark`, default theme per `next-themes`):
|
||||||
|
```
|
||||||
|
--canvas: oklch(0.13 0.015 70) /* warm charcoal, not blue-black */
|
||||||
|
--surface: oklch(0.18 0.02 70)
|
||||||
|
--surface-2: oklch(0.23 0.022 70)
|
||||||
|
--ink: oklch(0.93 0.01 75)
|
||||||
|
--ink-soft: oklch(0.62 0.02 75)
|
||||||
|
--hairline: oklch(1 0 0 / 0.09)
|
||||||
|
--signal: oklch(0.88 0.18 125)
|
||||||
|
--signal-ink: oklch(0.18 0.03 70)
|
||||||
|
--amber: oklch(0.85 0.15 70)
|
||||||
|
--vermilion: oklch(0.68 0.21 25)
|
||||||
|
```
|
||||||
|
Three semantic signals reused everywhere: **lime = OK/live, amber = warn, vermilion = flag/danger**. This kills the purple-accent + teal-primary monotony.
|
||||||
|
|
||||||
|
### Typography (3 roles, deliberate pairing — not "Inter everywhere")
|
||||||
|
- **Display:** `Bricolage Grotesque` (700–800) — characterful grotesque for headers/big numbers.
|
||||||
|
- **Body/UI:** `Inter` (400–600).
|
||||||
|
- **Data/label:** `JetBrains Mono` (500/700) — all stats, timestamps, channel IDs, metrics.
|
||||||
|
Load all three via `next/font/google` with CSS variables (keep current `--font-inter`/`--font-jetbrains-mono` names + add `--font-display`).
|
||||||
|
|
||||||
|
### Layout & signature
|
||||||
|
- **No `card` with border.** Use tonal `--surface` blocks with generous radius (`--r: 14px`) and internal padding; separate blocks with whitespace + sparse hairlines only where structurally meaningful.
|
||||||
|
- **Signature element = "scan-tick":** a 1px animated pulse line (CSS keyframe `scan`) that marks every live/section header — NOT a card outline. Global canvas carries a faint warm dot-grid texture (low opacity) instead of the current bluish dotted radial-gradient.
|
||||||
|
- **Nav = left "spine":** vertical rail of icon nodes joined by a hairline; active node gets a `--signal` dot + label reveal. Collapses to a bottom tab-bar < 768px (CSS only, no JS sidebar primitive).
|
||||||
|
- **Header = "status bar":** connection state (WS dot), guild selector, live clock — mono font, reads like an instrument readout.
|
||||||
|
|
||||||
|
## 0.1 Motion & 3D Layer (the "lebih kreatif" addition)
|
||||||
|
|
||||||
|
### Motion rules (from `motion/react`)
|
||||||
|
- **Page transitions:** one shared `RouteTransition` in `(dashboard)/layout.tsx` — `AnimatePresence mode="popLayout"` + `motion.div key={pathname}` (fade + 8px rise + slight blur-out, ~220ms, `easeOut`). Consistent everywhere, zero per-page boilerplate.
|
||||||
|
- **Enter choreography (per page, ONE signature moment):** staggered rise-in for the hero/ticker group using `staggerChildren` variants; afterwards, quiet springs for hover/tap (`scale: 1.03` on interactive blocks, `whileTap` on buttons).
|
||||||
|
- **Layout animation:** `layout` prop on list items (messages rows, recording rows, queue) so add/remove/filter reflows smoothly; `layoutId` for shared-element transitions (ticker → detail modal on dashboard).
|
||||||
|
- **Live pulse:** `motion` drives the severity ticks / speaker rings with springs, not CSS `transition` alone.
|
||||||
|
- **`useReducedMotion()`** (from `motion/react`) gates ALL heavy motion; CSS `@media (prefers-reduced-motion: reduce)` additionally kills `scan`/`spin-disc` keyframes. Accessibility floor, non-negotiable.
|
||||||
|
- **No scroll-jacking, no marquee loops, no per-element confetti.** One moment per page. (`frontend-design` skill: "Satu momen orkestrasi biasanya lebih mengena daripada efek tersebar.")
|
||||||
|
|
||||||
|
### 3D rules (raw `three`, no R3F — lean bundle)
|
||||||
|
- Exactly **two** scenes, chosen because they carry real data meaning: **Dashboard hero** (`SignalField` — a particle field whose pulse density reflects live activity) and **Voice page** (`OrbField` — speakers as glowing orbs whose height/ring radius reacts to who is speaking). Everything else stays 2D/motion.
|
||||||
|
- **Lazy + client-only:** `next/dynamic(() => import("./SignalField"), { ssr: false, loading: () => <StaticFallback/> })`. Three ships in its own chunk, loaded only on those two routes.
|
||||||
|
- **WebGL guard:** if `!window.WebGLRenderingContext` or context creation fails → render the static SVG/CSS fallback (a stylized 2D version of the same visual). Never blank.
|
||||||
|
- **Perf guardrails:** `dpr: [1, 1.75]`, `powerPreference: "high-performance"`, `antialias: true`; RAF loop paused on `document.hidden`; `dispose()` geometries/materials on unmount; particle count capped by `navigator.hardwareConcurrency` + viewport (`Math.min(900, w*h/2000)`).
|
||||||
|
- **Style:** warm palette ONLY — signal-lime particles, amber/vermilion for flag/warn states; fog + soft additive blending for glow (no harsh white lights, no metallic PBR).
|
||||||
|
- **Interactivity:** subtle pointer parallax (camera lerp toward cursor) + gentle idle rotation. No drag/drop, no raycasting menus.
|
||||||
|
|
||||||
|
### Per-page metaphor (kills monotony — each page feels different)
|
||||||
|
| Route | Metaphor | Signature visual | Motion / 3D |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/dashboard` | Live ops overview | **3D signal particle field** hero + asymmetric ticker blocks + radial moderation gauge | **3D SignalField** (reacts to activity), staggered ticker rise-in, gauge draws on mount |
|
||||||
|
| `/messages` | Transcript | Left channel **timeline spine**; right = flowing message entries with left **severity tick** (no bordered cards); search = command palette | `layout` on message rows, spring severity ticks, palette types in |
|
||||||
|
| `/voice` | Stage | **3D orb field** of speakers + equalizer rings; activity = horizontal **session ribbon** | **3D OrbField** (speaking → orb rises + ring pulses), session ribbon draws sequentially |
|
||||||
|
| `/media` | Turntable | Rotating **disc** now-playing; queue = borderless list | CSS 3D disc spin (spring on play/pause), queue `layout` reflow, progress bar springs |
|
||||||
|
| `/recordings` | Tape library | Rows with **waveform thumbnail** (custom SVG from duration) | Waveform bars spring on hover; new upload animates in (AnimatePresence) |
|
||||||
|
| `/moderation` | Security log | Vertical **event flow** with status nodes (dot + line), not a table of cards | Nodes pulse on live action; timeline draws in sequence |
|
||||||
|
| `/analysis` | Query console | Terminal-style search panel | Typing cursor + results stagger |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What to KEEP (reuse verbatim — correct + integrates to backend)
|
||||||
|
- `src/lib/api/server.ts` — 11 server fetchers (`getDashboardStats`, `getActivity`, `getMediaStatus`, `getConfig`, `getModerationStats/Actions`, `getGuilds`, `getVoiceStatus`, `getRecordings`, `getMessages`). **No change.**
|
||||||
|
- `src/lib/api/client.ts` — `apiRequest` + `ApiError`. **No change.**
|
||||||
|
- `src/lib/ws/*` — `connection.ts`, `context.tsx`, `types.ts` (17 typed events: `message_created/updated/deleted/analyzed`, `voice_*`, `media_state`, `voice_pcm_data` binary). **No change.**
|
||||||
|
- `src/lib/types/*` — all interfaces. **No change.**
|
||||||
|
- `src/lib/format.ts`, `src/lib/utils.ts` (`cn`). **No change.**
|
||||||
|
- `src/lib/navigation.ts` — `navItems`. Keep but extend icons/labels if needed.
|
||||||
|
- Hooks: `src/hooks/*` (use-dashboard, use-media, use-messages, use-voice, use-moderation, use-recordings, use-guilds, use-config, use-chatbot-user, use-action, use-mobile), `src/lib/hooks/use-mounted.ts`. **Reuse** (verify no bad imports into deleted barrels).
|
||||||
|
- Feature logic kept but **reskinned**: `src/components/media/music-player.tsx`, `src/components/voice/*`, `src/components/chatbot/*`, `src/components/messages/*`, `src/components/recordings/*`, `src/components/moderation/moderation-section.tsx`, `src/components/analysis/search-panel.tsx`, `src/components/dashboard/*` (charts → rewritten as custom SVG).
|
||||||
|
|
||||||
|
## 2. What to DELETE
|
||||||
|
- `src/components/ui/*` (all 50 shadcn primitives).
|
||||||
|
- `components.json`, `@shadcn/react` + `@base-ui/react` + `shadcn` deps.
|
||||||
|
- `recharts` (replace with custom SVG chart helpers in `src/components/charts/`).
|
||||||
|
- `src/app/globals.css` → rewrite (no `@import "shadcn/tailwind.css"`, no `--color-primary` teal, no `.glass*`, no `.text-gradient`/`.gradient-border` teal/purple, no bluish body texture).
|
||||||
|
- `src/components/layout/app-sidebar.tsx` (shadcn Sidebar) → replace with custom `Spine` nav.
|
||||||
|
- All `page.tsx`+`view.tsx` pairs → merge into single `page.tsx` per route.
|
||||||
|
|
||||||
|
## 3. What to BUILD (new)
|
||||||
|
- New `globals.css` (tokens above + utilities + keyframes `scan`, `eq`, `fade-up`, `spin-disc`).
|
||||||
|
- `src/components/primitives/` — minimal custom: `Button`, `Input`, `Select`, `Dialog`, `Tooltip`, `Badge`, `Progress`, `Avatar`, `Skeleton`, `Toast`, `Sheet`.
|
||||||
|
- `src/components/motion/` — `RouteTransition.tsx`, `Stagger.tsx`, `variants.ts`.
|
||||||
|
- `src/components/three/` — `SignalField.tsx`, `OrbField.tsx`, `WebGLGuard.tsx`, `StaticFallback.tsx`, `useThreeScene.ts`.
|
||||||
|
- `src/components/charts/` — `Sparkline`, `AreaActivity`, `RadialGauge`, `SessionRibbon`, `Waveform`.
|
||||||
|
- `src/components/layout/` — `Spine.tsx`, `StatusBar.tsx`, `ThemeToggle.tsx` (reskin).
|
||||||
|
- 7 merged `page.tsx` files (one per route) implementing the metaphors above.
|
||||||
|
- `src/app/layout.tsx` — root (fonts + ThemeProvider + Toaster), `src/app/(dashboard)/layout.tsx` — providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot, `src/app/page.tsx` → redirect `/dashboard`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target File & Folder Structure (authoritative)
|
||||||
|
|
||||||
|
Everything below `services/frontend/src/` is the new tree. `REWRITE` replaces existing; `NEW` creates; `DELETE` removes. The `app/` route tree collapses `page.tsx`+`view.tsx` into single `page.tsx` files containing BOTH server fetch (default export) and client view (`"use client"` named export in same file).
|
||||||
|
|
||||||
|
```
|
||||||
|
services/frontend/
|
||||||
|
├─ package.json REWRITE (drop shadcn/base-ui/recharts; add motion, three, @types/three)
|
||||||
|
├─ pnpm-workspace.yaml REWRITE (onlyBuiltDependencies: keep build list minimal)
|
||||||
|
├─ components.json DELETE (shadcn registry config — no longer used)
|
||||||
|
├─ next.config.ts KEEP (output: standalone, trailingSlash, images.unoptimized)
|
||||||
|
├─ tsconfig.json KEEP (paths "@/*" → src/*, strict)
|
||||||
|
├─ postcss.config.mjs KEEP (@tailwindcss/postcss)
|
||||||
|
├─ biome.json KEEP
|
||||||
|
└─ src/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ layout.tsx REWRITE (3 fonts + ThemeProvider + custom Toaster; rm sonner)
|
||||||
|
│ ├─ globals.css REWRITE (new token system §0; rm glass/teal/purple)
|
||||||
|
│ ├─ page.tsx KEEP (redirect → /dashboard/)
|
||||||
|
│ └─ (dashboard)/
|
||||||
|
│ ├─ layout.tsx REWRITE (providers + Spine + StatusBar + RouteTransition + MiniPlayer + Chatbot; rm shadcn Sidebar)
|
||||||
|
│ ├─ dashboard/ page.tsx REWRITE (server fetch + <DashboardView/> client; 3D SignalField hero)
|
||||||
|
│ ├─ messages/ page.tsx REWRITE (server seeds + <MessagesView/>; spine + severity ticks)
|
||||||
|
│ ├─ voice/ page.tsx REWRITE (server seeds + <VoiceView/>; 3D OrbField hero)
|
||||||
|
│ ├─ media/ page.tsx REWRITE (server seeds + <MediaView/>; turntable disc)
|
||||||
|
│ ├─ recordings/ page.tsx REWRITE (server seeds + <RecordingsView/>; waveform rows)
|
||||||
|
│ ├─ moderation/ page.tsx REWRITE (server seeds + <ModerationView/>; event-flow)
|
||||||
|
│ └─ analysis/ page.tsx REWRITE (client <AnalysisView/>; query console)
|
||||||
|
├─ components/
|
||||||
|
│ ├─ ui/ DELETE (all 50 shadcn primitives)
|
||||||
|
│ ├─ primitives/ NEW (Button, Input, Select, Dialog, Tooltip, Badge, Progress, Avatar, Skeleton, Toast, Sheet, index.ts)
|
||||||
|
│ ├─ motion/ NEW (variants.ts, Stagger.tsx, RouteTransition.tsx)
|
||||||
|
│ ├─ three/ NEW (WebGLGuard, SignalField, OrbField, StaticFallback, useThreeScene)
|
||||||
|
│ ├─ charts/ NEW (Sparkline, AreaActivity, RadialGauge, SessionRibbon, Waveform)
|
||||||
|
│ ├─ layout/ REWRITE (Spine NEW, StatusBar NEW, ThemeToggle REWRITE, app-sidebar DELETE)
|
||||||
|
│ ├─ dashboard/ REWRITE (stat-card DELETE; activity-chart/hourly/top-channels/moderation-donut/users/channels/reactions REWRITE)
|
||||||
|
│ ├─ messages/ REWRITE (message-card DELETE; message-list/detail/detail-view/ai-status-badge/ai-analysis-panel/attachments-grid/lightbox/search-overlay REWRITE)
|
||||||
|
│ ├─ voice/ REWRITE (voice-connection-card/connection-card/microphone-card DELETE; speaker-waveform/active-speakers-panel/activity-timeline/mic-control/listen-control REWRITE)
|
||||||
|
│ ├─ media/ REWRITE (music-player, mini-player REWRITE)
|
||||||
|
│ ├─ recordings/ REWRITE (recording-card DELETE; recording-player REWRITE)
|
||||||
|
│ ├─ moderation/ REWRITE (moderation-section REWRITE)
|
||||||
|
│ ├─ analysis/ REWRITE (search-panel REWRITE)
|
||||||
|
│ ├─ chatbot/ REWRITE (chatbot-container, chat-panel REWRITE; chatbot-context, index KEEP)
|
||||||
|
│ └─ shared/ REWRITE (empty-state, error-state, loading-skeleton, error-boundary, guild-selector REWRITE; index KEEP)
|
||||||
|
├─ hooks/ KEEP (verify no bad imports)
|
||||||
|
├─ lib/
|
||||||
|
│ ├─ api/ KEEP (server.ts, client.ts, index.ts)
|
||||||
|
│ ├─ ws/ KEEP (connection.ts, context.tsx, types.ts, ws-hook.ts)
|
||||||
|
│ ├─ types/ KEEP (all interfaces)
|
||||||
|
│ ├─ hooks/ KEEP (use-media-player.tsx, use-mounted.ts)
|
||||||
|
│ ├─ audio/ KEEP (voice PCM decode helpers if present)
|
||||||
|
│ ├─ format.ts KEEP
|
||||||
|
│ ├─ utils.ts KEEP (cn)
|
||||||
|
│ └─ navigation.ts KEEP
|
||||||
|
└─ (public assets) KEEP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Single-file page pattern (mandatory)
|
||||||
|
```tsx
|
||||||
|
// server component (default export) — runs on the server, fetches initial data
|
||||||
|
import { getX, getY } from "@/lib/api/server";
|
||||||
|
import { XView } from "./page"; // self-import of the named client export
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const [a, b] = await Promise.allSettled([getX(), getY()]);
|
||||||
|
return <XView initialA={a.status === "fulfilled" ? a.value : undefined}
|
||||||
|
initialB={b.status === "fulfilled" ? b.value : undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// client component (named export) — hydrated, takes initialData as SWR fallback
|
||||||
|
"use client";
|
||||||
|
export function XView({ initialA, initialB }: Props) {
|
||||||
|
const { data } = useX(initialA); // SWR fallbackData = initialA
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Self-referencing the named export keeps the file single-artifact while satisfying Next's RSC boundary (default = server, named = client). Tabs live inside `XView`.
|
||||||
|
|
||||||
|
### 4.2 Import rules (lint gate)
|
||||||
|
- No `@/components/ui/*` (deleted) — all UI via `@/components/primitives`.
|
||||||
|
- `three` only imported inside `src/components/three/*`; pages import those via `next/dynamic({ ssr: false })`.
|
||||||
|
- `motion` imported from `motion/react` only.
|
||||||
|
- All data: `@/lib/api/server` (server) / `@/lib/api/client` (client) — never invented endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASKS (granular — every file is its own task)
|
||||||
|
|
||||||
|
### PHASE 0 — Dependency surgery
|
||||||
|
- **T0.1** Edit `package.json`: remove `dependencies["@base-ui/react"]`.
|
||||||
|
- **T0.2** Remove `dependencies["@shadcn/react"]`.
|
||||||
|
- **T0.3** Remove `dependencies["shadcn"]`.
|
||||||
|
- **T0.4** Remove `dependencies["recharts"]`.
|
||||||
|
- **T0.5** Remove `dependencies["cmdk"]`.
|
||||||
|
- **T0.6** Remove `dependencies["sonner"]`.
|
||||||
|
- **T0.7** Remove `dependencies["react-day-picker"]`.
|
||||||
|
- **T0.8** Remove `dependencies["embla-carousel-react"]`.
|
||||||
|
- **T0.9** Remove `dependencies["react-resizable-panels"]`.
|
||||||
|
- **T0.10** Remove `dependencies["input-otp"]`.
|
||||||
|
- **T0.11** Add `dependencies["motion"]: "^12.0.0"`, `dependencies["three"]: "^0.180.0"`, `devDependencies["@types/three"]: "^0.180.0"`.
|
||||||
|
- **T0.12** `rm -f pnpm-lock.yaml && pnpm install` (regenerate lockfile).
|
||||||
|
- **T0.13** Verify `pnpm ls recharts @shadcn/react @base-ui/react` → empty; `pnpm ls motion three @types/three` → present.
|
||||||
|
- **T0.14** `cat pnpm-workspace.yaml`: confirm `onlyBuiltDependencies` keeps needed native builds, no broken shadcn postinstall.
|
||||||
|
|
||||||
|
### PHASE 1 — Design tokens (globals.css)
|
||||||
|
- **T1.1** Rewrite `@theme { }` head: light `:root` palette from §0 (canvas/surface/ink/ink-soft/hairline/signal/signal-ink/amber/vermilion/ring).
|
||||||
|
- **T1.2** Add radius tokens `--r: 14px`, `--r-panel: 12px`, `--r-control: 8px`, `--r-pill: 9999px`.
|
||||||
|
- **T1.3** Add `--font-display` token; keep `--font-sans`/`--font-mono`.
|
||||||
|
- **T1.4** Add `.dark { }` override block with §0 dark values (warm charcoal).
|
||||||
|
- **T1.5** Replace `@layer base body` bg: warm dot-grid `radial-gradient(oklch(0.45 0.03 70 / 0.05) 1px, transparent 1px)` + faint warm glow; remove old bluish radial layers.
|
||||||
|
- **T1.6** Retint scrollbar thumb to warm `oklch(0.4 0.02 70 / 0.2)`; keep `::selection` signal-tinted.
|
||||||
|
- **T1.7** Delete `.glass`, `.glass-elevated`, `.glass-intense`, `.dark .glass-intense` utilities.
|
||||||
|
- **T1.8** Delete `.text-gradient` and `.gradient-border`.
|
||||||
|
- **T1.9** Add `.surface` utility (bg var(--surface), radius var(--r), padding).
|
||||||
|
- **T1.10** Add `.scan-tick` (1px animated pulse line, keyframe `scan`).
|
||||||
|
- **T1.11** Add `.ticker`, `.pill`, `.mono` utilities.
|
||||||
|
- **T1.12** Add keyframes `scan`, `eq`, `fade-up`, `spin-disc` (keep used existing ones if still referenced).
|
||||||
|
- **T1.13** Add `@media (prefers-reduced-motion: reduce)` kill switch for scan/eq/spin-disc/pulse-ring/shimmer.
|
||||||
|
- **T1.14** Remove `@import "shadcn/tailwind.css";` (line 3); verify nothing else depends on shadcn CSS vars.
|
||||||
|
- **T1.15** Verify `grep -c "0.52 0.17 215\|0.55 0.2 280" src/app/globals.css` → `0`.
|
||||||
|
- **T1.16** `pnpm biome check src/app/globals.css` → no errors.
|
||||||
|
|
||||||
|
### PHASE 2 — Root layout + fonts
|
||||||
|
- **T2.1** In `layout.tsx` add `Bricolage_Grotesque` (`variable: "--font-display"`, subsets `["latin"]`, `display: "swap"`).
|
||||||
|
- **T2.2** Apply `inter.variable`, `jetbrainsMono.variable`, `bricolage.variable` to `<html>`.
|
||||||
|
- **T2.3** Remove `import { Toaster } from "@/components/ui/sonner"`.
|
||||||
|
- **T2.4** Comment out `<Toaster />` temporarily (re-enabled after T3.10).
|
||||||
|
- **T2.5** Keep `suppressHydrationWarning`, `ThemeProvider` (defaultTheme dark, enableSystem false).
|
||||||
|
- **T2.6** `npx tsc --noEmit` (fonts only; rest may still error until primitives exist).
|
||||||
|
|
||||||
|
### PHASE 3 — Custom primitives (replace 50 shadcn ui)
|
||||||
|
- **T3.1** `primitives/Button.tsx`: `motion.button`, variants `primary`/`ghost`/`danger`, `cn()` merge, `cursor-pointer`, `focus-visible:ring-2 ring-signal`, `whileTap` scale 0.97 gated by `useReducedMotion()`.
|
||||||
|
- **T3.2** `primitives/Input.tsx`: native `<input>`, `bg-surface`, `rounded-[var(--r-control)]`, `mono` prop.
|
||||||
|
- **T3.3** `primitives/Select.tsx`: native `<select>` styled, `bg-surface`.
|
||||||
|
- **T3.4** `primitives/Dialog.tsx`: native `<dialog>` + `showModal()`, warm `::backdrop`, `AnimatePresence`, `onClose`.
|
||||||
|
- **T3.5** `primitives/Tooltip.tsx`: CSS group-hover popover.
|
||||||
|
- **T3.6** `primitives/Badge.tsx`: tonal pill, `variant` → `bg-{tone}/15 text-{tone}` (signal/amber/vermilion/neutral).
|
||||||
|
- **T3.7** `primitives/Progress.tsx`: SVG track + `motion` fill, `value`/`max`, signal color.
|
||||||
|
- **T3.8** `primitives/Avatar.tsx`: `<img>` + initials fallback, signal bg, size prop.
|
||||||
|
- **T3.9** `primitives/Skeleton.tsx`: shimmer block (signal-tinted), `aria-hidden`.
|
||||||
|
- **T3.10** `primitives/Toast.tsx`: `ToastProvider` context + portal, `useToast()`, motion slide-in, auto-dismiss.
|
||||||
|
- **T3.11** `primitives/Sheet.tsx`: mobile drawer (`translate-x` spring), overlay, `open`/`onClose`.
|
||||||
|
- **T3.12** `primitives/index.ts` re-export all 11.
|
||||||
|
- **T3.13** Re-enable `<Toaster />` in `layout.tsx` (T2.4).
|
||||||
|
- **T3.14** Verify `npx tsc --noEmit` on primitives; `grep -rl "@/components/ui/" src/components/primitives` → empty.
|
||||||
|
|
||||||
|
### PHASE 4 — Motion foundation
|
||||||
|
- **T4.1** `motion/variants.ts`: export `spring`, `ease`, `fadeUp`, `stagger` (per §0.1).
|
||||||
|
- **T4.2** `motion/Stagger.tsx`: `StaggerGroup` + `StaggerItem` (`"use client"`).
|
||||||
|
- **T4.3** `motion/RouteTransition.tsx`: `"use client"`, `usePathname`, `AnimatePresence mode="popLayout"`, reduced-motion fallback to plain `<div>`.
|
||||||
|
- **T4.4** Verify `npx tsc --noEmit` on motion; `motion/react` import resolves.
|
||||||
|
|
||||||
|
### PHASE 5 — Custom SVG charts (replace recharts)
|
||||||
|
- **T5.1** `charts/Sparkline.tsx`: `<svg>` polyline from `points:number[]`, signal stroke, no axes.
|
||||||
|
- **T5.2** `charts/AreaActivity.tsx`: filled `<path>` area, low-opacity signal gradient, `pathLength` draw gated by reduced-motion.
|
||||||
|
- **T5.3** `charts/RadialGauge.tsx`: `<circle>` arc `stroke-dasharray`, center mono label.
|
||||||
|
- **T5.4** `charts/SessionRibbon.tsx`: horizontal segments per speaker duration.
|
||||||
|
- **T5.5** `charts/Waveform.tsx`: bars from deterministic seed, spring scaleY on hover.
|
||||||
|
- **T5.6** Verify `npx tsc --noEmit` on charts; no `recharts` import.
|
||||||
|
|
||||||
|
### PHASE 6 — Three.js foundation (lazy, guarded)
|
||||||
|
- **T6.1** `three/WebGLGuard.tsx`: `"use client"`, detect webgl2/webgl, render `children` or `fallback`.
|
||||||
|
- **T6.2** `three/useThreeScene.ts`: shared hook — renderer init (`dpr:[1,1.75]`, `powerPreference`), RAF with `document.hidden` pause, `dispose()` on unmount, resize observer.
|
||||||
|
- **T6.3** `three/SignalField.tsx`: `Points` BufferGeometry (~min(900, w*h/2000)), additive blend, signal-lime, fog, idle rotation + sine drift, `activity` prop, pointer parallax. Uses `useThreeScene`.
|
||||||
|
- **T6.4** `three/OrbField.tsx`: per-speaker `Sphere`, y-scale + ring lerp to speaking, tones signal/idle/vermilion.
|
||||||
|
- **T6.5** `three/StaticFallback.tsx`: 2D SVG/CSS silhouette for both scenes.
|
||||||
|
- **T6.6** Verify `grep -rl "from \"three\"" src | grep -v "components/three"` → empty; `npx tsc --noEmit` on three.
|
||||||
|
|
||||||
|
### PHASE 7 — Layout shell
|
||||||
|
- **T7.1** `layout/Spine.tsx`: `"use client"`, vertical rail from `navItems`, icon node + hairline, active signal dot + label reveal (motion spring), `max-md:` bottom tab-bar.
|
||||||
|
- **T7.2** `layout/StatusBar.tsx`: `"use client"`, page title + WS status dot (motion pulse) + `GuildSelector` + live clock (mono) + `ThemeToggle`.
|
||||||
|
- **T7.3** `layout/ThemeToggle.tsx`: restyle, keep `next-themes` logic, motion icon swap.
|
||||||
|
- **T7.4** Rewrite `(dashboard)/layout.tsx`: keep SWRConfig/WsProvider/MediaPlayerProvider/ChatbotProvider + sync functions verbatim; swap `AppSidebar`→`Spine`, header→`StatusBar`, wrap children in `RouteTransition`; remove `SidebarInset`/`SidebarTrigger`/`Separator`; keep MiniPlayer+ChatbotContainer.
|
||||||
|
- **T7.5** Delete `layout/app-sidebar.tsx`.
|
||||||
|
- **T7.6** Verify `grep -rl "components/ui/sidebar\|app-sidebar" src` → empty; `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
### PHASE 8 — Dashboard page + components
|
||||||
|
- **T8.1** Rewrite `dashboard/page.tsx`: default async `getDashboardStats`+`getActivity` → `<DashboardView>`; named `"use client"` view with useStats/useActivity, 3D hero + tickers + tabs.
|
||||||
|
- **T8.2** Add `WebGLGuard`+`SignalField` hero with `activity` ratio; overlay headline (Bricolage) + `<RadialGauge>`.
|
||||||
|
- **T8.3** Build asymmetric ticker row with `StaggerGroup` + 4 `.surface` blocks (mono number + label + `<Sparkline>`); inline (replaces stat-card).
|
||||||
|
- **T8.4** Delete `dashboard/stat-card.tsx`.
|
||||||
|
- **T8.5** Reskin `dashboard/activity-chart.tsx` → `charts/AreaActivity` (daily).
|
||||||
|
- **T8.6** Reskin `dashboard/hourly-activity-chart.tsx` → `charts/AreaActivity` (hourly).
|
||||||
|
- **T8.7** Reskin `dashboard/top-channels-chart.tsx` → `charts/` + `.surface`.
|
||||||
|
- **T8.8** Reskin `dashboard/moderation-donut.tsx` → `charts/RadialGauge`.
|
||||||
|
- **T8.9** Reskin `dashboard/users-section.tsx` → `.surface`.
|
||||||
|
- **T8.10** Reskin `dashboard/channels-section.tsx` → `.surface`.
|
||||||
|
- **T8.11** Reskin `dashboard/reactions-section.tsx` → `.surface`.
|
||||||
|
- **T8.12** Verify `grep -rl "components/ui/card" src/app/\(dashboard\)/dashboard src/components/dashboard` → empty; `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
### PHASE 9 — Messages page + components
|
||||||
|
- **T9.1** Rewrite `messages/page.tsx`: default `getMessages(guildId)`(+channels) → `<MessagesView>`; client spine + entries.
|
||||||
|
- **T9.2** Delete `messages/message-card.tsx`.
|
||||||
|
- **T9.3** Rewrite `messages/message-list.tsx`: left timeline spine + right severity-tick entries (`surface` + `border-l-2` lime/amber/vermilion, motion spring tick), `layout` reflow.
|
||||||
|
- **T9.4** Rewrite `messages/message-detail.tsx` → `.surface`.
|
||||||
|
- **T9.5** Rewrite `messages/message-detail-view.tsx` → `.surface` pane.
|
||||||
|
- **T9.6** Rewrite `messages/ai-status-badge.tsx` → `primitives/Badge`.
|
||||||
|
- **T9.7** Rewrite `messages/ai-analysis-panel.tsx` → `.surface`.
|
||||||
|
- **T9.8** Rewrite `messages/attachments-grid.tsx` → `.surface` grid.
|
||||||
|
- **T9.9** Rewrite `messages/lightbox.tsx` → `primitives/Dialog`.
|
||||||
|
- **T9.10** Rewrite `messages/search-overlay.tsx` → console palette, type-in animation, `primitives/Dialog`.
|
||||||
|
- **T9.11** Verify no `components/ui/card` in messages tree; `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
### PHASE 10 — Voice page + components
|
||||||
|
- **T10.1** Rewrite `voice/page.tsx`: default `getVoiceStatus()` → `<VoiceView>`; client `WebGLGuard`+`OrbField` hero + ribbon + tabs.
|
||||||
|
- **T10.2** Delete `voice/voice-connection-card.tsx`, `connection-card.tsx`, `microphone-card.tsx`.
|
||||||
|
- **T10.3** Rewrite `voice/speaker-waveform.tsx` → SVG ring / eq bars.
|
||||||
|
- **T10.4** Rewrite `voice/active-speakers-panel.tsx` → `.surface`.
|
||||||
|
- **T10.5** Rewrite `voice/activity-timeline.tsx` → `charts/SessionRibbon`.
|
||||||
|
- **T10.6** Rewrite `voice/mic-control.tsx` → `primitives/Button`.
|
||||||
|
- **T10.7** Rewrite `voice/listen-control.tsx` → `primitives/Button`.
|
||||||
|
- **T10.8** Verify `npx tsc --noEmit`; no `components/ui/card` in voice tree.
|
||||||
|
|
||||||
|
### PHASE 11 — Media page + components
|
||||||
|
- **T11.1** Rewrite `media/page.tsx`: default `getMediaStatus()` → `<MediaView>`; client turntable disc + transport + queue.
|
||||||
|
- **T11.2** Rewrite `media/music-player.tsx`: CSS-3D disc (spin-disc, pause when not playing, spring on play/pause), mono meta, `primitives/Button` transport, `.surface` queue rows with `layout`.
|
||||||
|
- **T11.3** Rewrite `media/mini-player.tsx` → compact `.surface`.
|
||||||
|
- **T11.4** Verify `npx tsc --noEmit`; no `components/ui/card` in media tree.
|
||||||
|
|
||||||
|
### PHASE 12 — Recordings page + components
|
||||||
|
- **T12.1** Rewrite `recordings/page.tsx`: default `getRecordings(50)` → `<RecordingsView>`; client rows `AnimatePresence`+`layout`, live `voice_recording_uploaded` prepend.
|
||||||
|
- **T12.2** Delete `recordings/recording-card.tsx`.
|
||||||
|
- **T12.3** Rewrite `recordings/recording-player.tsx` → `.surface` row + `charts/Waveform` + `primitives/Button`/`Dialog` play/delete.
|
||||||
|
- **T12.4** Verify `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
### PHASE 13 — Moderation + Analysis pages
|
||||||
|
- **T13.1** Rewrite `moderation/page.tsx`: default `getModerationStats/Actions` → `<ModerationView>`; client vertical event-flow, live actions pulse-in.
|
||||||
|
- **T13.2** Rewrite `moderation/moderation-section.tsx` → event-flow, no Card/table.
|
||||||
|
- **T13.3** Rewrite `analysis/page.tsx`: client `<AnalysisView/>` terminal console (`primitives/Input` mono + blinking caret, `.surface` results staggered).
|
||||||
|
- **T13.4** Rewrite `analysis/search-panel.tsx` → terminal style.
|
||||||
|
- **T13.5** Verify `npx tsc --noEmit`.
|
||||||
|
|
||||||
|
### PHASE 14 — Chatbot + shared + final cleanup
|
||||||
|
- **T14.1** Rewrite `chatbot/chatbot-container.tsx` → `.surface`, keep drag/minimize.
|
||||||
|
- **T14.2** Rewrite `chatbot/chat-panel.tsx` → bubbles via `AnimatePresence`, `primitives/*`.
|
||||||
|
- **T14.3** Keep `chatbot/chatbot-context.tsx` + `index.ts`.
|
||||||
|
- **T14.4** Rewrite `shared/empty-state.tsx` → tonal.
|
||||||
|
- **T14.5** Rewrite `shared/error-state.tsx`.
|
||||||
|
- **T14.6** Rewrite `shared/loading-skeleton.tsx` → `primitives/Skeleton`.
|
||||||
|
- **T14.7** Rewrite `shared/error-boundary.tsx`.
|
||||||
|
- **T14.8** Rewrite `shared/guild-selector.tsx` → `primitives/Select`.
|
||||||
|
- **T14.9** `rm -rf src/components/ui && rm -f components.json`.
|
||||||
|
- **T14.10** `grep -rn "components/ui/\|@shadcn\|@base-ui\|recharts" src` → MUST be empty.
|
||||||
|
- **T14.11** `grep -rl "from \"recharts\"\|@base-ui\|@shadcn" src` → empty (double-check).
|
||||||
|
- **T14.12** Verify `npx tsc --noEmit` across whole `src`.
|
||||||
|
|
||||||
|
### PHASE 15 — Build + lint gate
|
||||||
|
- **T15.1** `cd services/frontend && npx tsc --noEmit` → 0 errors.
|
||||||
|
- **T15.2** `pnpm biome check` → fix all issues (no `any` in new files).
|
||||||
|
- **T15.3** `pnpm build` (standalone) → success, emits `.next/standalone/server.js`.
|
||||||
|
- **T15.4** Inspect `.next/static/chunks/` for three-heavy chunk loaded only on dashboard/voice; confirm NOT in `/dashboard/` initial SSR HTML.
|
||||||
|
- **T15.5** Confirm `pnpm-lock.yaml` present (reproducible flake install).
|
||||||
|
- **T15.6** `grep -c "0.52 0.17 215\|0.55 0.2 280" .next/static/css/*.css` → 0.
|
||||||
|
|
||||||
|
### PHASE 16 — Local runtime smoke test (no prod)
|
||||||
|
- **T16.1** Start local standalone: `GMW_BACKEND_URL=http://127.0.0.1:4001 PORT=4017 node .next/standalone/server.js &`.
|
||||||
|
- **T16.2** `curl -s -o /dev/null -w "%{http_code}"` for all 7 routes → 200.
|
||||||
|
- **T16.3** `curl /dashboard/ | grep -o "Bricolage\|signal\|surface"` → present.
|
||||||
|
- **T16.4** `curl /_next/static/css/*.css | grep "0.52 0.17 215\|0.55 0.2 280"` → empty.
|
||||||
|
- **T16.5** Headless browser `/dashboard/`+`/voice/` WebGL on: no console errors, `<canvas>` present, `<StaticFallback/>` NOT rendered.
|
||||||
|
- **T16.6** Same pages WebGL off: `<StaticFallback/>` renders, no crash.
|
||||||
|
- **T16.7** Kill local server. Do NOT touch prod unit.
|
||||||
|
- **T16.8** Confirm 7 routes 200 + no console errors in T16.5/16.6.
|
||||||
|
|
||||||
|
### PHASE 17 — Flake + staging deploy
|
||||||
|
- **T17.1** Inspect `flake.nix` frontend drv: `filterSource` ignores `out/.next/node_modules`; `pnpm-lock.yaml` included.
|
||||||
|
- **T17.2** `nix build .#gmw-frontend --impure --sandbox-off` → succeeds.
|
||||||
|
- **T17.3** `nix copy` frontend drv to VPS into staging profile (test port e.g. 4217).
|
||||||
|
- **T17.4** Create/adjust staging systemd unit with `PORT=4217` exported BEFORE `node server.js` (LIDM PORT bug).
|
||||||
|
- **T17.5** `sudo systemctl restart gmw-frontend-staging`; `curl` staging → 200.
|
||||||
|
- **T17.6** Browser-check staging `/dashboard/`+`/voice/` (3D visible, fallback test).
|
||||||
|
- **T17.7** Verify staging CSS has no old teal/purple; new design renders.
|
||||||
|
|
||||||
|
### PHASE 18 — Production swap (CONFIRM WITH USER FIRST)
|
||||||
|
- **T18.1** STOP — send staging screenshots/URL; await explicit approval before touching prod.
|
||||||
|
- **T18.2** On approval: `nix-env --profile /nix/var/nix/profiles/gmw-frontend --set <new-drv>`.
|
||||||
|
- **T18.3** Confirm `gmw-frontend.service` exports `PORT=4017` before exec.
|
||||||
|
- **T18.4** `sudo systemctl restart gmw-frontend`.
|
||||||
|
- **T18.5** `curl` all 7 routes on `https://imphnen.asepharyana.my.id` → 200.
|
||||||
|
- **T18.6** Browser verify prod: new design, old teal gone, 3D scenes render.
|
||||||
|
- **T18.7** `journalctl -u gmw-frontend -f` 5 min; confirm WS reconnect + live features.
|
||||||
|
- **T18.8** Notify user with before/after notes; keep rollback plan (`nix-env --set <previous>; systemctl restart`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
- **Scope:** 7 pages + charts + primitives + motion + 2 three scenes + layout. Big but mechanical; each task is isolated (~90 atomic tasks).
|
||||||
|
- **Bundle weight:** `three` adds ~150KB gz but ONLY on dashboard/voice routes (lazy chunk, `ssr:false`). `motion` ~35KB gz app-wide — acceptable.
|
||||||
|
- **WebGL compatibility:** covered by `WebGLGuard` + static fallback. Old devices / strict privacy browsers never blank.
|
||||||
|
- **Motion excess:** risk of "AI-generated" scattered animation. Guard: one signature moment per page, shared variants, reduced-motion gates.
|
||||||
|
- **Feature regressions:** Voice PCM playback, media transport, chatbot drag — logic preserved, only skin changes. Smoke test (T16) catches SSR breaks; live WS/3D needs real backend (staging T17).
|
||||||
|
- **Removed deps:** dropping `recharts`/`sonner`/`cmdk` means rewriting charts + toasts + search palette — accounted for in Phases 3/5/9.
|
||||||
|
- **Next standalone PORT bug:** `server.js` may not read `PORT` — ensure unit exports `PORT=4017` before exec (T17.4/T18.3).
|
||||||
|
- **three + React 19:** raw three avoids R3F compat surface; lifecycle (dispose + RAF) handled in T6.2.
|
||||||
|
- **next-themes:** keep (light/dark toggle); default dark.
|
||||||
|
|
||||||
|
## Open questions (answer before T18)
|
||||||
|
- Q1: Deploy to prod now or staging-only first? (Recommend staging + screenshot review.)
|
||||||
|
- Q2: Keep `react-day-picker`/`embla` if any page still needs them? (Plan assumes no — verify in T14.10 grep.)
|
||||||
|
- Q3: Any brand name/wordmark change from "Discord Automod"? (Keep "Bete" identity unless told.)
|
||||||
|
- Q4: 3D depth — full 3D scenes on dashboard+voice as specced, or also a 3D accent on media (disc)? (Default: dashboard+voice only; media disc stays CSS 3D.)
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# GMW — Moderation Explainability (#1) + Semantic Search (#3) Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development to implement task-by-task.
|
||||||
|
> Hard constraint from user (2026-08-18): web is PUBLIC, read-only, for USERS not admins. Moderation MUST stay FULLY AUTOMATIC. Rules stay in CODE (no per-channel config UI).
|
||||||
|
|
||||||
|
**Goal:** Make GMW transparent (users see why a message was moderated) and searchable (users can semantic-search the message corpus), via two fully-automatic, code-driven, read-only-public features.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- **#1 Explainability:** Persist the structured moderation verdict that already exists in `AnalysisResult` (`flags[]`, `categories[]`, `severity`, `confidence`, `evidence[]`) into new columns on `moderation_actions`, surface them through the existing public moderation oRPC + the existing public `moderation` dashboard view. No new behavior — only new *data* + new *read* paths.
|
||||||
|
- **#3 Semantic Search:** Add a SECOND persistent Qdrant collection (`gmw_message_archive`) keyed by message id (NOT the TTL cache). Embed each captured text message at capture time (reuse `embedText`) and upsert. Add a public `messages.semanticSearch` oRPC + a read-only search UI on the public `messages` view. Best-effort / non-blocking — embed failures never affect moderation or capture.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript (discord-gateway + backend + frontend monorepo), Drizzle ORM + Postgres (PgBouncer on imrnes), Qdrant (100.121.180.82:6333), Next.js 16 App Router + shadcn/ui, oRPC over `/trpc`. pnpm. Deploy via GitHub Actions Nix build + `systemctl restart`.
|
||||||
|
|
||||||
|
**Critical existing facts (verified in repo):**
|
||||||
|
- `AnalysisResult` shape (`src/modules/ai-moderation/ai-analysis-worker.ts:55`): `messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?`. The shared `AnalysisResult` (`src/shared/moderation-types.ts:142`) ALSO has `evidence?: string[]` and `policyVersion?: string`. **THESE ARE ALREADY COMPUTED but only logged, never persisted to `moderation_actions`.**
|
||||||
|
- `moderation_actions` schema is DEFINED TWICE with a divergence:
|
||||||
|
- `src/shared/database/schema.ts:638` → `pgModerationActionsTable` (authoritative, has `reset_nickname` in `action_type` enum).
|
||||||
|
- `src/shared/database/schema/messages.ts:25` → another `pgModerationActionsTable` (NO `reset_nickname`; gateway-local copy).
|
||||||
|
- The gateway's `ModerationActionsDb` (`src/modules/message-capture/moderationActionsDb.ts`) imports from `schema.ts` (the authoritative one). The `messages.ts` copy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm the `messages.ts` copy is dead and delete it. **Decision: add columns to `schema.ts` (authoritative) AND the `messages.ts` copy to keep `$inferInsert`/`$inferSelect` in sync (the gateway `ModerationAction` type flows from shared).** Verify with grep that `messages.ts` `pgModerationActionsTable` is not used by any `.insert()`/`.select()` at runtime before relying on it; if only re-exported, we still patch it for type-safety.
|
||||||
|
- Migration mechanism: Drizzle-managed via `drizzle/migrations/*.sql` (journal `_journal.json`) applied by `runMigrations()` → `migratePostgres`. **New tables/columns must be added with `drizzle-kit generate` to produce a numbered `.sql` + journal entry**, OR (simpler, matches `0013_rename_*.sql` manual style) write a raw idempotent `.sql` under `drizzle/migrations/` AND register it in `_journal.json`. **Preferred here: use `pnpm drizzle-kit generate` so the journal stays consistent.** The legacy `src/shared/database/migrations/001_drop_unused_ai_columns.sql` is a PRE-drizzle manual script — do NOT follow that pattern.
|
||||||
|
- **Historical lesson (MUST respect):** a prior migration (`0004_drop_unused_ai_columns.sql` = old `001_drop`) DELETED `ai_evidence`, `ai_policy_version`, `ai_moderation_raw` from `messages` with the note "written but never read". → Our new `moderation_actions` columns MUST be read (serializer + FE render). No write-only columns.
|
||||||
|
- Embedding client: `embedText(text)` / `embedTexts(texts[])` in `src/modules/ai-moderation/embeddingClient.ts`. Returns `null` if `AI_LLM_EMBEDDING_MODEL` not configured. Reuses `config.AI_LLM_BASE_URL` + `config.AI_LLM_API_KEY`. OpenAI SDK v6 → `encoding_format: "float"` REQUIRED (Nvidia rejects base64).
|
||||||
|
- Qdrant client: `src/modules/ai-moderation/qdrantClient.ts`. Has `ensureQdrantCollection(vectorSize)`, `upsertQdrantPoint(cacheKey, vector, payload)`, `searchQdrant(vector, limit, scoreThreshold)`. These are hardcoded to the cache collection name `config.QDRANT_COLLECTION ?? "gmw_text_moderation"`. **#3 needs a second collection** → generalize the client to accept a collection name param (add `ensureQdrantCollectionV2(name, size)` / `upsertQdrantPointV2(name, id, vector, payload)` / `searchQdrantV2(name, vector, limit, scoreThreshold)` OR refactor `collectionName()` to take an arg). Keep the cache path unchanged.
|
||||||
|
- Capture hook: `captureMessage()` (`src/modules/message-capture/messageCapture.ts:201`) calls `messageStore.upsertMessageForCapture(messageRecord)` then (if not backlog) `queueMessageAnalysis`. **#3 embed must happen here**, async + fire-and-forget, after successful insert.
|
||||||
|
- Public moderation view: `services/frontend/src/app/(dashboard)/moderation/view.tsx` renders `ActionRow` per action. The `ModerationAction` FE type is in `services/frontend/src/lib/types/moderation.ts` (NO new fields yet). The backend `moderationService.listActions` SQL is in `services/backend/src/modules/moderation/moderation.repository.ts:68` (raw SQL, selects fixed columns, joins `messages`).
|
||||||
|
- oRPC wiring: `services/backend/src/orpc/router.ts` → `moderationRouter` (stats, actions) and `messagesRouter` (list, byChannel, getById, review, attachments). New procedures added here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 1 — Schema: add explainability columns to `moderation_actions`
|
||||||
|
|
||||||
|
**Objective:** Persist structured verdict on moderation actions so it can be surfaced (read) later.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/shared/database/schema.ts` (authoritative `pgModerationActionsTable`, ~line 638)
|
||||||
|
- Modify: `services/discord-gateway/src/shared/database/schema/messages.ts` (`pgModerationActionsTable` copy, ~line 25) to keep type in sync
|
||||||
|
- Create: `services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql`
|
||||||
|
- Update: `services/discord-gateway/drizzle/migrations/meta/_journal.json` (add new entry)
|
||||||
|
|
||||||
|
**Step 1: Add columns to both schema definitions**
|
||||||
|
Add after `executed_at` in BOTH `pgModerationActionsTable` definitions:
|
||||||
|
```ts
|
||||||
|
// ── Explainability (structured verdict, surfaced read-only to public web) ──
|
||||||
|
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
|
||||||
|
categories: pgText("categories"), // JSON array of category strings
|
||||||
|
severity: pgText("severity", {
|
||||||
|
enum: ["none", "low", "medium", "high", "critical"],
|
||||||
|
}),
|
||||||
|
confidence: pgReal("confidence"), // 0..1
|
||||||
|
score: pgReal("score"), // 0..1 raw model score
|
||||||
|
evidence: pgText("evidence"), // JSON array of short quoted snippets
|
||||||
|
policy_version: pgText("policy_version"), // rules.ts policy version string
|
||||||
|
```
|
||||||
|
Note: `flags`/`categories`/`evidence` stored as JSON-stringified TEXT (consistent with how `messages.ai_moderation_flags`/`ai_categories` are stored as TEXT elsewhere — confirm storage format in `updateMessageAIAnalysis`). Keep nullable.
|
||||||
|
|
||||||
|
**Step 2: Generate/author the migration SQL**
|
||||||
|
`0015_add_moderation_explainability.sql` (idempotent):
|
||||||
|
```sql
|
||||||
|
-- Add structured explainability columns to moderation_actions (read-only surfaced to public web).
|
||||||
|
ALTER TABLE IF EXISTS "moderation_actions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "severity" text
|
||||||
|
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||||
|
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "score" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||||
|
```
|
||||||
|
Register in `_journal.json`: append an entry with `idx: 15`, a new unique `tag` (hash), `version`, `when` = Date.now(), `tag` short, `breakpoints: false`. Use `pnpm drizzle-kit generate` if possible to get a correct tag; otherwise hand-edit the journal carefully (copy an existing entry's shape).
|
||||||
|
|
||||||
|
**Step 3: Type-check gateway**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||||
|
Expected: PASS (no new compile errors).
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/shared/database/schema.ts \
|
||||||
|
services/discord-gateway/src/shared/database/schema/messages.ts \
|
||||||
|
services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql \
|
||||||
|
services/discord-gateway/drizzle/migrations/meta/_journal.json
|
||||||
|
git commit -m "feat(db): add explainability columns to moderation_actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 2 — Persist verdict at the auto-delete + command-handler call sites
|
||||||
|
|
||||||
|
**Objective:** Populate the new columns from the already-computed `AnalysisResult` when a moderation action is logged. Fully automatic, no new behavior.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts` (`logAutoDeleteAttempt` ~line 166, and the second `createModerationAction` call ~line 234 for nickname/mute paths)
|
||||||
|
- Modify: `services/discord-gateway/src/modules/command-handler/moderation.handler.ts` (`createModerationAction` ~line 95)
|
||||||
|
- Helper (create): `services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts` — shared mapper so all 3 call sites stay DRY.
|
||||||
|
|
||||||
|
**Step 1: Create the mapper helper**
|
||||||
|
`verdictToActionFields.ts`:
|
||||||
|
```ts
|
||||||
|
import type { AnalysisResult } from "@/modules/ai-moderation/ai-analysis-worker";
|
||||||
|
import type { ModerationActionInsert } from "@/shared/index"; // or inline shape
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a computed AI verdict into the explainability columns of a moderation
|
||||||
|
* action. Null-safe: missing fields stay null (e.g. manual admin actions have
|
||||||
|
* no AnalysisResult). This is read-only structured data — it does NOT change
|
||||||
|
* any enforcement decision.
|
||||||
|
*/
|
||||||
|
export function verdictToActionFields(result?: {
|
||||||
|
flags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
severity?: string;
|
||||||
|
confidence?: number;
|
||||||
|
score?: number;
|
||||||
|
evidence?: string[];
|
||||||
|
policyVersion?: string;
|
||||||
|
}): {
|
||||||
|
flags: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
score: number | null;
|
||||||
|
evidence: string | null;
|
||||||
|
policy_version: string | null;
|
||||||
|
} {
|
||||||
|
if (!result) {
|
||||||
|
return { flags: null, categories: null, severity: null, confidence: null,
|
||||||
|
score: null, evidence: null, policy_version: null };
|
||||||
|
}
|
||||||
|
const j = (v: unknown) => (v == null ? null : JSON.stringify(v));
|
||||||
|
return {
|
||||||
|
flags: j(result.flags),
|
||||||
|
categories: j(result.categories),
|
||||||
|
severity: result.severity ?? null,
|
||||||
|
confidence: result.confidence ?? null,
|
||||||
|
score: result.score ?? null,
|
||||||
|
evidence: j(result.evidence),
|
||||||
|
policy_version: result.policyVersion ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Wire `logAutoDeleteAttempt`**
|
||||||
|
Find the `createModerationAction({...})` in `logAutoDeleteAttempt` and spread the verdict fields:
|
||||||
|
```ts
|
||||||
|
await messageStore.createModerationAction({
|
||||||
|
message_id: message.id,
|
||||||
|
user_id: message.user_id,
|
||||||
|
guild_id: message.guild_id,
|
||||||
|
action_type: "delete_message",
|
||||||
|
reason: result.reason,
|
||||||
|
...verdictToActionFields(result.analysisResult), // <-- pass the AnalysisResult through
|
||||||
|
executed_by: "auto-delete-manager",
|
||||||
|
status: ...,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
**IMPORTANT:** `result` here is `AutoDeleteResult` — verify it carries the `AnalysisResult` (or the verdict). If `AutoDeleteResult` does NOT carry the full `AnalysisResult`, trace where `attemptAutoDeleteFlaggedMessage` is called from and pass the `AnalysisResult` down (it is available in the analysis worker that triggered the delete). Confirm by reading `AutoDeleteResult` type + its producer. If the verdict is only available at the orchestrator level, add an optional `verdict?: AnalysisResult` field to `AutoDeleteResult` and populate it at the call site.
|
||||||
|
|
||||||
|
**Step 3: Wire the second call site in `autoDeleteManager.ts`** (the mute/nickname path ~line 234) similarly, if it has an `AnalysisResult` available; otherwise leave fields null (manual-style action).
|
||||||
|
|
||||||
|
**Step 4: Wire `moderation.handler.ts`** command path (~line 95) — pass `verdictToActionFields(verdict)` if the command handler has the `AnalysisResult` for the target message; otherwise nulls. Confirm what the handler receives.
|
||||||
|
|
||||||
|
**Step 5: Type-check + lint**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts \
|
||||||
|
services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts \
|
||||||
|
services/discord-gateway/src/modules/command-handler/moderation.handler.ts
|
||||||
|
git commit -m "feat(mods): persist structured verdict into moderation_actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 3 — Backend: surface explainability in `moderation.actions`
|
||||||
|
|
||||||
|
**Objective:** Read the new columns in the public oRPC so the frontend can render them. (Read path — satisfies the "never write-only" rule.)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/backend/src/modules/moderation/moderation.repository.ts` (`listActions` raw SQL ~line 68) — add new columns to SELECT + map.
|
||||||
|
- Modify: `services/frontend/src/lib/types/moderation.ts` (`ModerationAction` interface) — add new fields.
|
||||||
|
- Modify: `services/frontend/src/app/(dashboard)/moderation/view.tsx` (`ActionRow`) — render flags/categories badges + severity + confidence + evidence snippet.
|
||||||
|
|
||||||
|
**Step 1: Extend backend SELECT**
|
||||||
|
In `listActions`, add to the SELECT list: `a.flags, a.categories, a.severity, a.confidence, a.score, a.evidence, a.policy_version`. In the `.map(...)` add:
|
||||||
|
```ts
|
||||||
|
flags: r.flags ? safeJsonArray(String(r.flags)) : null,
|
||||||
|
categories: r.categories ? safeJsonArray(String(r.categories)) : null,
|
||||||
|
severity: r.severity ? String(r.severity) : null,
|
||||||
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
|
score: r.score != null ? Number(r.score) : null,
|
||||||
|
evidence: r.evidence ? safeJsonArray(String(r.evidence)) : null,
|
||||||
|
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||||
|
```
|
||||||
|
where `safeJsonArray(s)` = `JSON.parse(s)` wrapped in try/catch returning `[]` on failure (define a tiny local helper in the repository file).
|
||||||
|
|
||||||
|
**Step 2: Extend FE type**
|
||||||
|
In `services/frontend/src/lib/types/moderation.ts` `ModerationAction`:
|
||||||
|
```ts
|
||||||
|
flags: string[] | null;
|
||||||
|
categories: string[] | null;
|
||||||
|
severity: "none" | "low" | "medium" | "high" | "critical" | null;
|
||||||
|
confidence: number | null;
|
||||||
|
score: number | null;
|
||||||
|
evidence: string[] | null;
|
||||||
|
policy_version: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Render in `ActionRow`**
|
||||||
|
After the existing reason block, add (using existing `Badge` + `aiTone` from `@/lib/ai-status`):
|
||||||
|
```tsx
|
||||||
|
{a.severity && (
|
||||||
|
<Badge tone={aiTone(a.severity === "none" ? "clean" : a.severity)}>
|
||||||
|
{a.severity}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{a.flags?.length ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{a.flags.map((f) => <Badge key={f} tone="amber">{f}</Badge>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{a.evidence?.length ? (
|
||||||
|
<div className="mt-1 text-xs text-ink-faint border-l-2 border-hairline pl-2">
|
||||||
|
“{a.evidence[0]}”
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{a.confidence != null && (
|
||||||
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
|
conf {(a.confidence * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
Keep `ActionRow` read-only. No admin controls.
|
||||||
|
|
||||||
|
**Step 4: Type-check both services**
|
||||||
|
Run: `cd services/backend && pnpm typecheck && pnpm lint` and `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/backend/src/modules/moderation/moderation.repository.ts \
|
||||||
|
services/frontend/src/lib/types/moderation.ts \
|
||||||
|
services/frontend/src/app/\(dashboard\)/moderation/view.tsx
|
||||||
|
git commit -m "feat(web): surface moderation explainability (flags/severity/evidence)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 4 — Qdrant client: support a second persistent collection
|
||||||
|
|
||||||
|
**Objective:** Generalize the Qdrant client so #3 can use a dedicated archive collection without disturbing the automod cache.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts`
|
||||||
|
|
||||||
|
**Step 1: Add collection-aware variants**
|
||||||
|
Refactor `collectionName()` to accept an optional name, and add V2 functions that take an explicit collection:
|
||||||
|
```ts
|
||||||
|
function collectionName(fallback = config.QDRANT_COLLECTION ?? "gmw_text_moderation"): string {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
export const ARCHIVE_COLLECTION = config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
|
export async function ensureQdrantCollectionV2(name: string, vectorSize: number): Promise<boolean> {
|
||||||
|
// same body as ensureQdrantCollection but uses `name` instead of collectionName()
|
||||||
|
}
|
||||||
|
export async function upsertQdrantPointV2(
|
||||||
|
name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
|
||||||
|
): Promise<boolean> { /* PUT /collections/{name}/points with wait:true */ }
|
||||||
|
export async function searchQdrantV2(
|
||||||
|
name: string, vector: number[], limit: number, scoreThreshold: number,
|
||||||
|
): Promise<QdrantSearchHit[]> { /* POST /collections/{name}/points/search */ }
|
||||||
|
```
|
||||||
|
Keep all existing `ensureQdrantCollection` / `upsertQdrantPoint` / `searchQdrant` UNCHANGED (cache path). V2 functions mirror them with the `name` param. Reuse `request()` and the existing payload/score types.
|
||||||
|
|
||||||
|
**Step 2: Type-check**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
|
||||||
|
git commit -m "feat(qdrant): add collection-aware V2 upsert/search for archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 5 — Capture-time embed + archive upsert
|
||||||
|
|
||||||
|
**Objective:** Make every (non-backlog, text) captured message searchable in the persistent archive. Non-blocking / best-effort.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` (`captureMessage` ~line 201)
|
||||||
|
- Create: `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` — wraps embed + upsert with fire-and-forget + rate-limit guard.
|
||||||
|
|
||||||
|
**Step 1: Create `archiveEmbedder.ts`**
|
||||||
|
```ts
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedText } from "@/modules/ai-moderation/embeddingClient";
|
||||||
|
import { ARCHIVE_COLLECTION, ensureQdrantCollectionV2, upsertQdrantPointV2, qdrantPointId } from "@/modules/ai-moderation/qdrantClient";
|
||||||
|
import { config } from "@/shared/config/config";
|
||||||
|
|
||||||
|
const log = createChildLogger("archive-embedder");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget: embed a captured message and upsert into the persistent
|
||||||
|
* archive collection. Failures are swallowed — searching is a nice-to-have,
|
||||||
|
* never a precondition for capture or moderation.
|
||||||
|
*/
|
||||||
|
export function archiveMessageEmbedded(message: {
|
||||||
|
id: string; content: string; username: string; channel_id: string; guild_id: string; created_at: number;
|
||||||
|
}): void {
|
||||||
|
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||||
|
if (!message.content || message.content.trim().length < 3) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const vector = await embedText(message.content);
|
||||||
|
if (!vector) return;
|
||||||
|
const ok = await ensureQdrantCollectionV2(ARCHIVE_COLLECTION, vector.length);
|
||||||
|
if (!ok) return;
|
||||||
|
await upsertQdrantPointV2(ARCHIVE_COLLECTION, qdrantPointId(`archive:${message.id}`), vector, {
|
||||||
|
text: message.content.slice(0, 4000),
|
||||||
|
flags: "", // not a verdict payload; keep shape compatible
|
||||||
|
analyzed_at: Date.now(),
|
||||||
|
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, // 5y persistent
|
||||||
|
content_hash: undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.debug({ messageId: message.id, error: err instanceof Error ? err.message : String(err) }, "archive embed skipped");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Payload type reuse: `QdrantVerdictPayload` has `text`, `flags`, `analyzed_at`, `expires_at`, `content_hash?`. For the archive we only need `text` + timestamps; set `flags: ""` (empty, ignored by search filter which keys on `expires_at`). **Acceptable:** the search path filters `expires_at >= now` — 5y window satisfies that.
|
||||||
|
|
||||||
|
**Step 2: Call from `captureMessage`**
|
||||||
|
In `captureMessage`, after `const inserted = await messageStore.upsertMessageForCapture(messageRecord); if (!inserted) return;` and BEFORE the backlog branch, add:
|
||||||
|
```ts
|
||||||
|
if (!isBacklog && messageRecord.content) {
|
||||||
|
archiveMessageEmbedded(messageRecord);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`messageRecord` is the `MessageRecord` from `buildMessageRecord`; confirm it carries `content`, `channel_id`, `guild_id`, `created_at`. It does — see `messagesCrud`/`types`.)
|
||||||
|
|
||||||
|
**Step 3: Type-check + lint**
|
||||||
|
Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts \
|
||||||
|
services/discord-gateway/src/modules/message-capture/messageCapture.ts
|
||||||
|
git commit -m "feat(archive): embed captured messages into persistent Qdrant archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 6 — Backend: `messages.semanticSearch` oRPC
|
||||||
|
|
||||||
|
**Objective:** Public, read-only semantic search over the message archive.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.repository.ts` — add `semanticSearch(query, limit, guildId?)`.
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.service.ts` — expose `semanticSearch`.
|
||||||
|
- Modify: `services/backend/src/orpc/router.ts` — add `messages.semanticSearch` procedure.
|
||||||
|
- Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's `embeddingClient`. **Decision:** add a minimal backend embed helper `services/backend/src/modules/messages/embed.ts` that calls the same OpenAI-compatible endpoint via `config` (reuse `config.AI_LLM_BASE_URL`/`AI_LLM_API_KEY`/`AI_LLM_EMBEDDING_MODEL` if present on the backend; if not configured, return a clear "search unavailable" error). Mirror `encoding_format: "float"`.
|
||||||
|
- Modify: `services/backend/src/modules/messages/messages.schema.ts` — add `semanticSearchQuery` zod schema (limit, guildId?, query).
|
||||||
|
|
||||||
|
**Step 1: Backend embed helper** (`embed.ts`)
|
||||||
|
```ts
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { config } from "@/shared/config/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
const log = createChildLogger("messages-embed");
|
||||||
|
let client: OpenAI | null = null;
|
||||||
|
function getClient() {
|
||||||
|
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||||
|
if (!client) client = new OpenAI({ apiKey: config.AI_LLM_API_KEY, baseURL: config.AI_LLM_BASE_URL, maxRetries: 0, timeout: 30_000 });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||||
|
const c = getClient(); if (!c) return null;
|
||||||
|
try {
|
||||||
|
const r = await c.embeddings.create({ model: config.AI_LLM_EMBEDDING_MODEL as string, input: text, encoding_format: "float" });
|
||||||
|
return r.data[0].embedding;
|
||||||
|
} catch (e) { log.warn({ error: e instanceof Error ? e.message : String(e) }, "query embed failed"); return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Repository `semanticSearch`**
|
||||||
|
```ts
|
||||||
|
async semanticSearch(queryVector: number[], limit: number, guildId?: string) {
|
||||||
|
// Search archive collection, then join messages for text + channel.
|
||||||
|
const hits = await searchQdrantV2(ARCHIVE_COLLECTION, queryVector, limit, 0.6);
|
||||||
|
const ids = hits.map(h => h.cacheKey.replace("qdrant:", "")); // point id → we stored archive:<messageId>
|
||||||
|
// decode: qdrantPointId is a uint64; we need the original message id.
|
||||||
|
// SIMPLER: store message_id inside the payload too. → update archiveEmbedder payload to include `message_id`.
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**REFINEMENT (important):** `qdrantPointId` is a hash, not reversible. So the archive payload MUST carry `message_id` (and `channel_id`, `guild_id`, `username`, `created_at`) so the backend can return full results without a reverse lookup. **Update `archiveEmbedder.ts` payload** to include those fields, and relax `QdrantVerdictPayload` (or create `QdrantArchivePayload`) to allow them. Then `semanticSearch` returns the payloads directly (already contain text + metadata) — no DB join needed, and it works even for deleted messages (archive keeps the text). Apply `guildId` filter client-side on the returned payloads.
|
||||||
|
|
||||||
|
**Step 3: Service + router**
|
||||||
|
`messages.service.ts`: `async semanticSearch(query: string, limit: number, guildId?: string)` → embed → `repository.semanticSearch`.
|
||||||
|
`orpc/router.ts` under `messagesRouter`:
|
||||||
|
```ts
|
||||||
|
semanticSearch: os
|
||||||
|
.input(z.object({ query: z.string().min(1), limit: z.coerce.number().int().positive().max(50).default(10), guildId: z.string().optional() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const results = await messagesService.semanticSearch(input.query, input.limit, input.guildId);
|
||||||
|
return { results, nextCursor: null };
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Type-check + lint (backend)**
|
||||||
|
Run: `cd services/backend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/backend/src/modules/messages/embed.ts \
|
||||||
|
services/backend/src/modules/messages/messages.repository.ts \
|
||||||
|
services/backend/src/modules/messages/messages.service.ts \
|
||||||
|
services/backend/src/modules/messages/messages.schema.ts \
|
||||||
|
services/backend/src/orpc/router.ts
|
||||||
|
git commit -m "feat(api): public semantic message search over archive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 7 — Frontend: semantic search UI on `messages` view
|
||||||
|
|
||||||
|
**Objective:** Public, read-only search box + results on the existing messages dashboard.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `services/frontend/src/app/(dashboard)/messages/page.tsx` + `view.tsx` — add a search input (debounced) that calls a new `useMessagesSemanticSearch` hook → `messages.semanticSearch` oRPC, renders results as message cards (reuse `GlassPanel`/`Badge`/existing message row components).
|
||||||
|
- Modify: `services/frontend/src/lib/types/message.ts` — add `SemanticSearchResult` + `SemanticSearchResponse` types.
|
||||||
|
- Modify: `services/frontend/src/lib/api/client.ts` (or `server.ts`) — add `semanticSearch` fetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add a `POST /api/messages/semantic-search` or an oRPC client call consistent with existing `messages.*` calls (follow the EXISTING pattern in `src/lib/api/` — inspect how `messages.list` is called and replicate).
|
||||||
|
|
||||||
|
**Step 1: Add FE types**
|
||||||
|
```ts
|
||||||
|
export interface SemanticSearchResult {
|
||||||
|
message_id: string;
|
||||||
|
content: string;
|
||||||
|
username: string;
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string;
|
||||||
|
created_at: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: string | null; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add hook + wire view**
|
||||||
|
Follow the existing `use-moderation.ts` SWR pattern. Add `useMessagesSemanticSearch(query, guildId?)` returning `{ data, isLoading, error }`. In `messages/view.tsx`, add a search `Input` (from `@/components/primitives`) at the top, debounce ~300ms, and render results below the live list when a query is present. Reuse the message-row rendering already in that view (do not invent a new component).
|
||||||
|
|
||||||
|
**Step 3: Type-check + lint (frontend)**
|
||||||
|
Run: `cd services/frontend && pnpm typecheck && pnpm lint`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add services/frontend/src/app/\(dashboard\)/messages/ \
|
||||||
|
services/frontend/src/lib/types/message.ts \
|
||||||
|
services/frontend/src/lib/api/ \
|
||||||
|
services/frontend/src/hooks/
|
||||||
|
git commit -m "feat(web): public semantic message search UI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TASK 8 — Build all services + deploy + verify
|
||||||
|
|
||||||
|
1. `cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
2. `cd services/backend && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
3. `cd services/frontend && pnpm typecheck && pnpm build && pnpm lint`
|
||||||
|
4. Commit any formatting fixes (biome `--unsafe` if import order), author `asepharyana`, NO Co-Authored-By.
|
||||||
|
5. `git push origin main` → watch `gh run watch` on "Build & Deploy (Nix)".
|
||||||
|
6. After deploy: verify `systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubState` reflects new timestamp; same for backend + frontend.
|
||||||
|
7. Smoke: `curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=<urlencoded json>` OR via the public web `imphnen.asepharyana.my.id` messages page → type a query → expect results (after some messages have been embedded; embeddings only run on NEW captures post-deploy, so seed a few test messages or backfill).
|
||||||
|
8. Moderation explainability: trigger/observe a flagged message → confirm `moderation_actions.flags` is populated (SQL `SELECT flags, severity FROM moderation_actions ORDER BY created_at DESC LIMIT 5;`) and the public moderation view shows badges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RISKS / TRADEOFFS / OPEN QUESTIONS
|
||||||
|
- **Embedding cost:** #3 embeds EVERY captured message → more embedding API calls. Mitigated: only text ≥3 chars, fire-and-forget, skip if model unconfigured. If cost is a concern, batch embed (reuse `embedTexts`) per capture burst — but start simple (per-message) and observe.
|
||||||
|
- **Backfill:** post-deploy, the archive is empty until new messages arrive. Optional follow-up: a one-off backfill script over existing `messages` (out of scope for this plan unless user asks).
|
||||||
|
- **Schema duplication:** the `pgModerationActionsTable` double-definition must stay in sync (Task 1 patches both). If `messages.ts` copy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk).
|
||||||
|
- **`AutoDeleteResult` verdict availability (Task 2):** requires confirming the `AnalysisResult` is reachable at the `createModerationAction` call sites. If not, we add an optional field to `AutoDeleteResult` at the orchestrator call site. This is the highest-risk integration point — verify before assuming.
|
||||||
|
- **Public exposure:** semantic search returns message text + usernames. This is INTENDED (web is public for users). No auth added. If a guild wants private, that is a future config (out of scope).
|
||||||
|
- **Qdrant payload type:** reusing `QdrantVerdictPayload` for archive is slightly awkward (carries `flags`/`expires_at` semantics). Cleaner: introduce `QdrantArchivePayload` with `message_id`, `channel_id`, `guild_id`, `username`, `content`, `created_at`, `expires_at`. **Prefer the dedicated payload type in Task 4/5** to avoid confusion.
|
||||||
|
|
||||||
|
## VERIFICATION CHECKLIST
|
||||||
|
- [ ] `moderation_actions` has 7 new columns (DB + both schema defs).
|
||||||
|
- [ ] A real auto-delete populates `flags`/`severity`/`evidence` (verified via SQL).
|
||||||
|
- [ ] Public moderation view renders badges + evidence (manual browser check on imphnen.asepharyana.my.id/moderation).
|
||||||
|
- [ ] New message capture upserts a point into `gmw_message_archive` (verify via Qdrant `/collections/gmw_message_archive/points/count`).
|
||||||
|
- [ ] `messages.semanticSearch` returns relevant results for a known phrase.
|
||||||
|
- [ ] All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# GMW — Fitur Publik Lanjutan (#2–#6) Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service
|
||||||
|
> after its changes. Deploy via push to main (CI handles Nix build + systemd).
|
||||||
|
> Hard constraint (user 2026-08-18): public read-only web, fully automatic,
|
||||||
|
> rules in code, NO admin endpoints, NO shadow mode, NO per-channel web config.
|
||||||
|
> **EXPLICITLY EXCLUDED: User Reputation / Strike History** (user: "hapus
|
||||||
|
> sepenuhnya fitur user reputation" — it was never built; do not add it).
|
||||||
|
|
||||||
|
## Existing infra to reuse (verified)
|
||||||
|
- **WS**: backend `ws/server.ts` broadcasts JSON `{type,data,timestamp}` to
|
||||||
|
frontendClients. Backend `ws/redis-bridge.ts` subscribes Redis channels
|
||||||
|
listed in `DISCORD_CHANNEL_TO_WS_EVENT` (backend `shared/redis-channels.ts`)
|
||||||
|
and re-emits as WS events. FE `src/lib/ws` auto-reconnect typed client.
|
||||||
|
- **Gateway → Redis**: `EventBroadcaster` + `RedisEventPublisher` (
|
||||||
|
`discord-gateway/src/modules/event-broadcaster`). Publish via
|
||||||
|
`eventBroadcaster.publish(EventChannels.X, payload)`.
|
||||||
|
- **Moderation data**: `moderation_actions` table (now has explainability
|
||||||
|
cols). `moderation.repository.listActions` returns rows. `ModerationAction`
|
||||||
|
FE type at `frontend/src/lib/types/moderation.ts`.
|
||||||
|
- **Messages**: `messages.list` / `getMessagesByChannel` (backend oRPC +
|
||||||
|
repository). FE `messagesApi` + `useMessages`.
|
||||||
|
- **Charts**: NO chart lib installed. Use **pure SVG/CSS** (consistent with
|
||||||
|
repo; avoid new deps).
|
||||||
|
- **CSV**: client-side Blob download, no backend.
|
||||||
|
|
||||||
|
## Task 1 — Live Moderation Feed (#2)
|
||||||
|
**Gateway**: add `MODERATION_ACTION: "discord:moderation:action"` to
|
||||||
|
`redis-channels.ts` (shared) + `EventChannels.MODERATION_ACTION` in
|
||||||
|
`eventTypes.ts`. In `moderationActionsDb.createModerationAction`, after insert,
|
||||||
|
publish `eventBroadcaster.publish(EventChannels.MODERATION_ACTION, actionRow)`.
|
||||||
|
**Backend**: add `DISCORD_MODERATION_ACTION` constant + map
|
||||||
|
`[DISCORD_MODERATION_ACTION]: "moderation_action"` in `DISCORD_CHANNEL_TO_WS_EVENT`.
|
||||||
|
**FE**: in `src/lib/ws`, subscribe to `moderation_action`; add `useLiveModeration`
|
||||||
|
hook (SWR-style with WS push, capped buffer ~50). Add `<LiveModerationFeed>`
|
||||||
|
client component on `/moderation` page (top of list, animated new-row).
|
||||||
|
Risk: gateway publish at every action (already async insert) — fire-and-forget,
|
||||||
|
wrap in try/catch. Verify WS event reaches FE via `wscat`/curl or log.
|
||||||
|
|
||||||
|
## Task 2 — Toxic Topic Trends (#3)
|
||||||
|
**Backend**: add `moderation.trends` oRPC. Query `moderation_actions` grouped
|
||||||
|
by `categories` (jsonb text[]) over last 30 days, count per category + severity
|
||||||
|
breakdown. Also `action_type` distribution. Return
|
||||||
|
`{ categories: {name,count}[], severities: {level,count}[], actions: {type,count}[] }`.
|
||||||
|
Map jsonb array in SQL (use `unnest` or parse in JS). Reuse `getDatabase`.
|
||||||
|
**FE**: `useModerationTrends` hook + `<TopicTrends>` SVG bar chart (top 10
|
||||||
|
categories) + severity donut (SVG arcs). Place on `/moderation` as a panel.
|
||||||
|
|
||||||
|
## Task 3 — Channel Timeline / Replay (#4)
|
||||||
|
Reuse existing `messages.list` (guildId) + `getMessagesByChannel`. Add a
|
||||||
|
**Timeline tab** to `/messages` that groups messages by date (client-side
|
||||||
|
bucket from `created_at`). Load-more via cursor. No new backend (existing
|
||||||
|
`messagesRouter.list` already supports guildId+limit+cursor). If needed, add
|
||||||
|
`messages.timeline` aggregation (count per day) — but keep simple: client
|
||||||
|
groups fetched rows. Verify existing endpoint returns enough history.
|
||||||
|
|
||||||
|
## Task 4 — Export CSV (#5)
|
||||||
|
**FE only**. `lib/csv.ts` `toCsv(rows, columns)` + `downloadCsv(filename, csv)`.
|
||||||
|
Add "Export CSV" button on `/moderation` (exports current actions) and
|
||||||
|
`/messages` (exports current list). Pure client-side, read-only. No backend.
|
||||||
|
|
||||||
|
## Task 5 — Activity Heatmap (#6)
|
||||||
|
**Backend**: add `messages.activity` oRPC: per-channel message count grouped by
|
||||||
|
hour-of-day (0–23) over last 14 days. Return
|
||||||
|
`{ channels: {channelId, name, byHour: number[24]}[], max }`. Use SQL
|
||||||
|
`EXTRACT(hour from ...)` + group by channel. Channel name from
|
||||||
|
`message.metadata->'channel'->>'channelName'`.
|
||||||
|
**FE**: `useMessageActivity` hook + `<ActivityHeatmap>` SVG grid (channels ×
|
||||||
|
24h, color intensity = count/max). Place on `/messages` or `/dashboard`.
|
||||||
|
|
||||||
|
## Verification checklist
|
||||||
|
- [ ] `pnpm typecheck && pnpm lint && pnpm build` green for gateway, backend, frontend
|
||||||
|
- [ ] Backend `/trpc/moderation/trends` returns categories/severities/actions
|
||||||
|
- [ ] Backend `/trpc/messages/activity` returns byHour grids
|
||||||
|
- [ ] WS `moderation_action` received by FE (log or visible live row)
|
||||||
|
- [ ] No admin/write endpoint added; all public read-only
|
||||||
|
- [ ] No User Reputation code anywhere (grep "reputation|strike|reputasi")
|
||||||
|
- [ ] Deploy via push; all 3 services `running`; moderation + messages pages load
|
||||||
|
|
||||||
|
## Files touched (summary)
|
||||||
|
- gateway: `shared/redis-channels.ts`, `event-broadcaster/eventTypes.ts`,
|
||||||
|
`event-broadcaster/eventBroadcaster.ts`, `message-capture/moderationActionsDb.ts`
|
||||||
|
- backend: `shared/redis-channels.ts`, `orpc/router.ts`,
|
||||||
|
`modules/moderation/moderation.service.ts` (+repository),
|
||||||
|
`modules/messages/messages.service.ts` (+repository, +schema)
|
||||||
|
- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`,
|
||||||
|
`lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`,
|
||||||
|
`app/(dashboard)/messages/view.tsx`, new components under `components/`
|
||||||
|
|
||||||
|
## Status: COMPLETE (deployed + verified)
|
||||||
|
- Commit 9b3134d: features #2–#6 (live feed, trends, timeline, CSV export, heatmap)
|
||||||
|
- Commit 2a8f6d9: user reputation feature fully removed (643 deletions, no trace in src/tests)
|
||||||
|
- Migration 0016 applied: user_reputations DROPPED (DB verified: false)
|
||||||
|
- All 3 services active (gateway + backend restarted 18:29, frontend running)
|
||||||
|
- Gateway typecheck/lint/test(117 passed); backend typecheck/lint/build; FE lint/build — all GREEN
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- moderation/stats WS returns data (32 actions) → WS adapter works
|
||||||
|
- DB: user_reputations gone; moderation_actions explainability cols present
|
||||||
|
- Live Feed: gateway publishes discord:moderation:action → backend WS (same path as guild_member_*)
|
||||||
|
- Trends/Activity: backend router procedures registered (typecheck+tsc), same WS adapter
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# GMW — Fitur Publik Lanjutan #7–#15 + Bug Fix Reputation Removal
|
||||||
|
|
||||||
|
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service after its
|
||||||
|
> changes. Deploy via push to main (CI handles Nix build + systemd). Apply any new
|
||||||
|
> drizzle migration MANUALLY (systemd does NOT run migrations).
|
||||||
|
> Hard constraint (user): public read-only web, fully automatic, rules in code,
|
||||||
|
> NO admin endpoints, NO shadow mode, NO per-user reputation aggregation.
|
||||||
|
|
||||||
|
## Bug fix discovered during planning (MUST do first)
|
||||||
|
`services/backend/src/modules/dashboard/dashboard.repository.ts` still references
|
||||||
|
`pgUserReputationsTable` (import line 8; JOINs at lines 173 + 457) — that table was
|
||||||
|
DROPPED in migration `0016`. `dashboard.listUsers` / `dashboard.userDetail` will
|
||||||
|
**crash at runtime** (undefined table). Remove the import + the `r.*` join columns
|
||||||
|
(`trust_score`, `clean_message_streak`, `total_infractions`) from both queries.
|
||||||
|
This is a regression introduced by the reputation removal commit.
|
||||||
|
|
||||||
|
## Features to implement (#7–#15)
|
||||||
|
All reuse existing infra: `moderation_actions`, `messages`, `channel_cultures`,
|
||||||
|
`term_glossary_cache`, `ai_analysis_runs`, `message_edits`, gateway cron (for #15),
|
||||||
|
WS (proven Live Feed pattern), oRPC over WS (proven), pure-SVG charts (no libs).
|
||||||
|
|
||||||
|
| # | Feature | Data source | Surface |
|
||||||
|
|---|---------|-------------|---------|
|
||||||
|
| 7 | Flagged Link / Scam Domain Reporter | regex URL from `moderation_actions.content`/`evidence` | `/moderation` |
|
||||||
|
| 8 | Top Flagged Channels | join `moderation_actions.message_id`→`messages.channel_id` | `/moderation` |
|
||||||
|
| 9 | Moderation Heatmap by Hour | `moderation_actions.created_at` hour-of-day | `/moderation` |
|
||||||
|
| 10 | Flag Category Drill-down | `moderation_actions.categories` (reuse Trends) | `/moderation` FE-only |
|
||||||
|
| 11 | Channel Culture Glossary | `channel_cultures` (exists) | new `/channels` panel |
|
||||||
|
| 12 | Term Knowledge Base | `term_glossary_cache` (exists) | new `/glossary` panel |
|
||||||
|
| 13 | Edit/Evasion Tracker | `message_edits` (exists) | `/messages` |
|
||||||
|
| 14 | Auto-mod Coverage Stats | `ai_analysis_runs` (exists) | `/moderation` metric tiles |
|
||||||
|
| 15 | Weekly Digest (auto, cron) | aggregate #7/#8/#9 → Discord via gateway cron | gateway cron + `/moderation` |
|
||||||
|
|
||||||
|
## Architecture per layer
|
||||||
|
|
||||||
|
### Backend (oRPC, `services/backend/src`)
|
||||||
|
- New repository methods (add to existing repos, follow `getTrends` SQL style):
|
||||||
|
- `moderation.repository.ts`:
|
||||||
|
- `getTopFlaggedDomains(days)` — `regexp_matches(content,'https?://([^/\s]+)')` on
|
||||||
|
`moderation_actions WHERE created_at>=since`, group by host, COUNT, order DESC LIMIT 20.
|
||||||
|
- `getTopFlaggedChannels(days)` — join `moderation_actions a` LEFT JOIN `messages m`
|
||||||
|
ON `m.id=a.message_id`, group by `m.channel_id`, COUNT, order DESC LIMIT 15.
|
||||||
|
Channel name via `m.metadata::jsonb->'channel'->>'channelName'`.
|
||||||
|
- `getHourlyModeration(days)` — `EXTRACT(HOUR FROM to_timestamp(created_at/1000))`
|
||||||
|
group by hour, COUNT, severity breakdown. (24 rows)
|
||||||
|
- `getFlaggedByCategory(days, category)` — list actions where `categories` contains
|
||||||
|
`category` (reuse `listActions` filter or new query), for drill-down #10.
|
||||||
|
- `getCoverage(days)` — from `ai_analysis_runs`: total runs, status breakdown
|
||||||
|
(clean/flagged/warn/error/pending), coverage % = (analyzed)/(captured in window).
|
||||||
|
- `dashboard.repository.ts` (or new `knowledge.repository.ts`):
|
||||||
|
- `listChannelCultures(limit, search?)` — `channel_cultures` rows (channel_id,
|
||||||
|
guild_id, channel_name from messages metadata, culture_summary, last_analyzed_at).
|
||||||
|
- `listGlossary(limit, search?)` — `term_glossary_cache` (term, definition, source_url,
|
||||||
|
resolved_at, hit_count) order by hit_count DESC.
|
||||||
|
- `messages.repository.ts`:
|
||||||
|
- `getEditHistory(limit, channelId?)` — `message_edits` join `messages` for
|
||||||
|
old_content + channel + username + edited_at, order DESC LIMIT.
|
||||||
|
- `moderation.service.ts` / `dashboard.service.ts` / `messages.service.ts`: thin wrappers.
|
||||||
|
- `orpc/router.ts`: add procedures (follow `trends` shape):
|
||||||
|
- `moderation.topDomains`, `moderation.topChannels`, `moderation.byHour`,
|
||||||
|
`moderation.byCategory` (input `{days,category}`), `moderation.coverage`.
|
||||||
|
- `dashboard.channelCultures`, `dashboard.glossary`.
|
||||||
|
- `messages.editHistory`.
|
||||||
|
|
||||||
|
### Frontend (`services/frontend/src`)
|
||||||
|
- `lib/types/moderation.ts`: add `FlaggedDomain`, `FlaggedChannel`, `HourlyModeration`,
|
||||||
|
`ModerationCoverage` interfaces.
|
||||||
|
- `lib/types/index.ts` (+ message.ts): add `ChannelCultureRow`, `GlossaryRow`, `EditHistoryRow`.
|
||||||
|
- `lib/api/moderation.ts`: add `topDomains`, `topChannels`, `byHour`, `byCategory`, `coverage`.
|
||||||
|
- `lib/api/dashboard.ts` (or messages.ts): add `channelCultures`, `glossary`, `editHistory`.
|
||||||
|
- `lib/api/server.ts`: add SSR seed fetchers (follow `getModerationStats`).
|
||||||
|
- `hooks/use-moderation.ts`: add `useTopDomains`, `useTopChannels`, `useHourlyModeration`,
|
||||||
|
`useByCategory`, `useCoverage`. `hooks/use-dashboard.ts`/`use-messages.ts`: add culture/glossary/edit hooks. `hooks/index.ts`: export all.
|
||||||
|
- New components (pure SVG/CSS, reuse `GlassPanel`/`SectionHeader`/`Badge`/`Donut`):
|
||||||
|
- `components/ScamDomains.tsx`, `components/TopChannels.tsx`, `components/ModerationHeatmap.tsx`,
|
||||||
|
`components/CoverageTiles.tsx`, `components/ChannelCultureGlossary.tsx`,
|
||||||
|
`components/TermGlossary.tsx`, `components/EditHistory.tsx`.
|
||||||
|
- Wire into `app/(dashboard)/moderation/view.tsx` (grid col-span-2/3/5 as space allows)
|
||||||
|
and `app/(dashboard)/messages/view.tsx` (EditHistory panel) and new route pages
|
||||||
|
`app/(dashboard)/channels/page.tsx` + `app/(dashboard)/glossary/page.tsx` with
|
||||||
|
matching `view.tsx` (follow existing page→view SSR pattern; check `app/(dashboard)/dashboard/page.tsx`).
|
||||||
|
- Export CSV buttons reuse `lib/csv.ts` `downloadCsv` (client-side) for domains/channels/edits.
|
||||||
|
|
||||||
|
### Gateway (#15 Weekly Digest)
|
||||||
|
- Add a cron/interval in `services/discord-gateway` (check existing scheduler pattern —
|
||||||
|
search `setInterval`/`cron` in `src`). On a 7-day cadence, query backend oRPC
|
||||||
|
(`dashboard.activity`, `moderation.trends`, `moderation.topChannels`) — OR compute
|
||||||
|
directly via a shared repository — and post a formatted summary to the monitor guild
|
||||||
|
channel (via existing `discordClient.channels.send` helper). Fully automatic, no UI.
|
||||||
|
|
||||||
|
## Files touched (summary)
|
||||||
|
- backend: `modules/moderation/{repository,service}.ts`, `modules/dashboard/{repository,service}.ts`,
|
||||||
|
`modules/messages/{repository,service}.ts`, `orpc/router.ts`, `shared/index.ts` (if new tables),
|
||||||
|
`lib/types/*` (FE)
|
||||||
|
- frontend: `lib/api/*`, `lib/types/*`, `hooks/*`, `components/*`, `app/(dashboard)/*`
|
||||||
|
- gateway: new digest scheduler + (none if reuse backend) maybe `shared/redis-channels.ts`
|
||||||
|
|
||||||
|
## Constraints / pitfalls (from gmw-ops skill)
|
||||||
|
- `created_at` is bigint epoch-MS — compare with `<`/`>`, do NOT divide by 1000 in SQL.
|
||||||
|
- Pure SVG only — frontend has ZERO chart libs.
|
||||||
|
- `Badge` Tone = signal|amber|vermilion|neutral (no "rose").
|
||||||
|
- Frontend WS import is `@/lib/ws/context`; method `on` not `subscribe`.
|
||||||
|
- Commit author `asepharyana`, no Co-Authored-By.
|
||||||
|
- Rebuild `dist/` after gateway changes; apply drizzle migrations manually.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Per service: `pnpm typecheck && pnpm lint && pnpm build` green.
|
||||||
|
- Gateway: `pnpm test` (117+ pass).
|
||||||
|
- Live: `moderation/stats` WS returns data (proves adapter); new procedures registered
|
||||||
|
(typecheck = proof). `systemctl show` new ActiveEnterTimestamp after deploy.
|
||||||
|
- DB: confirm `channel_cultures`/`term_glossary_cache`/`message_edits`/`ai_analysis_runs`
|
||||||
|
have rows before relying on them (some may be empty → components handle empty state).
|
||||||
|
|
||||||
|
## Execution order
|
||||||
|
1. Bug fix dashboard.repository (reputation JOIN) — deploy-safe.
|
||||||
|
2. Backend repositories + service + router (#7,#8,#9,#14 dashboard; #11,#12; #13).
|
||||||
|
3. FE types + api + hooks + components + wire (#7,#8,#9,#10,#11,#12,#13,#14).
|
||||||
|
4. Gateway #15 digest (if scheduler exists) — verify via log, not UI.
|
||||||
|
5. Build/lint all 3 services; commit; push; monitor CI; apply migrations; verify live.
|
||||||
@@ -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": {
|
"suspicious": {
|
||||||
"noUnknownAtRules": "off",
|
"noUnknownAtRules": "off",
|
||||||
"useIterableCallbackReturn": "off",
|
"useIterableCallbackReturn": "off",
|
||||||
"noArrayIndexKey": "warn"
|
"noArrayIndexKey": "off",
|
||||||
|
"noExplicitAny": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"useSemanticElements": "off",
|
"useSemanticElements": "off",
|
||||||
"useButtonType": "off",
|
"useButtonType": "off",
|
||||||
"noAutofocus": "off"
|
"noAutofocus": "off",
|
||||||
|
"useMediaCaption": "off",
|
||||||
|
"noStaticElementInteractions": "off"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noImgElement": "warn"
|
"noImgElement": "warn"
|
||||||
@@ -50,7 +53,10 @@
|
|||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"noInvalidUseBeforeDeclaration": "off",
|
"noInvalidUseBeforeDeclaration": "off",
|
||||||
"noUnusedFunctionParameters": "warn"
|
"noUnusedFunctionParameters": "warn",
|
||||||
|
"noUnusedVariables": "warn",
|
||||||
|
"noUnusedImports": "warn",
|
||||||
|
"noUnusedPrivateClassMembers": "warn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
|
|||||||
@@ -7,10 +7,32 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
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 ----
|
# ---- Shared build tools ----
|
||||||
nodejs = pkgs.nodejs_22;
|
nodejs = pkgs.nodejs_22;
|
||||||
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
|
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
|
||||||
@@ -26,8 +48,8 @@
|
|||||||
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
|
||||||
|
|
||||||
# pnpm uses node-gyp for native addons — provide build tools
|
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||||
export npm_config_build_from_source=true
|
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||||
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
|
||||||
|
|
||||||
@@ -37,6 +59,36 @@
|
|||||||
pnpm rebuild 2>&1 || true
|
pnpm rebuild 2>&1 || true
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Shrink the shipped node_modules to production deps only. The full
|
||||||
|
# install's .pnpm virtual store carries dev-only packages (biome,
|
||||||
|
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
|
||||||
|
# needed at runtime, so we delete every .pnpm dir that is not part of
|
||||||
|
# the resolved production graph (`pnpm list --prod`).
|
||||||
|
#
|
||||||
|
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
|
||||||
|
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
|
||||||
|
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
|
||||||
|
# are only reachable through it), silently breaking voice.
|
||||||
|
# Instead we keep the full install's symlink layout and only prune
|
||||||
|
# orphaned package dirs + broken symlinks.
|
||||||
|
# Must run AFTER tsc (typescript is a devDep) and after native builds.
|
||||||
|
pruneProd = ''
|
||||||
|
echo "=== Pruning devDependencies (production-only node_modules) ==="
|
||||||
|
pnpm list --prod --depth 999 --parseable 2>/dev/null \
|
||||||
|
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
|
||||||
|
( cd node_modules/.pnpm \
|
||||||
|
&& for d in */; do \
|
||||||
|
d="''${d%/}"; \
|
||||||
|
[ "$d" = "node_modules" ] && continue; \
|
||||||
|
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
||||||
|
done ) || true
|
||||||
|
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
||||||
|
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
||||||
|
# which would otherwise fail the fixupPhase.
|
||||||
|
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
|
||||||
|
du -sh node_modules
|
||||||
|
'';
|
||||||
|
|
||||||
# ---- Backend ----
|
# ---- Backend ----
|
||||||
backend = pkgs.stdenv.mkDerivation {
|
backend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-backend";
|
pname = "gmw-backend";
|
||||||
@@ -49,33 +101,10 @@
|
|||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Compiling TypeScript ==="
|
echo "=== Compiling TypeScript ==="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
let count = 0;
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name.endsWith('.js')) {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
|
||||||
const n = c.replace(pat, (m, p1) => {
|
|
||||||
const target = path.join('dist', p1) + '.js';
|
|
||||||
const rel = path.relative(path.dirname(p), target);
|
|
||||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
|
||||||
});
|
|
||||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('dist');
|
|
||||||
console.log('Fixed ' + count + ' files');
|
|
||||||
"
|
|
||||||
echo "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'';
|
'' + pruneProd;
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/lib/gmw-backend
|
mkdir -p $out/lib/gmw-backend
|
||||||
@@ -105,43 +134,55 @@ WRAPPER
|
|||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
nodejs pnpm
|
nodejs pnpm
|
||||||
pkgs.python3 pkgs.gnumake pkgs.gcc
|
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
|
||||||
pkgs.rustc pkgs.cargo
|
pkgs.rustc pkgs.cargo
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
|
pkgs.openssl.dev
|
||||||
|
pkgs.git # for any FetchContent-based deps during native builds
|
||||||
pkgs.cacert
|
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 + ''
|
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
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||||
node -e "
|
node scripts/fix-imports.mjs
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
let count = 0;
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const e of fs.readdirSync(dir, {withFileTypes: true})) {
|
|
||||||
const p = path.join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name.endsWith('.js')) {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g;
|
|
||||||
const n = c.replace(pat, (m, p1) => {
|
|
||||||
const target = path.join('dist', p1) + '.js';
|
|
||||||
const rel = path.relative(path.dirname(p), target);
|
|
||||||
return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"';
|
|
||||||
});
|
|
||||||
if (n !== c) { fs.writeFileSync(p, n); count++; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk('dist');
|
|
||||||
console.log('Fixed ' + count + ' files');
|
|
||||||
"
|
|
||||||
echo "=== Build complete ==="
|
echo "=== Build complete ==="
|
||||||
'';
|
'' + pruneProd;
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/lib/gmw-discord-gateway
|
mkdir -p $out/lib/gmw-discord-gateway
|
||||||
@@ -154,6 +195,7 @@ WRAPPER
|
|||||||
cat > $out/bin/gmw-discord-gateway << WRAPPER
|
cat > $out/bin/gmw-discord-gateway << WRAPPER
|
||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
cd $out/lib/gmw-discord-gateway
|
cd $out/lib/gmw-discord-gateway
|
||||||
|
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||||
exec ${nodejs}/bin/node dist/index.js
|
exec ${nodejs}/bin/node dist/index.js
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x $out/bin/gmw-discord-gateway
|
chmod +x $out/bin/gmw-discord-gateway
|
||||||
@@ -165,39 +207,57 @@ WRAPPER
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ---- Frontend (Next.js static export) ----
|
# ---- Frontend (Next.js SSR standalone) ----
|
||||||
frontend = pkgs.stdenv.mkDerivation {
|
frontend = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-frontend";
|
pname = "gmw-frontend";
|
||||||
version = "1.0.0";
|
version = "1.0.0";
|
||||||
|
|
||||||
src = ./services/frontend;
|
src = frontendSrc;
|
||||||
|
|
||||||
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
|
||||||
|
|
||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
echo "=== Building Next.js static export ==="
|
echo "=== Building Next.js SSR (standalone) ==="
|
||||||
# Build args are provided as env vars
|
|
||||||
export NEXT_TELEMETRY_DISABLED=1
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
export GMW_BACKEND_URL=http://127.0.0.1:4001
|
||||||
npx next build 2>&1
|
npx next build 2>&1
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase = ''
|
||||||
mkdir -p $out/share/gmw-frontend
|
echo "=== Packaging standalone server ==="
|
||||||
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
|
mkdir -p $out/lib/gmw-frontend/standalone
|
||||||
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
|
# The standalone server bundles its own minimal node_modules but
|
||||||
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
|
# 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
|
# Remove dangling symlinks left by pnpm's hoisted .pnpm layout
|
||||||
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
|
# (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 = {
|
meta = {
|
||||||
description = "GMW Frontend — Next.js static dashboard";
|
description = "GMW Frontend — Next.js SSR dashboard";
|
||||||
platforms = pkgs.lib.platforms.linux;
|
platforms = pkgs.lib.platforms.linux;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ---- Proxy (nginx serving frontend) ----
|
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
|
||||||
proxy = pkgs.stdenv.mkDerivation {
|
proxy = pkgs.stdenv.mkDerivation {
|
||||||
pname = "gmw-proxy";
|
pname = "gmw-proxy";
|
||||||
version = "1.0.0";
|
version = "1.0.0";
|
||||||
@@ -212,11 +272,10 @@ WRAPPER
|
|||||||
mkdir -p $out/bin $out/etc $out/share
|
mkdir -p $out/bin $out/etc $out/share
|
||||||
|
|
||||||
# Substitute placeholders in nginx template
|
# Substitute placeholders in nginx template
|
||||||
sed \
|
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
||||||
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
|
-e "s|@NEXT_PORT@|4017|g" \
|
||||||
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
|
${./infra/nix/nginx.conf.template} \
|
||||||
${./infra/nix/nginx.conf.template} \
|
> $out/etc/nginx.conf
|
||||||
> $out/etc/nginx.conf
|
|
||||||
|
|
||||||
cat > $out/bin/gmw-proxy << WRAPPER
|
cat > $out/bin/gmw-proxy << WRAPPER
|
||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
@@ -226,7 +285,7 @@ WRAPPER
|
|||||||
'';
|
'';
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "GMW Proxy — nginx serving frontend";
|
description = "GMW Proxy — nginx -> Next.js + backend";
|
||||||
platforms = pkgs.lib.platforms.linux;
|
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 ./
|
COPY --from=builder --chown=node:node /build/package.json ./
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 4001
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
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"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
WEBSERVER_PORT: 3000
|
WEBSERVER_PORT: 4001
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4001/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
|||||||
@@ -11,28 +11,44 @@ http {
|
|||||||
'' close;
|
'' 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 {
|
server {
|
||||||
listen 8080;
|
listen 4009;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
# Use relative redirects (Location: /dashboard/) instead of absolute
|
# Use relative redirects (Location: /dashboard/) instead of absolute
|
||||||
# URLs that leak the internal listen port (8080) through Traefik.
|
# URLs that leak the internal listen port (4009) through the reverse proxy.
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# ── Backend REST ───────────────────────────────────────────────
|
||||||
location ^~ /api {
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
|
||||||
location ^~ /ws {
|
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_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
@@ -45,16 +61,49 @@ http {
|
|||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /assets/ {
|
# ── Backend oRPC (structured data RPCs over WebSocket + HTTP POST)
|
||||||
root @FRONTEND_ROOT@;
|
# 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;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Everything else → Next.js server (SSR) ──
|
||||||
location / {
|
location / {
|
||||||
root @FRONTEND_ROOT@;
|
proxy_pass http://gmw_next$uri$is_args$args;
|
||||||
index index.html;
|
proxy_http_version 1.1;
|
||||||
try_files $uri $uri/ /index.html;
|
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
|
-- 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;
|
BEGIN;
|
||||||
|
|
||||||
|
|||||||
@@ -225,21 +225,21 @@ All config via environment variables (`.env`), validated with Zod in `shared/con
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
# Server
|
# Server
|
||||||
WEBSERVER_PORT=3001
|
WEBSERVER_PORT=4001
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://user:pass@localhost:5432/discord_moderation
|
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/discord_moderation
|
||||||
# OR
|
# OR
|
||||||
DATABASE_HOST=localhost
|
DATABASE_HOST=100.121.180.82
|
||||||
DATABASE_PORT=5432
|
DATABASE_PORT=6432
|
||||||
DATABASE_NAME=discord_moderation
|
DATABASE_NAME=discord_moderation
|
||||||
DATABASE_USER=postgres
|
DATABASE_USER=postgres
|
||||||
DATABASE_PASSWORD=secret
|
DATABASE_PASSWORD=secret
|
||||||
|
|
||||||
# Redis (optional, for pub/sub)
|
# Redis (optional, for pub/sub)
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://100.121.180.82:6379
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
MONITOR_GUILD_ID=123456789
|
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
|
2. **Implement repository queries** for each module using Drizzle ORM
|
||||||
3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener
|
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)
|
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
|
6. **Write integration tests** for full request flow
|
||||||
|
|
||||||
## Circular Dependency Check
|
## Circular Dependency Check
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
|
"@orpc/server": "1.15.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
@@ -31,10 +32,10 @@
|
|||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^25.9.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.22.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2735
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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.
|
* 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";
|
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) {
|
async function api(path: string, init?: RequestInit) {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
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, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
@@ -6,19 +7,18 @@ import express, {
|
|||||||
type Response,
|
type Response,
|
||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createConfigRouter } from "../modules/config/index.js";
|
|
||||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
|
||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
import { appRouter } from "../orpc/router";
|
||||||
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 { errorHandler } from "../shared/middlewares/index.js";
|
import { errorHandler } from "../shared/middlewares/index.js";
|
||||||
|
|
||||||
// Auth removed — dashboard is public
|
// Auth removed — dashboard is public.
|
||||||
|
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
|
||||||
|
// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO
|
||||||
|
// transports sharing the /trpc path:
|
||||||
|
// - WebSocket (browser live RPCs) — see orpc/ws.ts
|
||||||
|
// - HTTP POST (server-side / RSC fetch) — handled below
|
||||||
|
// Only infra endpoints (health, prometheus metrics) remain plain HTTP.
|
||||||
|
|
||||||
const logger = createChildLogger("http.app");
|
const logger = createChildLogger("http.app");
|
||||||
|
|
||||||
@@ -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.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
@@ -58,23 +58,38 @@ export function createHttpApp(): Express {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// All routes are public
|
// Infra-only HTTP endpoints
|
||||||
app.use("/api", createHealthRouter());
|
app.use("/api", createHealthRouter());
|
||||||
app.use("/api", createConfigRouter());
|
|
||||||
app.use("/api", createDashboardRouter());
|
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
|
||||||
app.use("/api", createMessagesRouter());
|
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
|
||||||
app.use("/api", createAnalysisRouter());
|
// response itself; if no procedure matched we fall through to the 404 below.
|
||||||
app.use("/api", createChatbotRouter());
|
const orpcHandler = new RPCHandler(appRouter, {
|
||||||
app.use("/api", createRecordingsRouter());
|
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
|
||||||
app.use("/api", createUiStateRouter());
|
});
|
||||||
app.use("/api", createMediaRouter());
|
|
||||||
app.use("/api", createVoiceRouter());
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.path.startsWith("/trpc")) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orpcHandler
|
||||||
|
.handle(req, res, { prefix: "/trpc", context: {} })
|
||||||
|
.then(({ matched }) => {
|
||||||
|
if (!matched) next();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.error({ err }, "oRPC HTTP handler failed");
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
error: "NOT_FOUND",
|
error: "NOT_FOUND",
|
||||||
message: "Endpoint not found",
|
message:
|
||||||
|
"Endpoint not found — data APIs are served over /trpc (WebSocket/HTTP)",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServer, type Server } from "node:http";
|
import { createServer, type Server } from "node:http";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { createORPCWebSocketServer } from "../orpc/ws.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import { initializeDatabase } from "../shared/database/index.js";
|
import { initializeDatabase } from "../shared/database/index.js";
|
||||||
import { startRedisBridge } from "../ws/redis-bridge.js";
|
import { startRedisBridge } from "../ws/redis-bridge.js";
|
||||||
@@ -16,8 +17,9 @@ export async function startHttpServer(): Promise<Server> {
|
|||||||
|
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
// Attach WebSocket server to the same HTTP server
|
// Attach WebSocket servers to the same HTTP server
|
||||||
createWebSocketServer(server);
|
createWebSocketServer(server); // /ws — voice PCM + gateway events
|
||||||
|
createORPCWebSocketServer(server); // /trpc — structured data RPCs
|
||||||
|
|
||||||
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
|
||||||
await startRedisBridge();
|
await startRedisBridge();
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ async function main() {
|
|||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
logger.info({ signal }, "Shutting down gracefully");
|
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 {
|
try {
|
||||||
// 1. Stop accepting new HTTP connections
|
// 1. Stop accepting new HTTP connections
|
||||||
if (httpServer) {
|
if (httpServer) {
|
||||||
@@ -54,9 +63,11 @@ async function shutdown(signal: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
logger.info("Graceful shutdown completed");
|
logger.info("Graceful shutdown completed");
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Error during graceful shutdown");
|
logger.error({ err }, "Error during graceful shutdown");
|
||||||
|
clearTimeout(forceExitTimer);
|
||||||
process.exit(1);
|
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 { and, desc, eq, ilike, type SQL } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import { pgMessagesTable } from "../../shared/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
import {
|
import {
|
||||||
type MappedMessage,
|
type MappedMessage,
|
||||||
mapMessageRow,
|
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 { desc, eq } from "drizzle-orm";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
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");
|
const logger = createChildLogger("chatbot.repository");
|
||||||
|
|
||||||
@@ -31,13 +31,6 @@ export interface ChatbotHistoryRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInsights {
|
|
||||||
total_messages: number;
|
|
||||||
active_users: number;
|
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatbotRepository {
|
export class ChatbotRepository {
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -83,56 +76,6 @@ export class ChatbotRepository {
|
|||||||
"Chat history cleared",
|
"Chat history cleared",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerInsights(
|
|
||||||
guildId?: string,
|
|
||||||
channelId?: string,
|
|
||||||
): Promise<ServerInsights> {
|
|
||||||
try {
|
|
||||||
const db = getDatabase();
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
|
||||||
}
|
|
||||||
if (channelId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [result] = await db
|
|
||||||
.select({
|
|
||||||
total_messages: sql<number>`COUNT(*)::int`,
|
|
||||||
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
|
||||||
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
|
||||||
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
|
||||||
})
|
|
||||||
.from(pgMessagesTable)
|
|
||||||
.where(where);
|
|
||||||
|
|
||||||
const insights = result ?? {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug({ guildId, channelId, insights }, "Server insights fetched");
|
|
||||||
return insights;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{ error, guildId, channelId },
|
|
||||||
"Failed to load server insights",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatbotRepository = new ChatbotRepository();
|
export const chatbotRepository = new ChatbotRepository();
|
||||||
|
|||||||
@@ -1,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,
|
SaveConversationInput,
|
||||||
} from "./chatbot.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { chatbotRepository } 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");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
@@ -16,22 +18,26 @@ class ChatbotService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, messageLength: message.length },
|
{ userId, messageLength: message.length, context },
|
||||||
"processMessage called",
|
"processMessage called",
|
||||||
);
|
);
|
||||||
const recentContext = await this.getRecentConversationContext(userId);
|
const recentContext = await this.getRecentConversationContext(userId);
|
||||||
const serverInsights = await chatbotRepository.getServerInsights(
|
// Scope the agent to the server/channel the user is chatting in. We no
|
||||||
context?.guildId,
|
// longer bake server stats into the prompt — the model must pull current
|
||||||
context?.channelId,
|
// data via tools (see buildSystemPrompt), so it always answers from live
|
||||||
);
|
// numbers instead of a stale snapshot.
|
||||||
|
const scope = {
|
||||||
|
guildId: context?.guildId,
|
||||||
|
channelId: context?.channelId,
|
||||||
|
};
|
||||||
|
|
||||||
// Build LLM messages
|
const systemPrompt = this.buildSystemPrompt(scope);
|
||||||
const systemPrompt = this.buildSystemPrompt(serverInsights);
|
|
||||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
const conversationHistory = this.buildHistoryMessages(recentContext);
|
||||||
const llmResponse = await this.callLLM(
|
const llmResponse = await this.callLLM(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
message,
|
message,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
|
|
||||||
return llmResponse;
|
return llmResponse;
|
||||||
@@ -65,27 +71,29 @@ class ChatbotService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(insights: {
|
private buildSystemPrompt(scope: {
|
||||||
total_messages: number;
|
guildId?: string;
|
||||||
active_users: number;
|
channelId?: string;
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}): string {
|
}): string {
|
||||||
return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
|
const scopeLine = scope.guildId
|
||||||
|
? `- Scope: kamu menjawab soal server/guild id="${scope.guildId}"${scope.channelId ? `, channel id="${scope.channelId}"` : ""}.`
|
||||||
|
: "- Scope: tidak ada guild spesifik — jawab umum soal server ini.";
|
||||||
|
return `Kamu adalah chatbot Discord Watcher — temen ngobrol yang tau keadaan server, dan kamu PUNYA AKSES ke data server lewat tools.
|
||||||
|
|
||||||
Data server saat ini:
|
${scopeLine}
|
||||||
- Pesan: ${insights.total_messages}
|
|
||||||
- User aktif: ${insights.active_users}
|
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||||
- Flagged: ${insights.flagged}
|
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
||||||
- Warning: ${insights.warned}
|
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
|
||||||
|
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
|
||||||
|
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
||||||
|
|
||||||
Gaya ngobrol:
|
Gaya ngobrol:
|
||||||
- Santai, hangat, kayak ngobrol sama temen
|
- Santai, hangat, kayak ngobrol sama temen
|
||||||
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
||||||
- Sesekali pake emoji wajar aja, ga berlebihan
|
- Sesekali pake emoji wajar aja, ga berlebihan
|
||||||
- Kalo ditanya sesuatu yang kamu tau dari data server, jawab pake data itu
|
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||||
- Kalo ga tau atau ga nyambung, bilang aja terus tanya balik biar ngobrolnya jalan
|
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
|
||||||
- Jangan sebut "rule", "instruksi", "prompt" atau apapun soal cara kamu berpikir
|
|
||||||
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +113,7 @@ Gaya ngobrol:
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
history: Array<{ role: "user" | "assistant"; content: string }>,
|
history: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
|
scope: { guildId?: string; channelId?: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = config.AI_LLM_API_KEY;
|
const apiKey = config.AI_LLM_API_KEY;
|
||||||
const baseUrl = config.AI_LLM_BASE_URL;
|
const baseUrl = config.AI_LLM_BASE_URL;
|
||||||
@@ -118,41 +127,122 @@ Gaya ngobrol:
|
|||||||
try {
|
try {
|
||||||
const { default: axios } = await import("axios");
|
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 contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`;
|
||||||
|
|
||||||
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
|
// Seed conversation: prior turns + current question.
|
||||||
...history,
|
const messages: Array<
|
||||||
{ role: "user", content: contextPrefixed },
|
| { 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(
|
// ── Agentic tool loop ─────────────────────────────────────────
|
||||||
`${baseUrl}/chat/completions`,
|
const MAX_TOOL_ROUNDS = 4;
|
||||||
{
|
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
|
||||||
model,
|
const response = await axios.post(
|
||||||
messages,
|
`${baseUrl}/chat/completions`,
|
||||||
max_tokens: 500,
|
{
|
||||||
temperature: 0.4,
|
model,
|
||||||
},
|
messages,
|
||||||
{
|
tools,
|
||||||
headers: {
|
tool_choice: "auto",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
max_tokens: 600,
|
||||||
"Content-Type": "application/json",
|
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 {
|
// Parse the body into content + tool_calls. 9router may return either
|
||||||
choices?: Array<{ message?: { content?: string } }>;
|
// a single JSON object (stream:false honored) or SSE text (stream
|
||||||
};
|
// implied) — parseResponse handles both.
|
||||||
const content = result?.choices?.[0]?.message?.content?.trim();
|
const { content, toolCalls } = this.parseResponse(
|
||||||
|
response.data as string,
|
||||||
|
);
|
||||||
|
|
||||||
if (content) {
|
logger.debug(
|
||||||
return content;
|
{
|
||||||
|
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);
|
return this.fallbackResponse(userMessage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn({ error }, "LLM call failed, using fallback response");
|
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 {
|
private fallbackResponse(input: string): string {
|
||||||
const lower = input.toLowerCase();
|
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,13 +1,12 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import {
|
import {
|
||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
pgUserProfilesTable,
|
pgUserProfilesTable,
|
||||||
pgUserReputationsTable,
|
|
||||||
pgVoiceRecordingsTable,
|
pgVoiceRecordingsTable,
|
||||||
} from "../../shared/index.js";
|
} 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";
|
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||||
|
|
||||||
export class DashboardRepository {
|
export class DashboardRepository {
|
||||||
@@ -82,6 +81,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) {
|
async listUsers(query: ListUsersQuery) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
@@ -110,8 +155,7 @@ export class DashboardRepository {
|
|||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.last_message_at,
|
m.last_message_at
|
||||||
r.trust_score
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -124,7 +168,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY m.last_message_at DESC NULLS LAST
|
ORDER BY m.last_message_at DESC NULLS LAST
|
||||||
LIMIT ${limit + 1}
|
LIMIT ${limit + 1}
|
||||||
@@ -140,10 +183,6 @@ export class DashboardRepository {
|
|||||||
total_messages: Number(r.total_messages),
|
total_messages: Number(r.total_messages),
|
||||||
flagged_count: Number(r.flagged_count),
|
flagged_count: Number(r.flagged_count),
|
||||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||||
trust_score:
|
|
||||||
r.trust_score !== null && r.trust_score !== undefined
|
|
||||||
? Number(r.trust_score)
|
|
||||||
: null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||||
@@ -285,6 +324,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) {
|
async getUserDetail(userId: string) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
@@ -297,10 +430,7 @@ export class DashboardRepository {
|
|||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.clean_count,
|
m.clean_count,
|
||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
p.last_analyzed_at,
|
p.last_analyzed_at
|
||||||
r.trust_score,
|
|
||||||
r.clean_message_streak,
|
|
||||||
r.total_infractions
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
user_id,
|
user_id,
|
||||||
@@ -314,7 +444,6 @@ export class DashboardRepository {
|
|||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
) m
|
) m
|
||||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||||
@@ -341,13 +470,6 @@ export class DashboardRepository {
|
|||||||
last_analyzed_at: row.last_analyzed_at
|
last_analyzed_at: row.last_analyzed_at
|
||||||
? Number(row.last_analyzed_at)
|
? Number(row.last_analyzed_at)
|
||||||
: null,
|
: null,
|
||||||
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
|
|
||||||
clean_message_streak:
|
|
||||||
row.clean_message_streak != null
|
|
||||||
? Number(row.clean_message_streak)
|
|
||||||
: null,
|
|
||||||
total_infractions:
|
|
||||||
row.total_infractions != null ? Number(row.total_infractions) : null,
|
|
||||||
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
content: String(r.content),
|
content: String(r.content),
|
||||||
|
|||||||
@@ -1,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();
|
return dashboardRepository.getStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivity(days: number) {
|
||||||
|
logger.debug({ days }, "Fetching dashboard activity");
|
||||||
|
return dashboardRepository.getActivity(days);
|
||||||
|
}
|
||||||
|
|
||||||
async listUsers(query: ListUsersQuery) {
|
async listUsers(query: ListUsersQuery) {
|
||||||
logger.debug({ query }, "Listing dashboard users");
|
logger.debug({ query }, "Listing dashboard users");
|
||||||
return dashboardRepository.listUsers(query);
|
return dashboardRepository.listUsers(query);
|
||||||
@@ -38,6 +43,16 @@ export class DashboardService {
|
|||||||
logger.debug({ channelId }, "Fetching channel detail");
|
logger.debug({ channelId }, "Fetching channel detail");
|
||||||
return dashboardRepository.getChannelDetail(channelId);
|
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();
|
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 { sql } from "drizzle-orm";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("health.repository");
|
const logger = createChildLogger("health.repository");
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ export const moderationErrors = new Counter({
|
|||||||
labelNames: ["type"] as const,
|
labelNames: ["type"] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searxngCalls = new Counter({
|
export const webSearchCalls = new Counter({
|
||||||
name: "moderation_searxng_calls_total",
|
name: "moderation_websearch_calls_total",
|
||||||
help: "SearXNG search calls",
|
help: "Wikipedia web-search calls",
|
||||||
labelNames: ["status"] as const,
|
labelNames: ["status"] as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
|
export interface ChannelCultureRow {
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string | null;
|
||||||
|
channel_name: string | null;
|
||||||
|
culture_summary: string | null;
|
||||||
|
last_analyzed_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlossaryRow {
|
||||||
|
term: string;
|
||||||
|
definition: string;
|
||||||
|
source_url: string;
|
||||||
|
resolved_at: number;
|
||||||
|
hit_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditHistoryRow {
|
||||||
|
id: string;
|
||||||
|
message_id: string;
|
||||||
|
old_content: string;
|
||||||
|
edited_at: number;
|
||||||
|
channel_id: string | null;
|
||||||
|
channel_name: string | null;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KnowledgeRepository {
|
||||||
|
/** Public read-only channel culture glossary (AI-generated norms/slang). */
|
||||||
|
async listChannelCultures(limit = 50, search?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(c.channel_id ILIKE '%${search.replace(/'/g, "''")}%' OR c.culture_summary ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
c.channel_id,
|
||||||
|
c.guild_id,
|
||||||
|
COALESCE(NULLIF((
|
||||||
|
SELECT (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||||
|
FROM messages WHERE channel_id = c.channel_id AND metadata IS NOT NULL
|
||||||
|
LIMIT 1
|
||||||
|
), ''), c.channel_id) AS channel_name,
|
||||||
|
c.culture_summary,
|
||||||
|
c.last_analyzed_at
|
||||||
|
FROM channel_cultures c
|
||||||
|
${where}
|
||||||
|
ORDER BY c.last_analyzed_at DESC NULLS LAST
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channel_id: String(r.channel_id),
|
||||||
|
guild_id: r.guild_id ? String(r.guild_id) : null,
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
culture_summary: r.culture_summary ? String(r.culture_summary) : null,
|
||||||
|
last_analyzed_at: r.last_analyzed_at ? Number(r.last_analyzed_at) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public read-only term knowledge base (resolved via Wikipedia/SearXNG). */
|
||||||
|
async listGlossary(limit = 50, search?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(term ILIKE '%${search.replace(/'/g, "''")}%' OR definition ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT term, definition, source_url, resolved_at, hit_count
|
||||||
|
FROM term_glossary_cache
|
||||||
|
${where}
|
||||||
|
ORDER BY hit_count DESC, resolved_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
term: String(r.term),
|
||||||
|
definition: String(r.definition ?? ""),
|
||||||
|
source_url: r.source_url ? String(r.source_url) : "",
|
||||||
|
resolved_at: r.resolved_at ? Number(r.resolved_at) : 0,
|
||||||
|
hit_count: Number(r.hit_count ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeRepository = new KnowledgeRepository();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { knowledgeRepository } from "./knowledge.repository.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("knowledge.service");
|
||||||
|
|
||||||
|
export class KnowledgeService {
|
||||||
|
async listChannelCultures(limit = 50, search?: string) {
|
||||||
|
logger.debug({ limit, search }, "Listing channel cultures");
|
||||||
|
return knowledgeRepository.listChannelCultures(limit, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listGlossary(limit = 50, search?: string) {
|
||||||
|
logger.debug({ limit, search }, "Listing glossary terms");
|
||||||
|
return knowledgeRepository.listGlossary(limit, search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const knowledgeService = new KnowledgeService();
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMediaRouter } from "./media.routes.js";
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { 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"),
|
mode: z.enum(["music", "screen"]).default("music"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const mediaVolumeSchema = z.object({
|
export const mediaLoopSchema = z.object({
|
||||||
volume: z.number().min(0).max(1).default(1.0),
|
loop: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
|
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 {
|
import {
|
||||||
createChildLogger,
|
createChildLogger,
|
||||||
tryCommandThenFallback,
|
tryCommandThenFallback,
|
||||||
} from "../../shared/commandHelper.js";
|
} 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";
|
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("media.service");
|
const logger = createChildLogger("media.service");
|
||||||
@@ -28,7 +28,10 @@ export interface MediaItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
/** null/absent when idle; "music" | "screen" while a track is active. */
|
||||||
|
activeMode?: "music" | "screen" | null;
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
|
loop: boolean;
|
||||||
current: MediaItem | null;
|
current: MediaItem | null;
|
||||||
queue: MediaItem[];
|
queue: MediaItem[];
|
||||||
}
|
}
|
||||||
@@ -41,7 +44,9 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
|
|||||||
|
|
||||||
const DEFAULT_STATE: MediaState = {
|
const DEFAULT_STATE: MediaState = {
|
||||||
playing: false,
|
playing: false,
|
||||||
musicVolume: 1.0,
|
activeMode: null,
|
||||||
|
musicVolume: 0.3,
|
||||||
|
loop: false,
|
||||||
current: null,
|
current: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
};
|
};
|
||||||
@@ -56,9 +61,14 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
|
|||||||
rawPlaying === true ||
|
rawPlaying === true ||
|
||||||
rawPlaying === "playing" ||
|
rawPlaying === "playing" ||
|
||||||
rawPlaying === "buffering";
|
rawPlaying === "buffering";
|
||||||
|
const mode = raw.activeMode;
|
||||||
|
const activeMode: "music" | "screen" | null =
|
||||||
|
mode === "music" || mode === "screen" ? mode : null;
|
||||||
return {
|
return {
|
||||||
playing,
|
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,
|
current: (raw.current as MediaItem | null) ?? null,
|
||||||
queue: (raw.queue as MediaItem[]) ?? [],
|
queue: (raw.queue as MediaItem[]) ?? [],
|
||||||
};
|
};
|
||||||
@@ -99,7 +109,9 @@ export async function queue(
|
|||||||
() =>
|
() =>
|
||||||
publishCommand<MediaState>(
|
publishCommand<MediaState>(
|
||||||
COMMAND_MEDIA_QUEUE,
|
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,
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
() => readStatusFallback(),
|
() => 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> {
|
export async function setLoop(loop: boolean): Promise<MediaState> {
|
||||||
logger.info({ volume }, "setVolume called");
|
logger.info({ loop }, "setLoop called");
|
||||||
return tryCommandThenFallback(
|
return tryCommandThenFallback(
|
||||||
() =>
|
() =>
|
||||||
publishCommand<MediaState>(
|
publishCommand<MediaState>(
|
||||||
COMMAND_MEDIA_VOLUME,
|
COMMAND_MEDIA_LOOP,
|
||||||
{ volume },
|
{ loop },
|
||||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
() => readStatusFallback(),
|
() => 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 {
|
import {
|
||||||
and,
|
and,
|
||||||
desc,
|
desc,
|
||||||
@@ -9,13 +6,16 @@ import {
|
|||||||
isNull,
|
isNull,
|
||||||
like,
|
like,
|
||||||
lt,
|
lt,
|
||||||
ne,
|
|
||||||
notInArray,
|
notInArray,
|
||||||
or,
|
or,
|
||||||
type SQL,
|
type SQL,
|
||||||
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
import { getDatabase } from "../../shared/database/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 { mapMessageRow } from "../../shared/utils/messageMapper.js";
|
||||||
import type {
|
import type {
|
||||||
MessageCreate,
|
MessageCreate,
|
||||||
@@ -77,12 +77,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
@@ -115,6 +114,27 @@ export class MessagesRepository {
|
|||||||
return mapMessageRow(row as Record<string, unknown>);
|
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(
|
async findByChannel(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
query: MessageQuery,
|
query: MessageQuery,
|
||||||
@@ -129,12 +149,11 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
// Exclude spam threads (NULL-safe)
|
// Exclude spam threads (NULL-safe)
|
||||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
conditions.push(
|
const excludeThreads = or(
|
||||||
or(
|
isNull(pgMessagesTable.thread_id),
|
||||||
isNull(pgMessagesTable.thread_id),
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
|
||||||
)!,
|
|
||||||
);
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -153,6 +172,71 @@ export class MessagesRepository {
|
|||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async generator that yields messages ONE AT A TIME for WS streaming.
|
||||||
|
* Each `.next()` runs its own bounded DB query (limit+1) advancing on the
|
||||||
|
* `created_at` cursor, so memory stays flat and the caller can emit one WS
|
||||||
|
* frame per message (no 50-row batch). Stops when a page returns < limit.
|
||||||
|
*/
|
||||||
|
async *streamMany(
|
||||||
|
query: MessageQuery,
|
||||||
|
pageSize = 50,
|
||||||
|
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
|
||||||
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
|
if (query.guildId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
|
||||||
|
}
|
||||||
|
if (query.channelId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
|
||||||
|
}
|
||||||
|
if (query.userId) {
|
||||||
|
conditions.push(eq(pgMessagesTable.user_id, query.userId));
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||||
|
}
|
||||||
|
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||||
|
const excludeThreads = or(
|
||||||
|
isNull(pgMessagesTable.thread_id),
|
||||||
|
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
|
);
|
||||||
|
if (excludeThreads) conditions.push(excludeThreads);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
let cursor: string | undefined = query.cursor;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const pageConditions = where ? [where] : [];
|
||||||
|
if (cursor) {
|
||||||
|
pageConditions.push(lt(pgMessagesTable.created_at, Number(cursor)));
|
||||||
|
}
|
||||||
|
const pageWhere =
|
||||||
|
pageConditions.length > 0 ? and(...pageConditions) : undefined;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(pageWhere)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(pageSize + 1);
|
||||||
|
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const hasMore = rows.length > pageSize;
|
||||||
|
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
|
||||||
|
|
||||||
|
for (const r of pageRows) {
|
||||||
|
yield mapMessageRow(r as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMore) return;
|
||||||
|
cursor = String(rows[pageSize - 1].created_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: MessageCreate) {
|
async create(data: MessageCreate) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -223,58 +307,6 @@ export class MessagesRepository {
|
|||||||
return mapMessageRow(row as Record<string, unknown>);
|
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')).
|
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
|
||||||
* Optionally filtered by channelId, with configurable limit.
|
* Optionally filtered by channelId, with configurable limit.
|
||||||
@@ -347,12 +379,13 @@ export class MessagesRepository {
|
|||||||
like(pgAttachmentsTable.type, "image/%"),
|
like(pgAttachmentsTable.type, "image/%"),
|
||||||
// Exclude spam threads (NULL-safe for non-thread messages)
|
// Exclude spam threads (NULL-safe for non-thread messages)
|
||||||
...(EXCLUDED_THREAD_IDS.length > 0
|
...(EXCLUDED_THREAD_IDS.length > 0
|
||||||
? [
|
? (() => {
|
||||||
or(
|
const excludeThreads = or(
|
||||||
isNull(pgAttachmentsTable.thread_id),
|
isNull(pgAttachmentsTable.thread_id),
|
||||||
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||||
)!,
|
);
|
||||||
]
|
return excludeThreads ? [excludeThreads] : [];
|
||||||
|
})()
|
||||||
: []),
|
: []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -426,6 +459,69 @@ export class MessagesRepository {
|
|||||||
|
|
||||||
return { data: trimmed, nextCursor };
|
return { data: trimmed, nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-hour message volume for the last `days` days, grouped by channel.
|
||||||
|
* Powers the public Activity Heatmap (read-only, no write scope).
|
||||||
|
* Returns a flat list of { channel_id, hour (0-23), count } buckets.
|
||||||
|
*/
|
||||||
|
async getActivity(days = 30) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT channel_id,
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS c
|
||||||
|
FROM messages
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY channel_id, hour
|
||||||
|
ORDER BY channel_id, hour
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channelId: String(r.channel_id ?? "unknown"),
|
||||||
|
hour: Number(r.hour ?? 0),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recent message edits across the server (evasion-signal tracker).
|
||||||
|
* Public, read-only. Joins message_edits → messages for context.
|
||||||
|
*/
|
||||||
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const where = channelId
|
||||||
|
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
||||||
|
: "";
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
e.id,
|
||||||
|
e.message_id,
|
||||||
|
e.old_content,
|
||||||
|
e.edited_at,
|
||||||
|
m.channel_id,
|
||||||
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
|
m.username
|
||||||
|
FROM message_edits e
|
||||||
|
JOIN messages m ON m.id = e.message_id
|
||||||
|
${where}
|
||||||
|
ORDER BY e.edited_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
message_id: String(r.message_id),
|
||||||
|
old_content: r.old_content ? String(r.old_content) : "",
|
||||||
|
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||||
|
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesRepository = new MessagesRepository();
|
export const messagesRepository = new MessagesRepository();
|
||||||
|
|||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
@@ -38,13 +38,14 @@ export const messageUpdateSchema = z.object({
|
|||||||
aiConfidence: z.number().optional(),
|
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 MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||||
export 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 { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedQuery } from "./embed.js";
|
||||||
import { messagesRepository } from "./messages.repository.js";
|
import { messagesRepository } from "./messages.repository.js";
|
||||||
import type { MessageQuery } from "./messages.schema.js";
|
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
|
||||||
|
import { searchArchive } from "./qdrant.js";
|
||||||
|
|
||||||
const logger = createChildLogger("messages.service");
|
const logger = createChildLogger("messages.service");
|
||||||
|
|
||||||
@@ -15,6 +17,14 @@ export class MessagesService {
|
|||||||
return messagesRepository.findMany(query);
|
return messagesRepository.findMany(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream messages one at a time (no 50-row batch). The WS handler iterates
|
||||||
|
* this generator and emits one `message_snapshot` frame per message.
|
||||||
|
*/
|
||||||
|
streamMessages(query: MessageQuery, pageSize = 50) {
|
||||||
|
return messagesRepository.streamMany(query, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
async getMessagesByChannel(channelId: string, query: MessageQuery) {
|
||||||
if (!channelId) {
|
if (!channelId) {
|
||||||
throw new ValidationError("channelId is required");
|
throw new ValidationError("channelId is required");
|
||||||
@@ -34,7 +44,12 @@ export class MessagesService {
|
|||||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
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) {
|
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
|
||||||
@@ -58,15 +73,6 @@ export class MessagesService {
|
|||||||
return messagesRepository.getImageMessages(guildId, limit);
|
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(
|
async getReviewMessages(
|
||||||
channelId?: string,
|
channelId?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
@@ -75,24 +81,48 @@ export class MessagesService {
|
|||||||
return messagesRepository.getReviewMessages(channelId, limit);
|
return messagesRepository.getReviewMessages(channelId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reanalyzeErrorBatch(opts: {
|
/**
|
||||||
guildId?: string;
|
* Public, read-only semantic search over the persistent message archive.
|
||||||
channelId?: string;
|
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
|
||||||
messageIds?: string[];
|
* if embeddings/Qdrant are unavailable, returns an empty result set.
|
||||||
}) {
|
*/
|
||||||
if (
|
async semanticSearch(
|
||||||
!opts.guildId &&
|
input: SemanticSearchQuery,
|
||||||
!opts.channelId &&
|
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
|
||||||
(!opts.messageIds || opts.messageIds.length === 0)
|
const vector = await embedQuery(input.query);
|
||||||
) {
|
if (!vector) {
|
||||||
throw new ValidationError(
|
logger.debug(
|
||||||
"At least one of guildId, channelId, or messageIds[] is required",
|
{ query: input.query },
|
||||||
|
"semantic search skipped: no embedder",
|
||||||
);
|
);
|
||||||
|
return { results: [], nextCursor: null };
|
||||||
}
|
}
|
||||||
|
const hits = await searchArchive(vector, input.limit, 0.6);
|
||||||
|
const results = hits.map((h) => mapSearchHit(h));
|
||||||
|
return { results, nextCursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(opts, "Batch reanalyzing errored messages");
|
async getActivity(days = 30) {
|
||||||
return messagesRepository.reanalyzeErrorBatch(opts);
|
return messagesRepository.getActivity(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
|
logger.debug({ limit, channelId }, "Getting recent message edits");
|
||||||
|
return messagesRepository.getRecentEdits(limit, channelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||||
|
function mapSearchHit(hit: {
|
||||||
|
score: number;
|
||||||
|
payload: { text: string; content_hash?: string; analyzed_at: number };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
message_id: hit.payload.content_hash ?? null,
|
||||||
|
content: hit.payload.text,
|
||||||
|
score: hit.score,
|
||||||
|
created_at: hit.payload.analyzed_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const messagesService = new MessagesService();
|
export const messagesService = new MessagesService();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { config } from "@/shared/config/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages-qdrant");
|
||||||
|
|
||||||
|
export interface ArchiveHit {
|
||||||
|
score: number;
|
||||||
|
payload: {
|
||||||
|
text: string;
|
||||||
|
content_hash?: string;
|
||||||
|
analyzed_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ARCHIVE_COLLECTION =
|
||||||
|
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search the archive collection for the nearest vectors to `vector`. */
|
||||||
|
export async function searchArchive(
|
||||||
|
vector: number[],
|
||||||
|
limit: number,
|
||||||
|
scoreThreshold: number,
|
||||||
|
): Promise<ArchiveHit[]> {
|
||||||
|
if (!config.QDRANT_URL) return [];
|
||||||
|
try {
|
||||||
|
const json = (await request(
|
||||||
|
"POST",
|
||||||
|
`/collections/${ARCHIVE_COLLECTION}/points/search`,
|
||||||
|
{
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
score_threshold: scoreThreshold,
|
||||||
|
with_payload: true,
|
||||||
|
},
|
||||||
|
)) as {
|
||||||
|
result?: Array<{
|
||||||
|
score?: number;
|
||||||
|
payload?: ArchiveHit["payload"];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return (json.result ?? [])
|
||||||
|
.filter((h) => h.payload?.text)
|
||||||
|
.map((h) => ({
|
||||||
|
score: h.score ?? 0,
|
||||||
|
payload: h.payload as ArchiveHit["payload"],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"archive search failed",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate moderation trends over the last `days` days.
|
||||||
|
* - category counts (from the jsonb/text[] `categories` column, unnested)
|
||||||
|
* - severity distribution
|
||||||
|
* - action_type distribution
|
||||||
|
* Read-only; powers the public Toxic Topic Trends panel.
|
||||||
|
*/
|
||||||
|
async getTrends(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const cats = await db.execute(sql`
|
||||||
|
SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions a
|
||||||
|
WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != ''
|
||||||
|
GROUP BY cat
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT 15
|
||||||
|
`);
|
||||||
|
const catRows = (cats.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const sev = await db.execute(sql`
|
||||||
|
SELECT severity, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since} AND severity IS NOT NULL
|
||||||
|
GROUP BY severity
|
||||||
|
`);
|
||||||
|
const sevRows = (sev.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
const act = await db.execute(sql`
|
||||||
|
SELECT action_type, COUNT(*)::int AS c
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY action_type
|
||||||
|
ORDER BY c DESC
|
||||||
|
`);
|
||||||
|
const actRows = (act.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories: catRows.map((r) => ({
|
||||||
|
name: String(r.cat),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
severities: sevRows.map((r) => ({
|
||||||
|
level: String(r.severity),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
actions: actRows.map((r) => ({
|
||||||
|
type: String(r.action_type),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top flagged domains over the last `days` days.
|
||||||
|
* Extracts the host from any URL in `content`/`reason`/`evidence` and ranks
|
||||||
|
* by how often it appears in moderation actions. Powers the Scam Domain panel.
|
||||||
|
*/
|
||||||
|
async getTopFlaggedDomains(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT host, COUNT(*)::int AS c
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT a.id,
|
||||||
|
(regexp_matches(COALESCE(a.content,'') || ' ' || COALESCE(a.reason,'') || ' ' || COALESCE(a.evidence,''), 'https?://([^/\s?#]+)', 'g'))[1] AS host
|
||||||
|
FROM moderation_actions a
|
||||||
|
WHERE a.created_at >= ${since}
|
||||||
|
AND (a.content IS NOT NULL OR a.reason IS NOT NULL OR a.evidence IS NOT NULL)
|
||||||
|
) sub
|
||||||
|
WHERE host IS NOT NULL
|
||||||
|
GROUP BY host
|
||||||
|
ORDER BY c DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
domain: String(r.host).toLowerCase(),
|
||||||
|
count: Number(r.c ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top flagged channels over the last `days` days.
|
||||||
|
* Joins moderation_actions → messages to attribute each action to a channel.
|
||||||
|
* Powers the Top Flagged Channels panel.
|
||||||
|
*/
|
||||||
|
async getTopFlaggedChannels(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
m.channel_id,
|
||||||
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
|
COUNT(*)::int AS flagged_count
|
||||||
|
FROM moderation_actions a
|
||||||
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
|
WHERE a.created_at >= ${since} AND m.channel_id IS NOT NULL
|
||||||
|
GROUP BY m.channel_id, (m.metadata::jsonb -> 'channel' ->> 'channelName')
|
||||||
|
ORDER BY flagged_count DESC
|
||||||
|
LIMIT 15
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
channel_id: String(r.channel_id),
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
flagged_count: Number(r.flagged_count),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour-of-day distribution of moderation actions over the last `days` days.
|
||||||
|
* 24 rows (hour 0..23), with total + flagged-by-severity counts.
|
||||||
|
* Powers the Moderation Heatmap by Hour panel.
|
||||||
|
*/
|
||||||
|
async getHourlyModeration(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS total
|
||||||
|
FROM moderation_actions
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
const byHour = new Map<number, number>();
|
||||||
|
for (const r of rows) byHour.set(Number(r.hour), Number(r.total));
|
||||||
|
return Array.from({ length: 24 }, (_, h) => ({
|
||||||
|
hour: h,
|
||||||
|
total: byHour.get(h) ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moderation actions filtered to a single category (drill-down).
|
||||||
|
* Powers the Flag Category Drill-down panel.
|
||||||
|
*/
|
||||||
|
async getByCategory(days: number, category: string, limit = 50) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(`
|
||||||
|
SELECT
|
||||||
|
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
||||||
|
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
||||||
|
m.username, LEFT(m.content, 300) AS content
|
||||||
|
FROM moderation_actions a
|
||||||
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
|
WHERE a.created_at >= ${since}
|
||||||
|
AND a.categories IS NOT NULL
|
||||||
|
AND a.categories::jsonb @> ${JSON.stringify([category])}::jsonb
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: String(r.id ?? ""),
|
||||||
|
message_id: r.message_id ? String(r.message_id) : null,
|
||||||
|
user_id: r.user_id ? String(r.user_id) : null,
|
||||||
|
guild_id: String(r.guild_id ?? ""),
|
||||||
|
action_type: String(r.action_type ?? "unknown"),
|
||||||
|
reason: r.reason ? String(r.reason) : null,
|
||||||
|
status: String(r.status ?? "unknown"),
|
||||||
|
created_at: r.created_at ? Number(r.created_at) : null,
|
||||||
|
severity: r.severity ? String(r.severity) : null,
|
||||||
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
|
score: r.score != null ? Number(r.score) : null,
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
content: r.content ? String(r.content) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-moderation coverage over the last `days` days.
|
||||||
|
* Run completion rate from ai_analysis_runs — what fraction of analysis runs
|
||||||
|
* completed (vs failed/pending). Public "how much is automated" trust metric.
|
||||||
|
*/
|
||||||
|
async getCoverage(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT status, COUNT(*)::int AS c
|
||||||
|
FROM ai_analysis_runs
|
||||||
|
WHERE created_at >= ${since}
|
||||||
|
GROUP BY status
|
||||||
|
`);
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = String(r.status);
|
||||||
|
const c = Number(r.c ?? 0);
|
||||||
|
counts[s] = c;
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
const completed = counts.completed ?? 0;
|
||||||
|
const failed = counts.failed ?? 0;
|
||||||
|
const pending = (counts.pending ?? 0) + (counts.processing ?? 0);
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
pending,
|
||||||
|
coverage_rate:
|
||||||
|
total > 0 ? Number(((completed / total) * 100).toFixed(1)) : 0,
|
||||||
|
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationRepository = new ModerationRepository();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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() {
|
||||||
|
return moderationRepository.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrends(days = 30) {
|
||||||
|
return moderationRepository.getTrends(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopFlaggedDomains(days = 30) {
|
||||||
|
return moderationRepository.getTopFlaggedDomains(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopFlaggedChannels(days = 30) {
|
||||||
|
return moderationRepository.getTopFlaggedChannels(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHourlyModeration(days = 30) {
|
||||||
|
return moderationRepository.getHourlyModeration(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByCategory(days = 30, category: string) {
|
||||||
|
return moderationRepository.getByCategory(days, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCoverage(days = 30) {
|
||||||
|
return moderationRepository.getCoverage(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActions(query: ListModerationQuery) {
|
||||||
|
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 { and, desc, eq, lt, type SQL } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
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");
|
const logger = createChildLogger("recordings.service");
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ export interface RecordingRow {
|
|||||||
upload_error: string | null;
|
upload_error: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at: number | null;
|
uploaded_at: number | null;
|
||||||
duration_bytes: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedRecordings {
|
export interface PaginatedRecordings {
|
||||||
@@ -69,7 +68,6 @@ export class RecordingsService {
|
|||||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||||
created_at: pgVoiceRecordingsTable.created_at,
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||||
duration_bytes: pgVoiceRecordingsTable.size_bytes,
|
|
||||||
})
|
})
|
||||||
.from(pgVoiceRecordingsTable)
|
.from(pgVoiceRecordingsTable)
|
||||||
.where(where)
|
.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 { sql } from "drizzle-orm";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ui-state.service");
|
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 {
|
import {
|
||||||
COMMAND_GUILDS_LIST,
|
COMMAND_GUILDS_LIST,
|
||||||
COMMAND_GUILDS_TEXT_CHANNELS,
|
COMMAND_GUILDS_TEXT_CHANNELS,
|
||||||
@@ -8,13 +14,8 @@ import {
|
|||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
VOICE_STATUS_KEY,
|
VOICE_STATUS_KEY,
|
||||||
} from "../../shared/index.js";
|
} 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 { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
|
||||||
|
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
|
||||||
|
|
||||||
const logger = createChildLogger("voice.service");
|
const logger = createChildLogger("voice.service");
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ export interface Channel {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "voice" | "text";
|
type: "voice" | "text";
|
||||||
|
/** Whether the selfbot account can actually join this voice channel. */
|
||||||
|
joinable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuildVoiceEntry {
|
export interface GuildVoiceEntry {
|
||||||
@@ -43,6 +46,12 @@ export interface VoiceStatus {
|
|||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
activeChannelName: string | null;
|
activeChannelName: string | null;
|
||||||
connections: GuildVoiceEntry[];
|
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 = {
|
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||||
@@ -51,8 +60,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
|||||||
activeChannelId: null,
|
activeChannelId: null,
|
||||||
activeChannelName: null,
|
activeChannelName: null,
|
||||||
connections: [],
|
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.
|
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
|
||||||
* Attempts a Redis command first; on failure, falls back to the provided function.
|
* 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> {
|
function readVoiceStatusFallback(): Promise<VoiceStatus> {
|
||||||
return readRedisStatus(VOICE_STATUS_KEY).then(
|
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
|
||||||
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
|
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> {
|
export async function getVoiceStatus(): Promise<VoiceStatus> {
|
||||||
logger.debug("getVoiceStatus called");
|
logger.debug("getVoiceStatus called");
|
||||||
const cached = await readRedisStatus(VOICE_STATUS_KEY);
|
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,445 @@
|
|||||||
|
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 { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||||
|
import {
|
||||||
|
mediaLoopSchema,
|
||||||
|
mediaQueueSchema,
|
||||||
|
} from "../modules/media/media.schema";
|
||||||
|
import {
|
||||||
|
getStatus,
|
||||||
|
queue,
|
||||||
|
setLoop,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
} from "../modules/media/media.service";
|
||||||
|
import {
|
||||||
|
messageQuerySchema,
|
||||||
|
semanticSearchSchema,
|
||||||
|
} from "../modules/messages/messages.schema";
|
||||||
|
import { messagesService } from "../modules/messages/messages.service";
|
||||||
|
import { moderationService } from "../modules/moderation/moderation.service";
|
||||||
|
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||||
|
import { uiStateService } from "../modules/ui-state/ui-state.service";
|
||||||
|
import {
|
||||||
|
connectVoice,
|
||||||
|
disconnectVoice,
|
||||||
|
getGuilds,
|
||||||
|
getTextChannels,
|
||||||
|
getVoiceChannels,
|
||||||
|
getVoiceStatus,
|
||||||
|
} from "../modules/voice/voice.service";
|
||||||
|
import { config } from "../shared/config/index";
|
||||||
|
import { publishCommandNoReply } from "../shared/redis/index";
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────────
|
||||||
|
const dashboardRouter = {
|
||||||
|
stats: os.handler(() => dashboardService.getStats()),
|
||||||
|
activity: os
|
||||||
|
.input(
|
||||||
|
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => dashboardService.getActivity(input.days)),
|
||||||
|
users: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
dashboardService.listUsers({
|
||||||
|
limit: input.limit,
|
||||||
|
cursor: input.cursor,
|
||||||
|
search: input.search,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
userDetail: os
|
||||||
|
.input(z.object({ userId: z.string() }))
|
||||||
|
.handler(({ input }) => dashboardService.getUserDetail(input.userId)),
|
||||||
|
channels: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
search: z.string().optional(),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
dashboardService.listChannels({
|
||||||
|
limit: input.limit,
|
||||||
|
search: input.search,
|
||||||
|
guildId: input.guildId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
channelDetail: os
|
||||||
|
.input(z.object({ channelId: z.string() }))
|
||||||
|
.handler(({ input }) => dashboardService.getChannelDetail(input.channelId)),
|
||||||
|
reactions: os
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.handler(({ input }) => dashboardService.getTopReactions(input.limit)),
|
||||||
|
reactors: os
|
||||||
|
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
|
||||||
|
.handler(({ input }) => dashboardService.getTopReactors(input.limit)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Messages ─────────────────────────────────────────────────────
|
||||||
|
const messagesRouter = {
|
||||||
|
list: os
|
||||||
|
.input(messageQuerySchema)
|
||||||
|
.handler(({ input }) => messagesService.listMessages(input)),
|
||||||
|
byChannel: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getMessagesByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
detail: os
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.handler(({ input }) => messagesService.getMessageById(input.id)),
|
||||||
|
images: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
guildId: z.string(),
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getImageMessages(input.guildId, input.limit),
|
||||||
|
),
|
||||||
|
attachmentsByChannel: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
channelId: z.string(),
|
||||||
|
query: messageQuerySchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getAttachmentsByChannel(input.channelId, input.query),
|
||||||
|
),
|
||||||
|
review: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const rows = await messagesService.getReviewMessages(
|
||||||
|
input.channelId,
|
||||||
|
input.limit,
|
||||||
|
);
|
||||||
|
return { results: rows, limit: input.limit, cursor: null };
|
||||||
|
}),
|
||||||
|
// Public, read-only semantic search over the message archive.
|
||||||
|
semanticSearch: os
|
||||||
|
.input(semanticSearchSchema)
|
||||||
|
.handler(({ input }) => messagesService.semanticSearch(input)),
|
||||||
|
// Public, read-only activity heatmap data (per-hour volume by channel).
|
||||||
|
activity: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => messagesService.getActivity(input.days)),
|
||||||
|
// Public, read-only recent message edits (evasion tracker).
|
||||||
|
editHistory: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
messagesService.getRecentEdits(input.limit, input.channelId),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Moderation ───────────────────────────────────────────────────
|
||||||
|
const moderationRouter = {
|
||||||
|
stats: os.handler(() => moderationService.getStats()),
|
||||||
|
actions: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
status: z.string().optional(),
|
||||||
|
actionType: z.string().optional(),
|
||||||
|
cursor: z.coerce.number().int().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.listActions({
|
||||||
|
limit: input.limit,
|
||||||
|
status: input.status,
|
||||||
|
actionType: input.actionType,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
trends: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getTrends(input.days)),
|
||||||
|
// Flagged link / scam domain ranking (public Scam Domain panel).
|
||||||
|
topDomains: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getTopFlaggedDomains(input.days)),
|
||||||
|
// Top flagged channels (join moderation_actions → messages).
|
||||||
|
topChannels: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.getTopFlaggedChannels(input.days),
|
||||||
|
),
|
||||||
|
// Hour-of-day moderation distribution (heatmap by hour).
|
||||||
|
byHour: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getHourlyModeration(input.days)),
|
||||||
|
// Flag category drill-down (list actions for one category).
|
||||||
|
byCategory: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
category: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
moderationService.getByCategory(input.days, input.category),
|
||||||
|
),
|
||||||
|
// Auto-moderation coverage (analysis run completion rate).
|
||||||
|
coverage: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
days: z.coerce.number().int().positive().max(365).default(30),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Media ────────────────────────────────────────────────────────
|
||||||
|
const mediaRouter = {
|
||||||
|
status: os.handler(() => getStatus()),
|
||||||
|
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
|
||||||
|
await queue(input.source, input.mode);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
skip: os.handler(async () => {
|
||||||
|
await skip();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
stop: os.handler(async () => {
|
||||||
|
await stop();
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
|
||||||
|
await setLoop(input.loop);
|
||||||
|
return getStatus();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Voice ─────────────────────────────────────────────────────────
|
||||||
|
const voiceRouter = {
|
||||||
|
guilds: os.handler(() => getGuilds()),
|
||||||
|
textChannels: os
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.handler(({ input }) => getTextChannels(input.guildId)),
|
||||||
|
voiceChannels: os
|
||||||
|
.input(z.object({ guildId: z.string() }))
|
||||||
|
.handler(({ input }) => getVoiceChannels(input.guildId)),
|
||||||
|
status: os.handler(() => getVoiceStatus()),
|
||||||
|
connect: os
|
||||||
|
.input(z.object({ guildId: z.string(), channelId: z.string() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await connectVoice(input.guildId, input.channelId);
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
disconnect: os.handler(async () => {
|
||||||
|
await disconnectVoice();
|
||||||
|
return getVoiceStatus();
|
||||||
|
}),
|
||||||
|
command: os
|
||||||
|
.input(z.object({ command: z.string().min(1) }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
await publishCommandNoReply(input.command);
|
||||||
|
return { success: true, command: input.command };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Recordings ───────────────────────────────────────────────────
|
||||||
|
const recordingsRouter = {
|
||||||
|
list: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
recordingsService.getRecent(input.limit, {
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
cursor: input.cursor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
||||||
|
await recordingsService.deleteById(input.id);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
|
const analysisRouter = {
|
||||||
|
search: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
q: z.string().default(""),
|
||||||
|
channelId: z.string().optional(),
|
||||||
|
limit: z.coerce.number().int().positive().default(20),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
analysisService.search({
|
||||||
|
q: input.q,
|
||||||
|
channelId: input.channelId,
|
||||||
|
limit: input.limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Chatbot ───────────────────────────────────────────────────────
|
||||||
|
const chatbotRouter = {
|
||||||
|
chat: os
|
||||||
|
.input(
|
||||||
|
chatRequestSchema.extend({
|
||||||
|
// Per-device actor id; the old REST layer used an X-User-Id header.
|
||||||
|
// Anonymous sessions use a stable "anonymous" id.
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const response = await chatbotService.processMessage(
|
||||||
|
input.message,
|
||||||
|
input.context,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
await chatbotService.saveConversation({
|
||||||
|
userId,
|
||||||
|
userMessage: input.message,
|
||||||
|
botResponse: response,
|
||||||
|
context: input.context,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return { response, timestamp: new Date().toISOString() };
|
||||||
|
}),
|
||||||
|
history: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
const history = await chatbotService.getChatHistory(userId, input.limit);
|
||||||
|
return { history, total: history.length };
|
||||||
|
}),
|
||||||
|
clearHistory: os
|
||||||
|
.input(z.object({ userId: z.string().optional() }))
|
||||||
|
.handler(async ({ input }) => {
|
||||||
|
const userId = input.userId ?? "anonymous";
|
||||||
|
await chatbotService.clearChatHistory(userId);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Knowledge (public read-only culture glossary + term KB) ───────
|
||||||
|
const knowledgeRouter = {
|
||||||
|
channelCultures: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
knowledgeService.listChannelCultures(input.limit, input.search),
|
||||||
|
),
|
||||||
|
glossary: os
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
limit: z.coerce.number().int().positive().default(50),
|
||||||
|
search: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ input }) =>
|
||||||
|
knowledgeService.listGlossary(input.limit, input.search),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const configRouter = {
|
||||||
|
get: os.handler(() => ({
|
||||||
|
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||||
|
webserverPort: config.WEBSERVER_PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
|
||||||
|
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
|
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
|
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
|
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
||||||
|
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||||
|
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||||
|
voiceGuildId: config.VOICE_GUILD_ID || null,
|
||||||
|
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||||
|
logLevel: config.LOG_LEVEL,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UI State ──────────────────────────────────────────────────────
|
||||||
|
const uiStateRouter = {
|
||||||
|
get: os.handler(() => uiStateService.getState()),
|
||||||
|
update: os
|
||||||
|
.input(z.record(z.string(), z.unknown()))
|
||||||
|
.handler(({ input }) => uiStateService.updateState(input)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Root router ───────────────────────────────────────────────────
|
||||||
|
export const appRouter = {
|
||||||
|
dashboard: dashboardRouter,
|
||||||
|
messages: messagesRouter,
|
||||||
|
moderation: moderationRouter,
|
||||||
|
media: mediaRouter,
|
||||||
|
voice: voiceRouter,
|
||||||
|
recordings: recordingsRouter,
|
||||||
|
analysis: analysisRouter,
|
||||||
|
chatbot: chatbotRouter,
|
||||||
|
config: configRouter,
|
||||||
|
uiState: uiStateRouter,
|
||||||
|
knowledge: knowledgeRouter,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppRouter = typeof appRouter;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { IncomingMessage, Server } from "node:http";
|
||||||
|
import type { Duplex } from "node:stream";
|
||||||
|
import { onError } from "@orpc/server";
|
||||||
|
import { RPCHandler } from "@orpc/server/ws";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { appRouter } from "./router";
|
||||||
|
|
||||||
|
const logger = createChildLogger("orpc.ws");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the oRPC WebSocket handler to the shared HTTP server, on a path
|
||||||
|
* SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs
|
||||||
|
* (dashboard, messages, moderation, media, voice control, recordings,
|
||||||
|
* analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the
|
||||||
|
* `/ws` socket is left untouched for Discord PCM audio + gateway events.
|
||||||
|
*
|
||||||
|
* We use `noServer` + a manual `upgrade` router (instead of
|
||||||
|
* `new WebSocketServer({ server, path: "/trpc" })`) because two `ws` servers
|
||||||
|
* mounted with the `server` option on the SAME http.Server both register
|
||||||
|
* `upgrade` listeners, and `ws`'s path-guarded listener can reject (400) the
|
||||||
|
* other server's path. Routing the upgrade ourselves by URL keeps `/trpc`
|
||||||
|
* and `/ws` fully isolated.
|
||||||
|
*/
|
||||||
|
export function createORPCWebSocketServer(server: Server): WebSocketServer {
|
||||||
|
const handler = new RPCHandler(appRouter, {
|
||||||
|
interceptors: [
|
||||||
|
onError((error) => logger.error({ error }, "oRPC WS error")),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
||||||
|
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
handler.upgrade(ws, { context: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ path: "/trpc" }, "oRPC WebSocket server attached");
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
@@ -134,6 +134,7 @@ export const configSchema = z
|
|||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.default("https://9router.asepharyana.my.id/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||||
|
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
||||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -160,8 +161,6 @@ export const configSchema = z
|
|||||||
.default(30000)
|
.default(30000)
|
||||||
.describe("Timeout for individual LLM moderation calls"),
|
.describe("Timeout for individual LLM moderation calls"),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── AI Analysis Timing ──────────────────────────────────────────────
|
// ── AI Analysis Timing ──────────────────────────────────────────────
|
||||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
||||||
@@ -210,6 +209,11 @@ export const configSchema = z
|
|||||||
.default("https://api.openai.com/v1"),
|
.default("https://api.openai.com/v1"),
|
||||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
||||||
|
|
||||||
|
// ── Qdrant (message archive for semantic search) ──────────────────
|
||||||
|
QDRANT_URL: z.string().optional(),
|
||||||
|
QDRANT_API_KEY: z.string().optional(),
|
||||||
|
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
|
||||||
|
|
||||||
// ── Auto Delete ─────────────────────────────────────────────────────
|
// ── Auto Delete ─────────────────────────────────────────────────────
|
||||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createChildLogger } from "../logger/index.js";
|
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import type { Pool, PoolClient } from "pg";
|
import type { Pool, PoolClient } from "pg";
|
||||||
|
import { createChildLogger } from "../logger/index.js";
|
||||||
import { closePool, createPoolFromConfig } from "./pool.js";
|
import { closePool, createPoolFromConfig } from "./pool.js";
|
||||||
|
|
||||||
const logger = createChildLogger("database.init");
|
const logger = createChildLogger("database.init");
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export const pgMessagesTable = pgTable(
|
|||||||
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
enum: ["none", "monitor", "warn", "review", "delete", "escalate"],
|
||||||
}),
|
}),
|
||||||
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
ai_analyzed_at: pgBigint("ai_analyzed_at", { mode: "number" }),
|
||||||
|
ai_analysis_duration_ms: pgBigint("ai_analysis_duration_ms", {
|
||||||
|
mode: "number",
|
||||||
|
}),
|
||||||
ai_error: pgText("ai_error"),
|
ai_error: pgText("ai_error"),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
@@ -592,5 +595,4 @@ export type DbRetentionPolicyInsert =
|
|||||||
|
|
||||||
// Chatbot Messages
|
// Chatbot Messages
|
||||||
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||||
export type ChatbotMessageInsert =
|
export type ChatbotMessageInsert = typeof chatbotMessagesTable.$inferInsert;
|
||||||
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 { NextFunction, Request, Response } from "express";
|
||||||
import type { ZodSchema } from "zod";
|
import type { ZodSchema } from "zod";
|
||||||
|
import { AppError, ValidationError } from "@/shared/errors/index";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
const logger = createChildLogger("middleware");
|
const logger = createChildLogger("middleware");
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface MessageRecord {
|
|||||||
ai_confidence?: number | null;
|
ai_confidence?: number | null;
|
||||||
ai_recommended_action?: AIRecommendedAction | null;
|
ai_recommended_action?: AIRecommendedAction | null;
|
||||||
ai_analyzed_at?: number | null;
|
ai_analyzed_at?: number | null;
|
||||||
|
ai_analysis_duration_ms?: number | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const DISCORD_CHANNEL_TOPIC_UPDATED = "discord:channel:topic_updated";
|
|||||||
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
export const DISCORD_PRESENCE_UPDATED = "discord:presence:updated";
|
||||||
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
export const DISCORD_GUILD_MEMBER_ADDED = "discord:guild_member:added";
|
||||||
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
export const DISCORD_GUILD_MEMBER_REMOVED = "discord:guild_member:removed";
|
||||||
|
export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Command channels (backend -> discord-gateway)
|
// Command channels (backend -> discord-gateway)
|
||||||
@@ -62,6 +63,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue";
|
|||||||
export const COMMAND_MEDIA_SKIP = "media:skip";
|
export const COMMAND_MEDIA_SKIP = "media:skip";
|
||||||
export const COMMAND_MEDIA_STOP = "media:stop";
|
export const COMMAND_MEDIA_STOP = "media:stop";
|
||||||
export const COMMAND_MEDIA_VOLUME = "media:volume";
|
export const COMMAND_MEDIA_VOLUME = "media:volume";
|
||||||
|
export const COMMAND_MEDIA_LOOP = "media:loop";
|
||||||
export const COMMAND_MODERATION_ACTION = "moderation:action";
|
export const COMMAND_MODERATION_ACTION = "moderation:action";
|
||||||
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
||||||
|
|
||||||
@@ -125,4 +127,5 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
|
|||||||
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
|
||||||
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
|
||||||
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
|
||||||
|
[DISCORD_MODERATION_ACTION]: "moderation_action",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import Redis from "ioredis";
|
||||||
|
import { config } from "../config/index.js";
|
||||||
import {
|
import {
|
||||||
BACKEND_COMMAND,
|
BACKEND_COMMAND,
|
||||||
BACKEND_COMMAND_REPLY_PREFIX,
|
BACKEND_COMMAND_REPLY_PREFIX,
|
||||||
@@ -6,8 +8,6 @@ import {
|
|||||||
type CommandReply,
|
type CommandReply,
|
||||||
} from "../index.js";
|
} from "../index.js";
|
||||||
import { createChildLogger } from "../logger/index.js";
|
import { createChildLogger } from "../logger/index.js";
|
||||||
import Redis from "ioredis";
|
|
||||||
import { config } from "../config/index.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("redis.command-channel");
|
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_confidence: number | null;
|
||||||
ai_recommended_action: string | null;
|
ai_recommended_action: string | null;
|
||||||
ai_analyzed_at: number | null;
|
ai_analyzed_at: number | null;
|
||||||
|
ai_analysis_duration_ms: number | null;
|
||||||
ai_error: string | null;
|
ai_error: string | null;
|
||||||
is_reply: boolean | null;
|
is_reply: boolean | null;
|
||||||
is_forward: boolean | null;
|
is_forward: boolean | null;
|
||||||
@@ -58,6 +59,8 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
|||||||
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
ai_confidence: (row.ai_confidence as number | null) ?? null,
|
||||||
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
ai_recommended_action: (row.ai_recommended_action as string | null) ?? null,
|
||||||
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
ai_analyzed_at: (row.ai_analyzed_at as number | null) ?? null,
|
||||||
|
ai_analysis_duration_ms:
|
||||||
|
(row.ai_analysis_duration_ms as number | null) ?? null,
|
||||||
ai_error: (row.ai_error as string | null) ?? null,
|
ai_error: (row.ai_error as string | null) ?? null,
|
||||||
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
is_reply: row.is_reply === null ? null : Boolean(row.is_reply),
|
||||||
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
is_forward: row.is_forward === null ? null : Boolean(row.is_forward),
|
||||||
|
|||||||
@@ -1,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 Redis from "ioredis";
|
||||||
|
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
||||||
import { config } from "../shared/config/index.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";
|
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ws.redis-bridge");
|
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");
|
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||||
broadcastEvent(eventType, data);
|
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 { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/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";
|
import { setBroadcastFunctions } from "./broadcast.js";
|
||||||
|
|
||||||
const logger = createChildLogger("ws.server");
|
const logger = createChildLogger("ws.server");
|
||||||
@@ -66,6 +68,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, "Failed to send initial media_state");
|
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 {
|
export function closeWebSocketServer(): void {
|
||||||
@@ -80,9 +98,20 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
const frontendClients = new Set<WebSocket>();
|
const frontendClients = new Set<WebSocket>();
|
||||||
const gatewayClients = new Set<WebSocket>();
|
const gatewayClients = new Set<WebSocket>();
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
|
|
||||||
|
// Manual upgrade routing: without this, two `ws` servers bound to the same
|
||||||
|
// http.Server via the `server` option both register `upgrade` listeners and
|
||||||
|
// the path-guarded one destructively rejects the other's path (400). We own
|
||||||
|
// the upgrade event and dispatch by URL instead.
|
||||||
|
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||||
|
if (!req.url?.startsWith("/ws")) return;
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit("connection", ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Map-based dispatcher for JSON WebSocket message types
|
// Map-based dispatcher for JSON WebSocket message types
|
||||||
const jsonHandlers = new Map<string, MessageHandler>();
|
const jsonHandlers = new Map<string, MessageHandler>();
|
||||||
|
|
||||||
@@ -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) => {
|
wss.on("connection", (ws: WebSocket, req) => {
|
||||||
// Parse auth token from query string
|
// Parse auth token from query string
|
||||||
const rawUrl = req.url ?? "/";
|
const rawUrl = req.url ?? "/";
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tools } from "../src/modules/chatbot/chatbot.toolDefs.js";
|
||||||
|
|
||||||
|
const names = tools.map((t) => t.function.name);
|
||||||
|
|
||||||
|
describe("chatbot tool definitions", () => {
|
||||||
|
it("exposes a stable, non-empty tool set", () => {
|
||||||
|
expect(tools.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(new Set(names).size).toBe(names.length); // no dup names
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every tool declares a name, description, and object parameters", () => {
|
||||||
|
for (const t of tools) {
|
||||||
|
expect(t.type).toBe("function");
|
||||||
|
expect(typeof t.function.name).toBe("string");
|
||||||
|
expect(t.function.description.length).toBeGreaterThan(10);
|
||||||
|
expect(t.function.parameters.type).toBe("object");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required-only tools declare required args", () => {
|
||||||
|
const byName = new Map(tools.map((t) => [t.function.name, t]));
|
||||||
|
for (const [name, required] of [
|
||||||
|
["search_messages", "query"],
|
||||||
|
["get_user_messages", "userId"],
|
||||||
|
["get_user_profile", "userId"],
|
||||||
|
["get_user_reputation", "userId"],
|
||||||
|
["get_channel_culture", "channelId"],
|
||||||
|
["get_message_detail", "messageId"],
|
||||||
|
] as const) {
|
||||||
|
const tool = byName.get(name);
|
||||||
|
expect(tool, `missing tool ${name}`).toBeDefined();
|
||||||
|
expect(tool?.function.parameters.required).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the core server-watcher situations", () => {
|
||||||
|
for (const required of [
|
||||||
|
"get_server_stats",
|
||||||
|
"get_top_channels",
|
||||||
|
"get_recent_activity",
|
||||||
|
"get_top_flagged",
|
||||||
|
"search_messages",
|
||||||
|
"get_user_messages",
|
||||||
|
"get_user_profile",
|
||||||
|
"get_user_reputation",
|
||||||
|
"get_channel_culture",
|
||||||
|
"get_message_detail",
|
||||||
|
"get_message_reviews",
|
||||||
|
"get_voice_recordings",
|
||||||
|
"get_moderation_timeline",
|
||||||
|
"get_corrections",
|
||||||
|
]) {
|
||||||
|
expect(names, `missing ${required}`).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// ─── Shared Error Classes ────────────────────────────────────────────────────
|
// ─── Shared Error Classes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
AppError,
|
AppError,
|
||||||
ConfigError,
|
ConfigError,
|
||||||
@@ -6,7 +8,9 @@ import {
|
|||||||
NotFoundError,
|
NotFoundError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from "@bete/shared/errors";
|
} from "../src/shared/errors/index.js";
|
||||||
|
// ─── Backend middleware ──────────────────────────────────────────────────────
|
||||||
|
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
||||||
// ─── Shared utilities ─────────────────────────────────────────────────────────
|
// ─── Shared utilities ─────────────────────────────────────────────────────────
|
||||||
import {
|
import {
|
||||||
decodeCursor,
|
decodeCursor,
|
||||||
@@ -14,11 +18,7 @@ import {
|
|||||||
encodeCursor,
|
encodeCursor,
|
||||||
pageResult,
|
pageResult,
|
||||||
retryWithBackoff,
|
retryWithBackoff,
|
||||||
} from "@bete/shared/utils";
|
} from "../src/shared/utils/index.js";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// ─── Backend middleware ──────────────────────────────────────────────────────
|
|
||||||
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// 1. AppError / Error Hierarchy Tests
|
// 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";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||||
testTimeout: 15000,
|
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/
|
services/discord-gateway/
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point → initializeDiscordGateway()
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── bootstrap.ts # Discord Gateway initialization (no HTTP server)
|
│ │ ├── bootstrap.ts # Wires client, DB, Redis, workers, schedulers
|
||||||
│ │ └── shutdown.ts # Graceful shutdown handler
|
│ │ ├── shutdown.ts # Graceful shutdown (SIGINT/SIGTERM + transient errors)
|
||||||
|
│ │ └── retention.ts # Expired-record cleanup scheduler
|
||||||
│ ├── shared/
|
│ ├── shared/
|
||||||
│ │ ├── config/
|
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
|
||||||
│ │ │ └── config.ts # Environment configuration (Zod validated)
|
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
|
||||||
│ │ ├── database/
|
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
|
||||||
│ │ │ ├── schema.ts # Drizzle ORM schema
|
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
|
||||||
│ │ │ ├── drizzle.ts # Database connection
|
│ │ ├── logger/ # pino wrapper + createChildLogger()
|
||||||
│ │ │ ├── migrate.ts # Migration runner
|
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
|
||||||
│ │ │ └── voiceRecordingRepo.ts
|
│ │ ├── utils/ # retry, pagination
|
||||||
│ │ ├── errors/
|
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
|
||||||
│ │ │ └── errors.ts # Custom error classes
|
│ │ ├── uploader.ts # Shared attachment upload helper
|
||||||
│ │ ├── logger/
|
│ │ ├── redis-channels.ts # Redis channel-name constants
|
||||||
│ │ │ ├── logger.ts # Winston logger wrapper
|
│ │ └── moderation-types.ts # Shared AI analysis domain types
|
||||||
│ │ │ └── serialization.ts # Log value serialization
|
│ └── modules/
|
||||||
│ │ ├── utils/
|
│ ├── message-capture/ # Discord event listeners + DB store
|
||||||
│ │ │ └── retry.ts # Retry with backoff utility
|
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
|
||||||
│ │ └── discord/
|
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
|
||||||
│ │ └── clientOptions.ts # Discord.js client configuration
|
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
|
||||||
│ ├── modules/
|
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
|
||||||
│ │ ├── message-capture/ # Modular MVC: Message capture & storage
|
│ ├── attachment-upload/ # Download + (sharp) resize + upload
|
||||||
│ │ │ ├── messageCapture.ts # Controller: Discord event listeners
|
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||||
│ │ │ ├── messageStore.ts # Repository: Database operations
|
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
|
||||||
│ │ │ ├── messageMetadata.ts # Service: Message metadata extraction
|
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||||
│ │ │ ├── types.ts # Domain types
|
│ ├── channel-topic/ guild-member-events/
|
||||||
│ │ │ └── index.ts # Module exports
|
│ └── gateway-metrics/ # Prometheus /metrics endpoint (port 4016)
|
||||||
│ │ ├── ai-moderation/ # Modular MVC: AI analysis & moderation
|
```
|
||||||
│ │ │ ├── aiAnalyzer.ts # Controller: Analysis orchestration
|
|
||||||
│ │ │ ├── llmModerationClient.ts # Service: LLM API client
|
|
||||||
│ │ │ ├── aiAnalysisWorker.ts # Service: Worker pool management
|
|
||||||
│ │ │ ├── indonesianTextNormalizer.ts # Service: Text normalization
|
|
||||||
│ │ │ ├── moderationPrompt.ts # Service: Prompt generation
|
|
||||||
│ │ │ └── index.ts # Module exports
|
|
||||||
│ │ ├── voice-recording/ # Modular MVC: Voice recording & streaming
|
|
||||||
│ │ │ ├── voiceController.ts # Controller: Voice connection management
|
|
||||||
│ │ │ ├── recorder.ts # Service: Recording orchestration
|
|
||||||
│ │ │ ├── recorder/
|
|
||||||
│ │ │ │ ├── audioStream.ts # Service: Audio stream subscription
|
|
||||||
│ │ │ │ ├── decoder.ts # Service: Opus decoding
|
|
||||||
│ │ │ │ ├── segment.ts # Service: OGG segment rotation
|
|
||||||
│ │ │ │ ├── metadata.ts # Service: Segment metadata
|
|
||||||
│ │ │ │ ├── sessionRecording.ts # Service: Session management
|
|
||||||
│ │ │ │ └── uploader.ts # Service: Segment upload
|
|
||||||
│ │ │ └── index.ts # Module exports
|
|
||||||
│ │ ├── attachment-upload/ # Modular MVC: Attachment handling
|
|
||||||
│ │ │ ├── attachmentUploader.ts # Service: Upload orchestration
|
|
||||||
│ │ │ ├── imageResizer.ts # Service: Image resizing
|
|
||||||
│ │ │ └── index.ts # Module exports
|
|
||||||
│ │ └── event-broadcaster/ # Event-driven: Redis pub/sub
|
|
||||||
│ │ ├── eventBroadcaster.ts # Service: Event publishing
|
|
||||||
│ │ ├── eventTypes.ts # Domain: Event type definitions
|
|
||||||
│ │ └── index.ts # Module exports
|
|
||||||
│ ├── mock-crc.ts # CRC polyfill for discord.js
|
|
||||||
│ └── index.ts # Service entry point
|
|
||||||
├── package.json # Service dependencies
|
|
||||||
└── tsconfig.json # TypeScript configuration
|
|
||||||
|
|
||||||
## Architecture Patterns
|
## AI moderation pipeline (`ai-moderation/`)
|
||||||
|
|
||||||
### Modular MVC Structure
|
LLM-only judge — no regex/heuristic classification. One orchestrator call
|
||||||
Each module follows Controller-Service-Repository pattern:
|
handles a whole batch (text + media split internally, parallel paths).
|
||||||
- **Controller**: Discord event listeners (messageCapture, aiAnalyzer, voiceController)
|
|
||||||
- **Service**: Business logic (messageStore, llmModerationClient, recorder)
|
|
||||||
- **Repository**: Data access (messageStore, voiceRecordingRepo)
|
|
||||||
|
|
||||||
### Event-Driven Design
|
- `aiAnalyzer.ts` — public API: `queueMessageAnalysis`, `getAnalysisQueueStatus`,
|
||||||
- **Redis Pub/Sub**: All events published to Redis channels
|
`startPendingAIAnalysisWorker` (recovery worker + cache-prune).
|
||||||
- **Event Channels**:
|
- `batchScheduler.ts` — per-conversation debounce → `processBatch`.
|
||||||
- `discord:message:created` — New message captured
|
- `batchProcessor.ts` — batch lock/circuit-breaker, fans failed targets to
|
||||||
- `discord:message:updated` — Message edited
|
individual fallback.
|
||||||
- `discord:message:deleted` — Message deleted
|
- `individualFallbackProcessor.ts` — one-message-at-a-time retry path, own CB.
|
||||||
- `discord:message:analyzed` — AI analysis complete
|
- `conversationState.ts` / `circuitBreaker.ts` — per-conversation state,
|
||||||
- `discord:attachment:created` — Attachment detected
|
Piscina `workerPool`, `getConversationKey`.
|
||||||
- `discord:attachment:uploaded` — Attachment uploaded to storage
|
- `ai-analysis-worker.ts` — Piscina entry point (`batch` / `individual` jobs).
|
||||||
- `discord:voice:started` — Voice recording started
|
Runs `runModerationAnalysis` off the main thread.
|
||||||
- `discord:voice:stopped` — Voice recording stopped
|
- `moderationOrchestrator.ts` — exact-hash cache → batched semantic (Qdrant)
|
||||||
- `discord:voice:uploaded` — Voice segment uploaded
|
cache → LLM. Text and media paths run in parallel.
|
||||||
- `discord:analysis:queue_status` — Analysis queue status update
|
- `textBatchProcessor.ts` / `mediaBatchProcessor.ts` — actual LLM calls
|
||||||
|
(one call per sub-batch, not per message).
|
||||||
|
- `llmClient.ts` — central OpenAI-compatible chat client (streaming, retries,
|
||||||
|
thinking-disable injection). `visionAnalyzer.ts` / `mediaAnalysisClient.ts`
|
||||||
|
share the same router/base URL (different model alias for vision).
|
||||||
|
- `embeddingClient.ts` + `qdrantClient.ts` — semantic cache (one embed call +
|
||||||
|
one batched Qdrant search for all uncached targets).
|
||||||
|
- `textCacheStore.ts` / `channelCultureStore.ts` / `userProfileStore.ts` /
|
||||||
|
`userProfileStore.ts` — caches learned user profile summaries (optional).
|
||||||
|
|
||||||
### Shared Infrastructure
|
### Concurrency model
|
||||||
- **Config**: Zod-validated environment variables
|
|
||||||
- **Logger**: Winston logger with context support
|
|
||||||
- **Database**: Drizzle ORM with PostgreSQL
|
|
||||||
- **Errors**: Custom error classes with codes and status codes
|
|
||||||
- **Utils**: Retry logic with exponential backoff
|
|
||||||
|
|
||||||
### No HTTP Server
|
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
||||||
- Discord Gateway service is **event-driven only**
|
`llmClient.withLlmConcurrency`.
|
||||||
- No Express, WebSocket, or HTTP routes
|
- Piscina pool (`PISCINA_MAX_THREADS`, default 4) runs the heavy LLM work off
|
||||||
- All communication via Redis pub/sub
|
the event loop; **each worker thread initializes its own pg Pool** (min 0,
|
||||||
- Backend service consumes events and serves HTTP API
|
grows to `POSTGRES_POOL_MAX`). See "Memory & connections" below.
|
||||||
|
|
||||||
## Initialization Flow
|
## Memory & DB connections
|
||||||
|
|
||||||
1. Load environment config (Zod validation)
|
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
||||||
2. Initialize database connection
|
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
||||||
3. Run pending migrations
|
|
||||||
4. Create Discord client with optimized cache settings
|
|
||||||
5. Initialize Redis event broadcaster
|
|
||||||
6. Register Discord event listeners (messageCapture, aiAnalyzer)
|
|
||||||
7. Login to Discord
|
|
||||||
8. Listen for graceful shutdown signals (SIGINT, SIGTERM)
|
|
||||||
|
|
||||||
## Graceful Shutdown
|
`POSTGRES_POOL_MIN=0` (default). The gateway = main process + up to 4 Piscina
|
||||||
|
worker threads, each with its own pg Pool. With min:0 the pools stay empty
|
||||||
|
until a query runs and drop idle clients afterward, instead of holding
|
||||||
|
`(1 main + 4 workers) × 2 = 10` permanently-open idle connections against
|
||||||
|
PgBouncer. The pool still grows on demand up to `POSTGRES_POOL_MAX`.
|
||||||
|
|
||||||
On shutdown signal:
|
## Event channels (Redis pub/sub)
|
||||||
1. Close database connection
|
|
||||||
2. Disconnect from voice channels
|
|
||||||
3. Close Redis connection
|
|
||||||
4. Destroy Discord client
|
|
||||||
5. Exit process
|
|
||||||
|
|
||||||
## Dependencies
|
`discord:message:{created,updated,deleted,analyzed}`,
|
||||||
|
`discord:attachment:{created,uploaded}`,
|
||||||
|
`discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}`,
|
||||||
|
`discord:analysis:queue_status`,
|
||||||
|
`discord:reaction:{added,removed}`,
|
||||||
|
`discord:thread:{created,deleted,updated}`,
|
||||||
|
`discord:channel_topic:updated`,
|
||||||
|
`discord:presence:updated`,
|
||||||
|
`discord:guild_member:{added,removed}`.
|
||||||
|
See `src/shared/redis-channels.ts` for the canonical names.
|
||||||
|
|
||||||
**Core Discord**:
|
## Initialization flow
|
||||||
- discord.js-selfbot-v13
|
|
||||||
- @discordjs/voice
|
|
||||||
- @discordjs/opus
|
|
||||||
|
|
||||||
**Audio Processing**:
|
1. Validate env (Zod). Refuse to start if `AI_ANALYSIS_ENABLED` but no key.
|
||||||
- prism-media (Opus encoding/decoding)
|
2. `AUTO_MIGRATE_ON_STARTUP` → run pending Drizzle migrations.
|
||||||
- opusscript (Opus fallback)
|
3. `initializeDatabase()` (pg Pool, min 0).
|
||||||
- sharp (Image resizing)
|
4. Create discord.js-selfbot-v13 client; register listeners on `ready`.
|
||||||
|
5. Start `gmw-discord-gateway` metrics server (port `METRICS_PORT`, default 4016).
|
||||||
|
6. `client.login(token)`.
|
||||||
|
|
||||||
**Data & Config**:
|
## Graceful shutdown
|
||||||
- drizzle-orm (ORM)
|
|
||||||
- pg (PostgreSQL driver)
|
|
||||||
- zod (Config validation)
|
|
||||||
- ioredis (Redis client)
|
|
||||||
|
|
||||||
**Logging & Utilities**:
|
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
|
||||||
- winston (Structured logging)
|
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
|
||||||
- p-retry (Retry logic)
|
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
|
||||||
- p-limit (Concurrency limiting)
|
close command handler → close DB → destroy client → exit.
|
||||||
- piscina (Worker pool)
|
|
||||||
|
|
||||||
## Event Flow Example
|
## Observability
|
||||||
|
|
||||||
### Message Capture Flow
|
Prometheus scrapes `127.0.0.1:4016/metrics` (`bete_*` prefix). Collectors run
|
||||||
1. Discord emits `messageCreate` event
|
per-scrape and expose: process memory/uptime, and (when AI analysis is on) live
|
||||||
2. `messageCapture.ts` listener receives event
|
pipeline gauges — `ai_analysis_queued_conversations`,
|
||||||
3. Extract metadata (user, channel, content, timestamp)
|
`ai_analysis_active_batch_requests`, `ai_analysis_active_individual_requests`,
|
||||||
4. `messageStore.ts` inserts into database
|
`ai_analysis_individual_in_flight`, `ai_analysis_individual_circuit_breaker_active`,
|
||||||
5. `eventBroadcaster.messageCreated()` publishes to Redis
|
`ai_analysis_worker_threads`, `ai_analysis_worker_threads_active`.
|
||||||
6. Backend service subscribes to `discord:message:created` channel
|
|
||||||
7. Backend processes and stores in its own database
|
|
||||||
|
|
||||||
### Voice Recording Flow
|
## Key invariants (do not break)
|
||||||
1. `voiceController.connect()` joins voice channel
|
|
||||||
2. `recorder.ts` subscribes to user audio streams
|
|
||||||
3. For each speaking user:
|
|
||||||
- Create audio stream subscription
|
|
||||||
- Decode Opus packets to PCM
|
|
||||||
- Rotate OGG segments (5s default)
|
|
||||||
- Collect user metadata
|
|
||||||
4. On silence (3s):
|
|
||||||
- Finalize segment
|
|
||||||
- Create metadata JSON
|
|
||||||
- Upload segment to storage
|
|
||||||
- Publish `discord:voice:uploaded` event
|
|
||||||
5. Backend service receives event and indexes recording
|
|
||||||
|
|
||||||
## No Breaking Changes
|
- **LLM is the only judge.** Failed LLM → `status:"error"` + recovery retry.
|
||||||
|
Never reintroduce regex/heuristic content classification.
|
||||||
- Original `src/` remains untouched for now
|
- **Discord tokens are sanitized** (`discordTokens.ts`: `<:emoji:id>` →
|
||||||
- Discord Gateway is a **new service** in `services/discord-gateway/`
|
`[emoji:name]`, `<@id>` → `@user`, etc.) before content reaches the LLM, so
|
||||||
- Can run alongside existing monolith during transition
|
numeric snowflake IDs never trigger false positives.
|
||||||
- Backend service will consume Redis events
|
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
||||||
- Frontend continues to use Backend HTTP API
|
not N sequential round-trips. `ensureQdrantCollection` is memoized.
|
||||||
|
- **Streaming is mandatory** against the 9router base URL (non-stream waits for
|
||||||
|
the full body and times out). `llmClient` aggregates SSE chunks.
|
||||||
|
|||||||
@@ -1,408 +1,80 @@
|
|||||||
# Discord Gateway Service - Module Structure
|
# Discord Gateway Service — Module Structure
|
||||||
|
|
||||||
## Complete Directory Tree
|
> Kept as a compact module map. For the authoritative layout, design
|
||||||
|
> decisions, and invariants, see `ARCHITECTURE.md`. This file was rewritten
|
||||||
|
> on 2026-08-16 to fix stale references (`winston` → pino,
|
||||||
|
> `mock-crc.ts`/`indonesianTextNormalizer.ts` removed,
|
||||||
|
> `aiAnalysisWorker.ts` → `ai-analysis-worker.ts`,
|
||||||
|
> `llmModerationClient.ts` → `llmClient.ts`).
|
||||||
|
|
||||||
|
## Top-level
|
||||||
|
|
||||||
```
|
```
|
||||||
services/discord-gateway/
|
services/discord-gateway/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── app/
|
│ ├── index.ts # Entry point
|
||||||
│ │ ├── bootstrap.ts
|
│ ├── app/ # bootstrap, shutdown, retention
|
||||||
│ │ │ └── Initializes Discord client, database, Redis broadcaster
|
│ ├── shared/ # config, database, logger, errors, utils, discord, uploader
|
||||||
│ │ │ Registers event listeners, handles graceful shutdown
|
│ └── modules/
|
||||||
│ │ └── shutdown.ts
|
│ ├── message-capture/ # Discord listeners + DB store + metadata
|
||||||
│ │ └── Graceful shutdown handler for SIGINT/SIGTERM/exceptions
|
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
|
||||||
│ │
|
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
|
||||||
│ ├── shared/
|
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
|
||||||
│ │ ├── config/
|
│ ├── attachment-upload/ # Download + sharp resize + upload
|
||||||
│ │ │ └── config.ts
|
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
|
||||||
│ │ │ └── Zod-validated environment configuration
|
│ ├── command-handler/ # Backend→gateway Redis commands
|
||||||
│ │ │ - Discord token, database URL, Redis URL
|
│ ├── reaction-tracking/ thread-tracking/ user-presence/
|
||||||
│ │ │ - AI LLM settings, recording parameters
|
│ ├── channel-topic/ guild-member-events/
|
||||||
│ │ │ - Attachment upload settings, retention policies
|
│ └── gateway-metrics/ # Prometheus /metrics (port 4016)
|
||||||
│ │ │
|
├── tests/ # Vitest suites (129 tests)
|
||||||
│ │ ├── database/
|
├── drizzle/ # Drizzle migration SQL + journal
|
||||||
│ │ │ ├── schema.ts
|
├── ARCHITECTURE.md README.md package.json tsconfig.json vitest.config.ts
|
||||||
│ │ │ │ └── Drizzle ORM schema definitions
|
|
||||||
│ │ │ ├── drizzle.ts
|
|
||||||
│ │ │ │ └── PostgreSQL connection and initialization
|
|
||||||
│ │ │ ├── migrate.ts
|
|
||||||
│ │ │ │ └── Database migration runner
|
|
||||||
│ │ │ ├── migrateCli.ts
|
|
||||||
│ │ │ │ └── CLI for programmatic migrations
|
|
||||||
│ │ │ ├── voiceRecordingRepo.ts
|
|
||||||
│ │ │ │ └── Voice recording repository
|
|
||||||
│ │ │ └── migrations/
|
|
||||||
│ │ │ └── Database migration files
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── errors/
|
|
||||||
│ │ │ └── errors.ts
|
|
||||||
│ │ │ └── Custom error classes
|
|
||||||
│ │ │ - AppError (base)
|
|
||||||
│ │ │ - ConfigError
|
|
||||||
│ │ │ - AudioError
|
|
||||||
│ │ │ - VoiceConnectionError
|
|
||||||
│ │ │ - ValidationError
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── logger/
|
|
||||||
│ │ │ ├── logger.ts
|
|
||||||
│ │ │ │ └── Winston logger wrapper with context support
|
|
||||||
│ │ │ └── serialization.ts
|
|
||||||
│ │ │ └── Log value serialization utilities
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── utils/
|
|
||||||
│ │ │ └── retry.ts
|
|
||||||
│ │ │ └── Retry with exponential backoff utility
|
|
||||||
│ │ │
|
|
||||||
│ │ └── discord/
|
|
||||||
│ │ └── clientOptions.ts
|
|
||||||
│ │ └── Discord.js client configuration
|
|
||||||
│ │
|
|
||||||
│ ├── modules/
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── message-capture/
|
|
||||||
│ │ │ ├── messageCapture.ts
|
|
||||||
│ │ │ │ └── CONTROLLER: Discord event listeners
|
|
||||||
│ │ │ │ - messageCreate, messageUpdate, messageDelete
|
|
||||||
│ │ │ │ - Validates capture target, publishes events
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── messageStore.ts
|
|
||||||
│ │ │ │ └── REPOSITORY: Database CRUD operations
|
|
||||||
│ │ │ │ - upsertMessageForCapture
|
|
||||||
│ │ │ │ - updateMessageAsEdited
|
|
||||||
│ │ │ │ - updateMessageAsDeleted
|
|
||||||
│ │ │ │ - insertAttachment
|
|
||||||
│ │ │ │ - getMessageById
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── messageMetadata.ts
|
|
||||||
│ │ │ │ └── SERVICE: Message metadata extraction
|
|
||||||
│ │ │ │ - getMessageMetadata
|
|
||||||
│ │ │ │ - getMessageLocation
|
|
||||||
│ │ │ │ - getDisplayContent
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── types.ts
|
|
||||||
│ │ │ │ └── Domain types
|
|
||||||
│ │ │ │ - MessageRecord
|
|
||||||
│ │ │ │ - AttachmentRecord
|
|
||||||
│ │ │ │ - VoiceSegmentRecord
|
|
||||||
│ │ │ │ - AIStatus, AISeverity, AIRecommendedAction
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── index.ts
|
|
||||||
│ │ └── Module exports
|
|
||||||
│ │
|
|
||||||
│ │ ├── ai-moderation/
|
|
||||||
│ │ │ ├── aiAnalyzer.ts
|
|
||||||
│ │ │ │ └── CONTROLLER: Analysis orchestration
|
|
||||||
│ │ │ │ - startPendingAIAnalysisWorker
|
|
||||||
│ │ │ │ - queueMessageAnalysis
|
|
||||||
│ │ │ │ - Manages analysis queue and worker pool
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── llmModerationClient.ts
|
|
||||||
│ │ │ │ └── SERVICE: LLM API integration
|
|
||||||
│ │ │ │ - Calls LLM for text/image moderation
|
|
||||||
│ │ │ │ - Parses responses, handles errors
|
|
||||||
│ │ │ │ - Retry logic with backoff
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── aiAnalysisWorker.ts
|
|
||||||
│ │ │ │ └── SERVICE: Worker pool management
|
|
||||||
│ │ │ │ - Piscina worker pool for parallel analysis
|
|
||||||
│ │ │ │ - Conversation context batching
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── indonesianTextNormalizer.ts
|
|
||||||
│ │ │ │ └── SERVICE: Text preprocessing
|
|
||||||
│ │ │ │ - Normalize Indonesian text
|
|
||||||
│ │ │ │ - Handle diacritics, abbreviations
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── moderationPrompt.ts
|
|
||||||
│ │ │ │ └── SERVICE: Prompt generation
|
|
||||||
│ │ │ │ - Generate LLM prompts for moderation
|
|
||||||
│ │ │ │ - Include context and policy
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── index.ts
|
|
||||||
│ │ └── Module exports
|
|
||||||
│ │
|
|
||||||
│ │ ├── voice-recording/
|
|
||||||
│ │ │ ├── voiceController.ts
|
|
||||||
│ │ │ │ └── CONTROLLER: Voice connection management
|
|
||||||
│ │ │ │ - connect(guildId, channelId)
|
|
||||||
│ │ │ │ - disconnect()
|
|
||||||
│ │ │ │ - listGuilds(), listVoiceChannels()
|
|
||||||
│ │ │ │ - getStatus()
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── recorder.ts
|
|
||||||
│ │ │ │ └── SERVICE: Recording orchestration
|
|
||||||
│ │ │ │ - startRecording(client, channel)
|
|
||||||
│ │ │ │ - stopRecording(guildId)
|
|
||||||
│ │ │ │ - Manages active recording sessions
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── recorder/
|
|
||||||
│ │ │ │ ├── audioStream.ts
|
|
||||||
│ │ │ │ │ └── SERVICE: Audio stream subscription
|
|
||||||
│ │ │ │ │ - subscribeToAudioStream
|
|
||||||
│ │ │ │ │ - Opus packet handling
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ ├── decoder.ts
|
|
||||||
│ │ │ │ │ └── SERVICE: Opus decoding
|
|
||||||
│ │ │ │ │ - OpusDecoder class
|
|
||||||
│ │ │ │ │ - Decode Opus to PCM
|
|
||||||
│ │ │ │ │ - Rotation and cooldown logic
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ ├── segment.ts
|
|
||||||
│ │ │ │ │ └── SERVICE: OGG segment rotation
|
|
||||||
│ │ │ │ │ - SegmentManager class
|
|
||||||
│ │ │ │ │ - Rotate segments (5s default)
|
|
||||||
│ │ │ │ │ - Write OGG files
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ ├── metadata.ts
|
|
||||||
│ │ │ │ │ └── SERVICE: Segment metadata
|
|
||||||
│ │ │ │ │ - collectUserMetadata
|
|
||||||
│ │ │ │ │ - createSegmentMetadata
|
|
||||||
│ │ │ │ │ - User info, roles, timestamps
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ ├── sessionRecording.ts
|
|
||||||
│ │ │ │ │ └── SERVICE: Session management
|
|
||||||
│ │ │ │ │ - createRecordingSession
|
|
||||||
│ │ │ │ │ - finalizeRecordingSession
|
|
||||||
│ │ │ │ │ - Track active sessions
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ │ │ └── uploader.ts
|
|
||||||
│ │ │ │ └── SERVICE: Segment upload
|
|
||||||
│ │ │ │ - uploadRecordingSegment
|
|
||||||
│ │ │ │ - Upload to external storage
|
|
||||||
│ │ │ │ - Retry logic
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── index.ts
|
|
||||||
│ │ └── Module exports
|
|
||||||
│ │
|
|
||||||
│ │ ├── attachment-upload/
|
|
||||||
│ │ │ ├── attachmentUploader.ts
|
|
||||||
│ │ │ │ └── SERVICE: Upload orchestration
|
|
||||||
│ │ │ │ - processAttachmentUpload
|
|
||||||
│ │ │ │ - Download from Discord
|
|
||||||
│ │ │ │ - Upload to external storage
|
|
||||||
│ │ │ │ - Retry with backoff
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ ├── imageResizer.ts
|
|
||||||
│ │ │ │ └── SERVICE: Image processing
|
|
||||||
│ │ │ │ - resizeImage
|
|
||||||
│ │ │ │ - Resize to max dimension
|
|
||||||
│ │ │ │ - Preserve aspect ratio
|
|
||||||
│ │ │ │
|
|
||||||
│ │ │ └── index.ts
|
|
||||||
│ │ └── Module exports
|
|
||||||
│ │
|
|
||||||
│ │ └── event-broadcaster/
|
|
||||||
│ │ ├── eventBroadcaster.ts
|
|
||||||
│ │ │ └── SERVICE: Redis pub/sub publisher
|
|
||||||
│ │ │ - EventBroadcaster class
|
|
||||||
│ │ │ - RedisEventPublisher class
|
|
||||||
│ │ │ - Publish to Redis channels
|
|
||||||
│ │ │ - Methods:
|
|
||||||
│ │ │ - messageCreated()
|
|
||||||
│ │ │ - messageUpdated()
|
|
||||||
│ │ │ - messageDeleted()
|
|
||||||
│ │ │ - messageAnalyzed()
|
|
||||||
│ │ │ - attachmentCreated()
|
|
||||||
│ │ │ - attachmentUploaded()
|
|
||||||
│ │ │ - voiceRecordingStarted()
|
|
||||||
│ │ │ - voiceRecordingStopped()
|
|
||||||
│ │ │ - voiceRecordingUploaded()
|
|
||||||
│ │ │ - analysisQueueStatus()
|
|
||||||
│ │ │
|
|
||||||
│ │ ├── eventTypes.ts
|
|
||||||
│ │ │ └── Domain types
|
|
||||||
│ │ │ - DiscordGatewayEvent interface
|
|
||||||
│ │ │ - EventChannels constants
|
|
||||||
│ │ │ - Event channel names
|
|
||||||
│ │ │
|
|
||||||
│ │ └── index.ts
|
|
||||||
│ └── Module exports
|
|
||||||
│
|
|
||||||
│ ├── mock-crc.ts
|
|
||||||
│ │ └── CRC polyfill for discord.js compatibility
|
|
||||||
│ │
|
|
||||||
│ └── index.ts
|
|
||||||
│ └── Service entry point
|
|
||||||
│ - Initialize Discord Gateway
|
|
||||||
│ - Handle startup errors
|
|
||||||
│
|
|
||||||
├── ARCHITECTURE.md
|
|
||||||
│ └── Detailed architecture documentation
|
|
||||||
│
|
|
||||||
├── README.md
|
|
||||||
│ └── Complete service documentation
|
|
||||||
│
|
|
||||||
├── MODULE_STRUCTURE.md
|
|
||||||
│ └── This file - module structure reference
|
|
||||||
│
|
|
||||||
└── package.json
|
|
||||||
└── Service dependencies and scripts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module Responsibilities
|
## Module responsibilities (summary)
|
||||||
|
|
||||||
### message-capture
|
### message-capture
|
||||||
**Purpose**: Capture Discord messages (create, update, delete)
|
Captures `messageCreate`/`messageUpdate`/`messageDelete`, extracts metadata,
|
||||||
**Pattern**: Controller-Service-Repository
|
stores to Postgres, publishes to Redis. Controller–Service–Repository split:
|
||||||
- **Controller** (messageCapture.ts): Listens to Discord events
|
`messageCapture.ts` (listener) → `messageStore.ts` (DB) + `messageMetadata.ts`
|
||||||
- **Service** (messageMetadata.ts): Extracts metadata
|
(service).
|
||||||
- **Repository** (messageStore.ts): Database operations
|
|
||||||
- **Events Published**:
|
|
||||||
- `discord:message:created`
|
|
||||||
- `discord:message:updated`
|
|
||||||
- `discord:message:deleted`
|
|
||||||
|
|
||||||
### ai-moderation
|
### ai-moderation
|
||||||
**Purpose**: Analyze messages with LLM for moderation
|
LLM-only moderation. Entry: `aiAnalyzer.ts` (`queueMessageAnalysis`,
|
||||||
**Pattern**: Controller-Service-Service-Service
|
`startPendingAIAnalysisWorker`, `getAnalysisQueueStatus`). Scheduling:
|
||||||
- **Controller** (aiAnalyzer.ts): Orchestrates analysis workflow
|
`batchScheduler.ts` → `batchProcessor.ts` (batch lock + circuit breaker) →
|
||||||
- **Service** (llmModerationClient.ts): LLM API integration
|
`individualFallbackProcessor.ts` (per-message retry). Heavy work runs in the
|
||||||
- **Service** (aiAnalysisWorker.ts): Worker pool management
|
Piscina pool via `ai-analysis-worker.ts` (jobs `batch` / `individual`).
|
||||||
- **Service** (indonesianTextNormalizer.ts): Text preprocessing
|
Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched
|
||||||
- **Service** (moderationPrompt.ts): Prompt generation
|
semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
|
||||||
- **Events Published**:
|
(one LLM call per sub-batch), `llmClient.ts` (central streaming client),
|
||||||
- `discord:message:analyzed`
|
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
|
||||||
- `discord:analysis:queue_status`
|
`channelCultureStore.ts` / `userProfileStore.ts`.
|
||||||
|
|
||||||
### voice-recording
|
### voice-recording
|
||||||
**Purpose**: Record voice channel audio
|
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
|
||||||
**Pattern**: Controller-Service-SubServices
|
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
|
||||||
- **Controller** (voiceController.ts): Voice connection management
|
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
|
||||||
- **Service** (recorder.ts): Recording orchestration
|
|
||||||
- **Sub-services** (recorder/*): Audio processing pipeline
|
|
||||||
- audioStream.ts: Opus packet subscription
|
|
||||||
- decoder.ts: Opus to PCM decoding
|
|
||||||
- segment.ts: OGG file rotation
|
|
||||||
- metadata.ts: User metadata collection
|
|
||||||
- sessionRecording.ts: Session lifecycle
|
|
||||||
- uploader.ts: Segment upload
|
|
||||||
- **Events Published**:
|
|
||||||
- `discord:voice:started`
|
|
||||||
- `discord:voice:stopped`
|
|
||||||
- `discord:voice:uploaded`
|
|
||||||
|
|
||||||
### attachment-upload
|
### attachment-upload
|
||||||
**Purpose**: Upload message attachments to external storage
|
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
|
||||||
**Pattern**: Service-Service
|
(sharp resize). Emits `discord:attachment:*`.
|
||||||
- **Service** (attachmentUploader.ts): Upload orchestration
|
|
||||||
- **Service** (imageResizer.ts): Image processing
|
|
||||||
- **Events Published**:
|
|
||||||
- `discord:attachment:created`
|
|
||||||
- `discord:attachment:uploaded`
|
|
||||||
|
|
||||||
### event-broadcaster
|
### event-broadcaster
|
||||||
**Purpose**: Publish events to Redis pub/sub
|
`RedisEventPublisher` (ioredis publish) + `EventBroadcaster` (typed methods).
|
||||||
**Pattern**: Service-Domain
|
Channel names in `src/shared/redis-channels.ts`.
|
||||||
- **Service** (eventBroadcaster.ts): Redis publisher
|
|
||||||
- **Domain** (eventTypes.ts): Event type definitions
|
|
||||||
- **Channels**:
|
|
||||||
- discord:message:* (message events)
|
|
||||||
- discord:attachment:* (attachment events)
|
|
||||||
- discord:voice:* (voice events)
|
|
||||||
- discord:analysis:* (analysis events)
|
|
||||||
|
|
||||||
## Shared Infrastructure
|
### gateway-metrics
|
||||||
|
`metrics.ts` Prometheus HTTP server on `METRICS_PORT` (4016). Collectors run
|
||||||
|
per scrape; live pipeline gauges registered in `bootstrap.ts`.
|
||||||
|
|
||||||
### config
|
## Shared infrastructure
|
||||||
- Zod-validated environment variables
|
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
|
||||||
- Type-safe configuration access
|
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
|
||||||
- Sensible defaults
|
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
|
||||||
|
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
|
||||||
|
|
||||||
### database
|
## Notes
|
||||||
- Drizzle ORM schema
|
- No HTTP server (other than the metrics endpoint). Pure event-driven.
|
||||||
- PostgreSQL connection
|
- `MODULE_STRUCTURE.md` is intentionally a sketch; `ARCHITECTURE.md` is the
|
||||||
- Migration management
|
detailed reference. When they diverge, `ARCHITECTURE.md` wins.
|
||||||
- Voice recording repository
|
|
||||||
|
|
||||||
### logger
|
|
||||||
- Winston logger wrapper
|
|
||||||
- Context-aware logging
|
|
||||||
- Log serialization utilities
|
|
||||||
|
|
||||||
### errors
|
|
||||||
- Custom error classes
|
|
||||||
- Error codes and HTTP status codes
|
|
||||||
- Proper error hierarchy
|
|
||||||
|
|
||||||
### utils
|
|
||||||
- Retry with exponential backoff
|
|
||||||
- Configurable retry parameters
|
|
||||||
|
|
||||||
### discord
|
|
||||||
- Discord.js client configuration
|
|
||||||
- Cache optimization
|
|
||||||
- Partial handling
|
|
||||||
|
|
||||||
## Event Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Discord Events
|
|
||||||
↓
|
|
||||||
message-capture (Controller)
|
|
||||||
↓
|
|
||||||
messageStore (Repository) → PostgreSQL
|
|
||||||
↓
|
|
||||||
eventBroadcaster (Service)
|
|
||||||
↓
|
|
||||||
Redis Pub/Sub
|
|
||||||
↓
|
|
||||||
Backend Service (Subscriber)
|
|
||||||
↓
|
|
||||||
HTTP API / WebSocket
|
|
||||||
↓
|
|
||||||
Frontend Application
|
|
||||||
```
|
|
||||||
|
|
||||||
## No HTTP Server
|
|
||||||
|
|
||||||
- ✅ No Express
|
|
||||||
- ✅ No WebSocket server
|
|
||||||
- ✅ No HTTP routes
|
|
||||||
- ✅ No middleware
|
|
||||||
- ✅ Pure event-driven service
|
|
||||||
|
|
||||||
## Graceful Shutdown
|
|
||||||
|
|
||||||
1. Close PostgreSQL connection
|
|
||||||
2. Disconnect from voice channels
|
|
||||||
3. Close Redis connection
|
|
||||||
4. Destroy Discord client
|
|
||||||
5. Exit process
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
**Discord**:
|
|
||||||
- discord.js-selfbot-v13
|
|
||||||
- @discordjs/voice
|
|
||||||
- @discordjs/opus
|
|
||||||
|
|
||||||
**Audio**:
|
|
||||||
- prism-media
|
|
||||||
- opusscript
|
|
||||||
- sharp
|
|
||||||
|
|
||||||
**Data**:
|
|
||||||
- drizzle-orm
|
|
||||||
- pg
|
|
||||||
- zod
|
|
||||||
- ioredis
|
|
||||||
|
|
||||||
**Logging**:
|
|
||||||
- winston
|
|
||||||
- p-retry
|
|
||||||
- p-limit
|
|
||||||
- piscina
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The Discord Gateway service is a **pure event-driven microservice** that:
|
|
||||||
- Captures Discord messages, voice, and attachments
|
|
||||||
- Performs AI moderation analysis
|
|
||||||
- Publishes events to Redis pub/sub
|
|
||||||
- Has no HTTP server or WebSocket
|
|
||||||
- Follows Modular MVC pattern
|
|
||||||
- Maintains clean module boundaries
|
|
||||||
- Provides type-safe configuration
|
|
||||||
- Includes structured logging
|
|
||||||
- Handles graceful shutdown
|
|
||||||
|
|
||||||
The service is designed to run alongside the Backend service, which consumes Redis events and serves the HTTP API to the Frontend.
|
|
||||||
|
|||||||
@@ -243,10 +243,10 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
|
|||||||
- Connect to Backend HTTP API
|
- Connect to Backend HTTP API
|
||||||
- Subscribe to WebSocket events
|
- Subscribe to WebSocket events
|
||||||
|
|
||||||
3. **Docker & CI/CD**
|
3. **Nix & CI/CD**
|
||||||
- Dockerfile for Discord Gateway
|
- flake.nix package for Discord Gateway
|
||||||
- Docker Compose for multi-service setup
|
- systemd services (gmw-backend, gmw-discord-gateway)
|
||||||
- GitHub Actions for build/deploy
|
- GitHub Actions for build/deploy (nix copy → systemctl restart)
|
||||||
|
|
||||||
4. **Documentation**
|
4. **Documentation**
|
||||||
- API documentation
|
- API documentation
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ export default defineConfig({
|
|||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url:
|
url:
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
"postgresql://postgres:postgres@localhost:5432/bete",
|
"postgresql://asephs:***@100.121.180.82:6432/dcbot",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "term_glossary_cache" (
|
||||||
|
"term" text PRIMARY KEY NOT NULL,
|
||||||
|
"definition" text NOT NULL,
|
||||||
|
"source_url" text DEFAULT '' NOT NULL,
|
||||||
|
"resolved_at" bigint NOT NULL,
|
||||||
|
"hit_count" integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_term_glossary_cache_resolved_at" ON "term_glossary_cache" USING btree ("resolved_at");
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: 0015_add_moderation_explainability.sql
|
||||||
|
-- Date: 2026-08-18
|
||||||
|
-- Description: Add structured explainability columns to moderation_actions.
|
||||||
|
-- These are READ (surfaced read-only to the public web) so they are never
|
||||||
|
-- "written but never read" — they back the public moderation transparency view.
|
||||||
|
-- Idempotent: no-ops on databases that already carry the columns.
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "moderation_actions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "severity" text
|
||||||
|
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||||
|
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "score" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Remove the user reputation feature entirely (trust scores, infractions).
|
||||||
|
-- The feature was removed from the codebase; this drops the orphaned table.
|
||||||
|
DROP TABLE IF EXISTS "user_reputations";
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 11,
|
"idx": 11,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1781388000000,
|
"when": 1785600000000,
|
||||||
"tag": "0011_add_voice_transcription",
|
"tag": "0011_add_voice_transcription",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
@@ -99,6 +99,27 @@
|
|||||||
"when": 1785551832190,
|
"when": 1785551832190,
|
||||||
"tag": "0013_rename_mascot_chat_to_chatbot",
|
"tag": "0013_rename_mascot_chat_to_chatbot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785621600000,
|
||||||
|
"tag": "0014_add_term_glossary_cache",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787184000000,
|
||||||
|
"tag": "0015_add_moderation_explainability",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787185000000,
|
||||||
|
"tag": "0016_drop_user_reputations",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,16 +7,9 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
"@discordjs/opus",
|
||||||
"@lng2004/node-datachannel",
|
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"node-av",
|
"sharp"
|
||||||
"node-crc",
|
]
|
||||||
"sharp",
|
|
||||||
"zeromq"
|
|
||||||
],
|
|
||||||
"patchedDependencies": {
|
|
||||||
"node-crc@4.0.0": "./patches/node-crc@4.0.0.patch"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -33,14 +26,11 @@
|
|||||||
"@snazzah/davey": "^0.1.11",
|
"@snazzah/davey": "^0.1.11",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"@dank074/discord-video-stream": "latest",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"imghash": "^1.1.4",
|
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"node-crc": "^4.0.0",
|
|
||||||
"openai": "^6.38.0",
|
"openai": "^6.38.0",
|
||||||
"opusscript": "^0.0.8",
|
"opusscript": "^0.0.8",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/Cargo.toml b/Cargo.toml
|
|
||||||
index a967508960d8b6b686b23401ea14333b858a675c..d0282a30ee931563a65d774ed783c83d6d2fdd5e 100644
|
|
||||||
--- a/Cargo.toml
|
|
||||||
+++ b/Cargo.toml
|
|
||||||
@@ -3,7 +3,7 @@ name = "node-crc"
|
|
||||||
version = "4.0.0"
|
|
||||||
authors = ["Magic Len <len@magiclen.org>"]
|
|
||||||
edition = "2021"
|
|
||||||
-rust-version = "1.65"
|
|
||||||
+rust-version = "1.77.0"
|
|
||||||
repository = "https://github.com/magiclen/node-crc"
|
|
||||||
homepage = "https://magiclen.org/node-js-crc/"
|
|
||||||
keywords = ["nodejs", "crc8", "crc16", "crc32", "crc64"]
|
|
||||||
Generated
+4122
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user