Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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
|
||||||
|
|||||||
@@ -11,18 +11,63 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||||
VPS_USER: ${{ secrets.VPS_USER }}
|
VPS_USER: ${{ secrets.VPS_USER }}
|
||||||
|
|
||||||
jobs:
|
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:
|
build-and-deploy:
|
||||||
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
service: [backend, discord-gateway, proxy]
|
service: [backend, discord-gateway, proxy, frontend]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v7
|
||||||
@@ -37,9 +82,24 @@ jobs:
|
|||||||
extra-conf: |
|
extra-conf: |
|
||||||
sandbox = false
|
sandbox = false
|
||||||
accept-flake-config = true
|
accept-flake-config = true
|
||||||
|
# Attic binary cache as substituter on the runner: lets CI pull the
|
||||||
|
# prebuilt attic client (and any cached deps/builds) over HTTPS,
|
||||||
|
# no SSH round-trip needed. extra-substituters (NOT
|
||||||
|
# extra-trusted-substituters) is required — Determinate Nix never
|
||||||
|
# merges trusted-* substituters for nix-store CLI clients.
|
||||||
|
extra-substituters = https://attic.asepharyana.my.id/gmw
|
||||||
|
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88=
|
||||||
|
# NOTE: nix-installer-action unconditionally injects
|
||||||
|
# 'build-provenance-tags' into /etc/nix/nix.conf (a Determinate
|
||||||
|
# Nix-only setting). With determinate:false the runner's upstream
|
||||||
|
# nix warns 'unknown setting build-provenance-tags' on every
|
||||||
|
# invocation — benign, cosmetic. Switching determinate:true would
|
||||||
|
# silence it but changes the runner's nix flavor.
|
||||||
|
|
||||||
- name: Cache Nix
|
- name: Cache Nix
|
||||||
uses: DeterminateSystems/magic-nix-cache-action@v14
|
uses: DeterminateSystems/magic-nix-cache-action@v14
|
||||||
|
with:
|
||||||
|
use-flakehub: false
|
||||||
|
|
||||||
- name: Build ${{ matrix.service }}
|
- name: Build ${{ matrix.service }}
|
||||||
id: build
|
id: build
|
||||||
@@ -60,17 +120,141 @@ jobs:
|
|||||||
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
|
||||||
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Push build result to Attic binary cache (attic.asepharyana.my.id) so
|
||||||
|
# the VPS can substitute it instead of a single-stream `nix copy ssh://`.
|
||||||
|
#
|
||||||
|
# Fast path: push DIRECTLY from the runner to the public attic endpoint
|
||||||
|
# (validated 2026-08-10: token auth over public HTTPS works without
|
||||||
|
# Tailscale). This skips the ~794MB closure SSH copy to the VPS that
|
||||||
|
# used to take 25+ minutes per new store path.
|
||||||
|
#
|
||||||
|
# The attic client is NOT in nixpkgs anymore and has no prebuilt
|
||||||
|
# releases, so we pull the same prebuilt closure the VPS uses
|
||||||
|
# (/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0, ~52MB).
|
||||||
|
# The closure itself lives in the attic cache (pushed once from the
|
||||||
|
# VPS), so the runner bootstraps it over HTTPS via the configured
|
||||||
|
# extra-substituters — no SSH round-trip. If that fails we fall back
|
||||||
|
# to `nix copy --from ssh://`, then the old VPS-hop flow (SSH copy to
|
||||||
|
# VPS, then attic push from the VPS over Tailscale) so the deploy step
|
||||||
|
# always has a working closure path.
|
||||||
|
- name: Push to Attic cache
|
||||||
|
env:
|
||||||
|
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$ATTIC_TOKEN" ]; then
|
||||||
|
echo "ATTIC_TOKEN not set; skipping attic push"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||||
|
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
|
||||||
|
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||||
|
|
||||||
|
attic_push_vps_hop() {
|
||||||
|
echo "Fallback: VPS-hop attic push"
|
||||||
|
# Copy closure to VPS (fast if attic already has it via substitute)
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|
||||||
|
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||||
|
# Push from VPS → Attic over Tailscale.
|
||||||
|
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
|
||||||
|
# writing the narinfo to gmw when chunks exist in the upstream
|
||||||
|
# cache.nixos.org — leaving the path 404 on gmw so the VPS deploy's
|
||||||
|
# nix-store --realise can't find it and falls back to ssh copy.
|
||||||
|
# sudo: attic must read root's config (~/.config/attic), which has
|
||||||
|
# the imrnes-ts server → Tailscale. Non-root users' configs only
|
||||||
|
# have the public `pub` server → "Server imrnes-ts does not exist".
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|
||||||
|
|| echo "attic push failed (non-fatal; ssh copy fallback below)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Get an attic client on the runner ────────────────────────────
|
||||||
|
# Order: PATH → pull the prebuilt closure from the attic cache
|
||||||
|
# itself (extra-substituters configured in Install Nix step, HTTPS
|
||||||
|
# only, no SSH) → pull over ssh from the VPS → VPS-hop.
|
||||||
|
# The attic client closure is stored in the attic cache (pushed
|
||||||
|
# once from the VPS), so the fast path never depends on SSH.
|
||||||
|
ATTIC_BIN=""
|
||||||
|
if command -v attic >/dev/null 2>&1; then
|
||||||
|
ATTIC_BIN="$(command -v attic)"
|
||||||
|
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
|
||||||
|
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
|
||||||
|
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||||
|
elif nix copy --from "ssh://$VPS_USER@$VPS_HOST" "$ATTIC_DIR" 2>>/tmp/attic-bootstrap.err; then
|
||||||
|
echo "✅ Pulled attic client from VPS over ssh"
|
||||||
|
ATTIC_BIN="$ATTIC_DIR/bin/attic"
|
||||||
|
else
|
||||||
|
echo "attic client unavailable on runner; using VPS-hop flow"
|
||||||
|
echo "--- bootstrap errors (stderr) ---"
|
||||||
|
tail -5 /tmp/attic-bootstrap.err 2>/dev/null || true
|
||||||
|
attic_push_vps_hop
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Direct push: runner → attic public endpoint ──────────────────
|
||||||
|
# --ignore-upstream-cache-filter forces the narinfo write even when
|
||||||
|
# the path's chunks already exist in upstream cache.nixos.org (which
|
||||||
|
# attic would otherwise skip, leaving the path 404 on the gmw cache).
|
||||||
|
mkdir -p "$HOME/.config/attic"
|
||||||
|
cat > "$HOME/.config/attic/config.toml" <<EOF
|
||||||
|
default-server = "pub"
|
||||||
|
|
||||||
|
[servers.pub]
|
||||||
|
endpoint = "https://attic.asepharyana.my.id"
|
||||||
|
token = "$ATTIC_TOKEN"
|
||||||
|
EOF
|
||||||
|
# Retry the direct push — a transient 502 (e.g. atticd restart,
|
||||||
|
# Traefik blip) must not abort the whole closure upload. attic push
|
||||||
|
# is idempotent, so re-running only uploads what's still missing.
|
||||||
|
push_ok=""
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if "$ATTIC_BIN" push pub:gmw "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
|
||||||
|
echo "✅ Pushed $STORE_PATH to attic directly from runner"
|
||||||
|
push_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ -z "$push_ok" ]; then
|
||||||
|
echo "Direct attic push failed after 3 attempts; using VPS-hop flow"
|
||||||
|
attic_push_vps_hop
|
||||||
|
fi
|
||||||
|
|
||||||
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
|
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
|
||||||
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
|
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
|
||||||
- name: Deploy ${{ matrix.service }} to VPS
|
- name: Deploy ${{ matrix.service }} to VPS
|
||||||
run: |
|
run: |
|
||||||
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
STORE_PATH="${{ steps.build.outputs.store-path }}"
|
||||||
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
|
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
|
||||||
|
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"
|
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Updating profile ==="
|
echo "=== Updating profile ==="
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
|
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
|
||||||
|
|
||||||
echo "=== Restarting service ==="
|
echo "=== Restarting service ===\n"
|
||||||
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && sleep 3 && sudo systemctl is-active gmw-${{ matrix.service }}"
|
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"
|
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)"
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -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_*`
|
|
||||||
@@ -11,6 +11,24 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
|
||||||
|
# against this host's glibc and ships both lib + dev headers, so the
|
||||||
|
# binding links cleanly inside the Nix sandbox (no manual cmake build).
|
||||||
|
libdatachannel = pkgs.libdatachannel;
|
||||||
|
|
||||||
|
# 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 —
|
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
|
||||||
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
|
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
|
||||||
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
|
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
|
||||||
@@ -46,6 +64,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/screenshare.
|
||||||
|
# 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";
|
||||||
@@ -84,7 +132,7 @@
|
|||||||
console.log('Fixed ' + count + ' files');
|
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
|
||||||
@@ -119,6 +167,7 @@ WRAPPER
|
|||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
|
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
|
||||||
pkgs.git # libdatachannel FetchContent clones from GitHub
|
pkgs.git # libdatachannel FetchContent clones from GitHub
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
@@ -137,27 +186,34 @@ WRAPPER
|
|||||||
# pnpm rebuild aborts on the first failing package and runs scripts
|
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||||
# from the wrong cwd — build each native dep explicitly with its own
|
# from the wrong cwd — build each native dep explicitly with its own
|
||||||
# install script. Each failure is tolerated (|| true); the packages
|
# install script. Each failure is tolerated (|| true); the packages
|
||||||
# that matter (opus, datachannel, node-av) are verified at runtime.
|
# that matter (opus) are verified at runtime.
|
||||||
for pkg in \
|
for pkg in \
|
||||||
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
|
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
|
||||||
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
|
|
||||||
node_modules/.pnpm/zeromq@*/node_modules/zeromq
|
|
||||||
do
|
do
|
||||||
if [ -d "$pkg" ]; then
|
if [ -d "$pkg" ]; then
|
||||||
echo "--- native build: $pkg ---"
|
echo "--- native build: $pkg ---"
|
||||||
(cd "$pkg" && npm run install 2>&1 || true)
|
(cd "$pkg" && npm run install 2>&1 || true)
|
||||||
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
|
|
||||||
# expected first argument to be an array) — the install fallback
|
|
||||||
# populates devDeps incl. cmake-js; build directly via cmake-js.
|
|
||||||
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
|
|
||||||
echo "--- datachannel cmake-js compile ---"
|
|
||||||
# Nix splits OpenSSL headers/libs across outputs — merge them
|
|
||||||
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
|
|
||||||
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
echo "=== Compiling TypeScript ==="
|
echo "=== Building libdatachannel-min N-API binding ==="
|
||||||
|
# The GoLive screen-share stack uses a minimal N-API binding
|
||||||
|
# (native/libdatachannel-min) over nixpkgs libdatachannel.
|
||||||
|
(
|
||||||
|
cd native/libdatachannel-min
|
||||||
|
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
|
||||||
|
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
|
||||||
|
# node-addon-api include root).
|
||||||
|
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
|
||||||
|
-type d -path "*node_modules/node-addon-api" | head -1)
|
||||||
|
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
|
||||||
|
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
|
||||||
|
NAPI_INCLUDE=$NAPI_INCLUDE \
|
||||||
|
npx node-gyp rebuild 2>&1 || true
|
||||||
|
ls -la build/Release/datachannel_min.node 2>/dev/null \
|
||||||
|
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|
||||||
|
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
|
||||||
|
)
|
||||||
|
echo "=== Compiling TypeScript ===="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||||
node -e "
|
node -e "
|
||||||
@@ -185,12 +241,28 @@ WRAPPER
|
|||||||
console.log('Fixed ' + count + ' files');
|
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
|
||||||
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
|
||||||
|
|
||||||
|
# GoLive native binding — loadNative resolves it relative to
|
||||||
|
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
|
||||||
|
# build/Release/datachannel_min.node; libdatachannel .so must sit
|
||||||
|
# next to it and be on LD_LIBRARY_PATH at runtime.
|
||||||
|
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
|
||||||
|
cp native/libdatachannel-min/build/Release/datachannel_min.node \
|
||||||
|
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
|
||||||
|
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
|
||||||
|
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
|
||||||
|
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
|
||||||
|
# If the binding failed to build, screen share is simply disabled —
|
||||||
|
# the gateway itself must still start.
|
||||||
|
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
|
||||||
|
echo "WARN: datachannel_min.node missing — GoLive screen share disabled in this build"
|
||||||
|
fi
|
||||||
|
|
||||||
# Also include drizzle migrations if they exist
|
# Also include drizzle migrations if they exist
|
||||||
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
|
||||||
|
|
||||||
@@ -199,6 +271,7 @@ 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
|
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||||
|
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_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
|
||||||
@@ -210,39 +283,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";
|
||||||
@@ -257,9 +348,8 @@ 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
|
||||||
|
|
||||||
@@ -271,7 +361,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,30 @@ http {
|
|||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /assets/ {
|
# ── Next.js build assets — immutable, edge/shareable ───────────
|
||||||
root @FRONTEND_ROOT@;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,3 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import express, {
|
import express, {
|
||||||
type Express,
|
type Express,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
@@ -6,13 +5,15 @@ import express, {
|
|||||||
type Response,
|
type Response,
|
||||||
} from "express";
|
} from "express";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
import { createAnalysisRouter } from "../modules/analysis/index.js";
|
||||||
|
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
||||||
import { createConfigRouter } from "../modules/config/index.js";
|
import { createConfigRouter } from "../modules/config/index.js";
|
||||||
import { createDashboardRouter } from "../modules/dashboard/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 { createMediaRouter } from "../modules/media/index.js";
|
import { createMediaRouter } from "../modules/media/index.js";
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
import { createMessagesRouter } from "../modules/messages/index.js";
|
||||||
|
import { createModerationRouter } from "../modules/moderation/index.js";
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
||||||
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
||||||
import { createVoiceRouter } from "../modules/voice/index.js";
|
import { createVoiceRouter } from "../modules/voice/index.js";
|
||||||
@@ -69,6 +70,7 @@ export function createHttpApp(): Express {
|
|||||||
app.use("/api", createUiStateRouter());
|
app.use("/api", createUiStateRouter());
|
||||||
app.use("/api", createMediaRouter());
|
app.use("/api", createMediaRouter());
|
||||||
app.use("/api", createVoiceRouter());
|
app.use("/api", createVoiceRouter());
|
||||||
|
app.use("/api", createModerationRouter());
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { analysisService } from "./analysis.service.js";
|
import { analysisService } from "./analysis.service.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { chatbotService } from "./chatbot.service.js";
|
import { chatbotService } from "./chatbot.service.js";
|
||||||
|
|
||||||
@@ -9,6 +9,18 @@ interface AuthenticatedRequest extends Request {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
|
||||||
|
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
|
||||||
|
* auth middleware userId takes precedence when present.
|
||||||
|
*/
|
||||||
|
function resolveUserId(req: Request): string {
|
||||||
|
const authId = (req as AuthenticatedRequest).userId;
|
||||||
|
if (authId) return authId;
|
||||||
|
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
|
||||||
|
return header || "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
export const handleChatbotChat = asyncHandler(
|
export const handleChatbotChat = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { message, context } = req.body as {
|
const { message, context } = req.body as {
|
||||||
@@ -24,8 +36,8 @@ export const handleChatbotChat = asyncHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user ID from auth middleware (if available)
|
// Get user ID from X-User-Id header (no-login device uuid) or auth
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = resolveUserId(req);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ userId, messageLength: message.length, context },
|
{ userId, messageLength: message.length, context },
|
||||||
@@ -59,7 +71,7 @@ export const handleChatbotChat = asyncHandler(
|
|||||||
|
|
||||||
export const getChatbotHistory = asyncHandler(
|
export const getChatbotHistory = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = resolveUserId(req);
|
||||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
||||||
|
|
||||||
const history = await chatbotService.getChatHistory(userId, limit);
|
const history = await chatbotService.getChatHistory(userId, limit);
|
||||||
@@ -73,7 +85,7 @@ export const getChatbotHistory = asyncHandler(
|
|||||||
|
|
||||||
export const clearChatbotHistory = asyncHandler(
|
export const clearChatbotHistory = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = resolveUserId(req);
|
||||||
|
|
||||||
await chatbotService.clearChatHistory(userId);
|
await chatbotService.clearChatHistory(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
|
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { 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, pgMessagesTable } from "../../shared/index.js";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.repository");
|
const logger = createChildLogger("chatbot.repository");
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import { chatRequestSchema } from "./chatbot.schema.js";
|
|||||||
export function createChatbotRouter(): Router {
|
export function createChatbotRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post(
|
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
|
||||||
"/chat",
|
|
||||||
validateBody(chatRequestSchema),
|
|
||||||
handleChatbotChat,
|
|
||||||
);
|
|
||||||
router.get("/chat/history", getChatbotHistory);
|
router.get("/chat/history", getChatbotHistory);
|
||||||
router.delete("/chat/history", clearChatbotHistory);
|
router.delete("/chat/history", clearChatbotHistory);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
SaveConversationInput,
|
SaveConversationInput,
|
||||||
} from "./chatbot.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { chatbotRepository } from "./chatbot.repository.js";
|
import { chatbotRepository } from "./chatbot.repository.js";
|
||||||
|
import { executeTool, tools } from "./chatbot.tools.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.service");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
@@ -118,41 +119,104 @@ 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 }];
|
||||||
|
|
||||||
|
// ── Agentic tool loop ─────────────────────────────────────────
|
||||||
|
const MAX_TOOL_ROUNDS = 4;
|
||||||
|
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${baseUrl}/chat/completions`,
|
`${baseUrl}/chat/completions`,
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
max_tokens: 500,
|
tools,
|
||||||
|
tool_choice: "auto",
|
||||||
|
max_tokens: 600,
|
||||||
temperature: 0.4,
|
temperature: 0.4,
|
||||||
|
stream: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 30_000,
|
timeout: 45_000,
|
||||||
|
// 9router returns SSE even without stream:true; force stream:true
|
||||||
|
// in the body and read the raw SSE text.
|
||||||
|
responseType: "text",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = response.data as {
|
// Parse SSE `data:` lines → content + tool_calls.
|
||||||
choices?: Array<{ message?: { content?: string } }>;
|
const { content, toolCalls } = this.parseSse(response.data as string);
|
||||||
};
|
|
||||||
const content = result?.choices?.[0]?.message?.content?.trim();
|
|
||||||
|
|
||||||
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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let result = "";
|
||||||
|
try {
|
||||||
|
result = await executeTool(tc.name, tc.args);
|
||||||
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn({ response: result }, "LLM returned empty response");
|
if (content?.trim()) {
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("LLM returned empty response (no tools, no content)");
|
||||||
|
return this.fallbackResponse(userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +224,93 @@ Gaya ngobrol:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,232 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools the chatbot LLM can call. Definitions describe the schema to the
|
||||||
|
* model; the executor implements each one against the real database.
|
||||||
|
* This turns the chatbot from "blind stats guesser" into an agent that
|
||||||
|
* pulls real, current server data on demand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ToolResult = string;
|
||||||
|
|
||||||
|
/** JSON schema for a tool definition (OpenAI function-calling format). */
|
||||||
|
export interface ToolDef {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: ToolDef[] = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_server_stats",
|
||||||
|
description:
|
||||||
|
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID guild/server (opsional). Kosongkan = semua data.",
|
||||||
|
},
|
||||||
|
channelId: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID channel (opsional).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_channels",
|
||||||
|
description:
|
||||||
|
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID server (opsional).",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah channel teratas (default 5, max 10).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_recent_activity",
|
||||||
|
description:
|
||||||
|
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID server (opsional).",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah pesan terakhir (default 5).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_flagged",
|
||||||
|
description:
|
||||||
|
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID server (opsional).",
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah pesan (default 5).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Executes a tool call against the real DB and returns a readable result. */
|
||||||
|
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 limitRaw =
|
||||||
|
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
||||||
|
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
|
||||||
|
|
||||||
|
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, limit);
|
||||||
|
case "get_top_flagged":
|
||||||
|
return await topFlagged(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"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool executors ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async function serverStats(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||||
|
if (channelId) conditions.push(`channel_id = '${channelId}'`);
|
||||||
|
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(
|
||||||
|
`SELECT COUNT(*)::int AS total_messages,
|
||||||
|
COUNT(DISTINCT user_id)::int AS active_users,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
||||||
|
FROM messages ${cond}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rows =
|
||||||
|
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
|
||||||
|
const r = rows[0] ?? {};
|
||||||
|
return JSON.stringify({
|
||||||
|
total_messages: r.total_messages ?? 0,
|
||||||
|
active_users: r.active_users ?? 0,
|
||||||
|
flagged: r.flagged ?? 0,
|
||||||
|
warned: r.warned ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||||
|
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(
|
||||||
|
`SELECT channel_id,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM messages ${cond}
|
||||||
|
GROUP BY channel_id
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
|
||||||
|
return JSON.stringify(rows.slice(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions: string[] = [];
|
||||||
|
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||||
|
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(
|
||||||
|
`SELECT username, content, channel_id, created_at
|
||||||
|
FROM messages ${cond}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conditions = ["ai_status IN ('flagged', 'warn')"];
|
||||||
|
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
||||||
|
const cond = `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
|
||||||
|
const result = await db.execute(
|
||||||
|
sql.raw(
|
||||||
|
`SELECT username, content, channel_id, ai_status, created_at
|
||||||
|
FROM messages ${cond}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import {
|
import {
|
||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
@@ -5,9 +8,6 @@ import {
|
|||||||
pgUserReputationsTable,
|
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 +82,52 @@ export class DashboardRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getActivity(days: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const sinceMs = Date.now() - days * 86400000;
|
||||||
|
const dayAgoMs = Date.now() - 86400000;
|
||||||
|
|
||||||
|
// Daily buckets (last N days)
|
||||||
|
const daily = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
|
||||||
|
COUNT(*)::int AS messages,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
||||||
|
COUNT(DISTINCT user_id)::int AS active_users
|
||||||
|
FROM ${pgMessagesTable}
|
||||||
|
WHERE created_at >= ${sinceMs}
|
||||||
|
GROUP BY day
|
||||||
|
ORDER BY day
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Hourly distribution (last 24h)
|
||||||
|
const hourly = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||||
|
COUNT(*)::int AS messages,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
|
||||||
|
FROM ${pgMessagesTable}
|
||||||
|
WHERE created_at >= ${dayAgoMs}
|
||||||
|
GROUP BY hour
|
||||||
|
ORDER BY hour
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
day: String(r.day),
|
||||||
|
messages: Number(r.messages),
|
||||||
|
flagged: Number(r.flagged),
|
||||||
|
active_users: Number(r.active_users),
|
||||||
|
})),
|
||||||
|
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
|
||||||
|
hour: Number(r.hour),
|
||||||
|
messages: Number(r.messages),
|
||||||
|
flagged: Number(r.flagged),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async listUsers(query: ListUsersQuery) {
|
async listUsers(query: ListUsersQuery) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const limit = query.limit ?? 20;
|
const limit = query.limit ?? 20;
|
||||||
@@ -285,6 +331,100 @@ export class DashboardRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTopReactions(limit: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const cap = Math.min(Math.max(limit || 20, 1), 50);
|
||||||
|
|
||||||
|
// Top messages by net reactions (adds minus removes), joined to message content
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
m.id AS message_id,
|
||||||
|
m.content,
|
||||||
|
m.username,
|
||||||
|
m.channel_id,
|
||||||
|
m.created_at,
|
||||||
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
|
r.reaction_count::int
|
||||||
|
FROM (
|
||||||
|
SELECT message_id,
|
||||||
|
(COUNT(*) FILTER (WHERE reaction_type = 'add')
|
||||||
|
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count
|
||||||
|
FROM message_reactions
|
||||||
|
GROUP BY message_id
|
||||||
|
) r
|
||||||
|
JOIN messages m ON m.id = r.message_id
|
||||||
|
WHERE r.reaction_count > 0
|
||||||
|
ORDER BY r.reaction_count DESC
|
||||||
|
LIMIT ${cap}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
|
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
// Top emoji per message (adds only) for the breakdown
|
||||||
|
const ids = rows.map((r) => String(r.message_id));
|
||||||
|
const emojiResult = await db.execute(sql`
|
||||||
|
SELECT message_id, emoji, COUNT(*)::int AS c
|
||||||
|
FROM message_reactions
|
||||||
|
WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)})
|
||||||
|
GROUP BY message_id, emoji
|
||||||
|
ORDER BY message_id, c DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const emojiByMessage = new Map<
|
||||||
|
string,
|
||||||
|
Array<{ emoji: string; count: number }>
|
||||||
|
>();
|
||||||
|
for (const e of emojiResult.rows as Record<string, unknown>[]) {
|
||||||
|
const mid = String(e.message_id);
|
||||||
|
const list = emojiByMessage.get(mid) ?? [];
|
||||||
|
list.push({ emoji: String(e.emoji), count: Number(e.c) });
|
||||||
|
emojiByMessage.set(mid, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
message_id: String(r.message_id),
|
||||||
|
content: r.content ? String(r.content) : "",
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
channel_id: String(r.channel_id),
|
||||||
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
created_at: r.created_at ? Number(r.created_at) : null,
|
||||||
|
reaction_count: Number(r.reaction_count),
|
||||||
|
top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopReactors(limit: number) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const cap = Math.min(Math.max(limit || 20, 1), 50);
|
||||||
|
|
||||||
|
// Top users by net reactions given (adds minus removes)
|
||||||
|
const result = await db.execute(sql`
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
(COUNT(*) FILTER (WHERE reaction_type = 'add')
|
||||||
|
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count,
|
||||||
|
COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count,
|
||||||
|
COUNT(DISTINCT message_id)::int AS messages_reacted,
|
||||||
|
COUNT(DISTINCT emoji)::int AS emojis_used
|
||||||
|
FROM message_reactions
|
||||||
|
GROUP BY user_id, username
|
||||||
|
ORDER BY net_count DESC
|
||||||
|
LIMIT ${cap}
|
||||||
|
`);
|
||||||
|
|
||||||
|
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
|
||||||
|
user_id: String(r.user_id),
|
||||||
|
username: String(r.username ?? "unknown"),
|
||||||
|
net_count: Number(r.net_count),
|
||||||
|
adds_count: Number(r.adds_count),
|
||||||
|
messages_reacted: Number(r.messages_reacted),
|
||||||
|
emojis_used: Number(r.emojis_used),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async getUserDetail(userId: string) {
|
async getUserDetail(userId: string) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { dashboardService } from "./dashboard.service.js";
|
import { dashboardService } from "./dashboard.service.js";
|
||||||
|
|
||||||
@@ -19,6 +19,16 @@ export function createDashboardRouter(): Router {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/dashboard/activity?days=14 — message volume over time
|
||||||
|
router.get(
|
||||||
|
"/dashboard/activity",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
|
||||||
|
const activity = await dashboardService.getActivity(days);
|
||||||
|
res.json(activity);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/dashboard/users — paginated user list with profiles
|
// GET /api/dashboard/users — paginated user list with profiles
|
||||||
router.get(
|
router.get(
|
||||||
"/dashboard/users",
|
"/dashboard/users",
|
||||||
@@ -77,5 +87,25 @@ export function createDashboardRouter(): Router {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/dashboard/reactions — top reacted messages
|
||||||
|
router.get(
|
||||||
|
"/dashboard/reactions",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
const reactions = await dashboardService.getTopReactions(limit);
|
||||||
|
res.json(reactions);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/dashboard/reactors — top users by reactions given
|
||||||
|
router.get(
|
||||||
|
"/dashboard/reactors",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
const reactors = await dashboardService.getTopReactors(limit);
|
||||||
|
res.json(reactors);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
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,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");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||||
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
|
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
|
||||||
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
|
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
|
||||||
|
|
||||||
const logger = createChildLogger("media.routes");
|
const logger = createChildLogger("media.routes");
|
||||||
|
|
||||||
@@ -55,14 +55,14 @@ export function createMediaRouter(): Router {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/media/volume
|
// POST /api/media/loop
|
||||||
router.post(
|
router.post(
|
||||||
"/media/volume",
|
"/media/loop",
|
||||||
validateBody(mediaVolumeSchema),
|
validateBody(mediaLoopSchema),
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { volume } = req.body as { volume: number };
|
const { loop } = req.body as { loop: boolean };
|
||||||
logger.debug({ volume }, "Media volume requested");
|
logger.debug({ loop }, "Media loop requested");
|
||||||
const state = await setVolume(volume);
|
const state = await setLoop(loop);
|
||||||
res.json(state);
|
res.json(state);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { messageQuerySchema } from "./messages.schema.js";
|
import { messageQuerySchema } from "./messages.schema.js";
|
||||||
import { messagesService } from "./messages.service.js";
|
import { messagesService } from "./messages.service.js";
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -115,6 +115,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,
|
||||||
@@ -223,58 +244,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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import {
|
import {
|
||||||
handleGetAttachmentsByChannel,
|
handleGetAttachmentsByChannel,
|
||||||
handleGetImageMessages,
|
handleGetImageMessages,
|
||||||
@@ -9,27 +9,10 @@ import {
|
|||||||
handleGetMessagesByChannel,
|
handleGetMessagesByChannel,
|
||||||
handleListMessages,
|
handleListMessages,
|
||||||
} from "./messages.controller.js";
|
} from "./messages.controller.js";
|
||||||
import { reanalyzeBatchSchema } from "./messages.schema.js";
|
|
||||||
import { messagesService } from "./messages.service.js";
|
import { messagesService } from "./messages.service.js";
|
||||||
|
|
||||||
const logger = createChildLogger("messages.routes");
|
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 {
|
export function createMessagesRouter(): Router {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -51,74 +34,6 @@ export function createMessagesRouter(): Router {
|
|||||||
// (uses /detail/ prefix to avoid collision with :channelId route above)
|
// (uses /detail/ prefix to avoid collision with :channelId route above)
|
||||||
router.get("/messages/detail/:id", handleGetMessageById);
|
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
|
// GET /api/review - Get flagged/warned messages for review
|
||||||
router.get(
|
router.get(
|
||||||
"/review",
|
"/review",
|
||||||
|
|||||||
@@ -38,13 +38,6 @@ 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>;
|
|
||||||
|
|||||||
@@ -34,7 +34,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 +63,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,
|
||||||
@@ -74,25 +70,6 @@ export class MessagesService {
|
|||||||
logger.debug({ channelId, limit }, "Getting review messages");
|
logger.debug({ channelId, limit }, "Getting review messages");
|
||||||
return messagesRepository.getReviewMessages(channelId, limit);
|
return messagesRepository.getReviewMessages(channelId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reanalyzeErrorBatch(opts: {
|
|
||||||
guildId?: string;
|
|
||||||
channelId?: string;
|
|
||||||
messageIds?: string[];
|
|
||||||
}) {
|
|
||||||
if (
|
|
||||||
!opts.guildId &&
|
|
||||||
!opts.channelId &&
|
|
||||||
(!opts.messageIds || opts.messageIds.length === 0)
|
|
||||||
) {
|
|
||||||
throw new ValidationError(
|
|
||||||
"At least one of guildId, channelId, or messageIds[] is required",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(opts, "Batch reanalyzing errored messages");
|
|
||||||
return messagesRepository.reanalyzeErrorBatch(opts);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesService = new MessagesService();
|
export const messagesService = new MessagesService();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createModerationRouter } from "./moderation.routes.js";
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
username: r.username ? String(r.username) : null,
|
||||||
|
content: r.content ? String(r.content) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||||
|
const nextCursor =
|
||||||
|
rows.length > limit ? String(lastRow?.created_at ?? "") : null;
|
||||||
|
|
||||||
|
return { data, nextCursor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationRepository = new ModerationRepository();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Request, Response, Router } from "express";
|
||||||
|
import express from "express";
|
||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
|
import { moderationService } from "./moderation.service.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("moderation.routes");
|
||||||
|
|
||||||
|
export function createModerationRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/moderation/stats — moderation action summary
|
||||||
|
router.get(
|
||||||
|
"/moderation/stats",
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const stats = await moderationService.getStats();
|
||||||
|
res.json(stats);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/moderation/actions — paginated moderation action log
|
||||||
|
router.get(
|
||||||
|
"/moderation/actions",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const limit = Number(req.query.limit) || 50;
|
||||||
|
const status = req.query.status as string | undefined;
|
||||||
|
const actionType = req.query.actionType as string | undefined;
|
||||||
|
const cursor = req.query.cursor as string | undefined;
|
||||||
|
|
||||||
|
const result = await moderationService.listActions({
|
||||||
|
limit,
|
||||||
|
status,
|
||||||
|
actionType,
|
||||||
|
cursor: cursor ? Number(cursor) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug({ count: result.data.length }, "Moderation actions listed");
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
import {
|
||||||
|
type ListModerationQuery,
|
||||||
|
moderationRepository,
|
||||||
|
} from "./moderation.repository.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("moderation.service");
|
||||||
|
|
||||||
|
export class ModerationService {
|
||||||
|
async getStats() {
|
||||||
|
logger.debug("Fetching moderation stats");
|
||||||
|
return moderationRepository.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listActions(query: ListModerationQuery) {
|
||||||
|
logger.debug({ query }, "Listing moderation actions");
|
||||||
|
return moderationRepository.listActions(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationService = new ModerationService();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { recordingsService } from "./recordings.service.js";
|
import { recordingsService } from "./recordings.service.js";
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { uiStateService } from "./ui-state.service.js";
|
import { uiStateService } from "./ui-state.service.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("ui-state.service");
|
const logger = createChildLogger("ui-state.service");
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
import { publishCommandNoReply } from "../../shared/redis/index.js";
|
||||||
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
|
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { Request, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
|
||||||
import {
|
import {
|
||||||
handleConnectVoice,
|
handleConnectVoice,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -160,8 +160,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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -592,5 +592,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");
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,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";
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user