refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped

- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Developer
2026-07-27 21:54:31 +07:00
co-authored by Claude Opus 4.8
parent 63f21513bd
commit 5802d02e29
223 changed files with 11499 additions and 13350 deletions
+54 -65
View File
@@ -1,82 +1,71 @@
name: Deploy to VPS name: Build & Deploy
run-name: "Build & Deploy ${{ github.sha }}"
on: on:
push: push:
branches: branches: [main]
- main
jobs: jobs:
deploy: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
service: [backend, discord-gateway, proxy]
steps: steps:
- name: Checkout repository - name: Setup
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install system dependencies
run: | run: |
apt-get update set -eu
apt-get install -y --no-install-recommends \ apt-get update -qq
build-essential \ apt-get install -y -qq --no-install-recommends ca-certificates git docker.io
ca-certificates \ git config --global http.sslVerify false
curl \
libssl-dev \
pkg-config \
python3
- name: Set up Node.js - name: Checkout
uses: actions/setup-node@v4 run: |
with: cd /tmp
node-version: '22' git clone --depth 1 https://MythEclipse:${{ secrets.REGISTRY_TOKEN }}@git.imrnes.team/MythEclipse/GMW.git repo
cd /tmp/repo
- name: Set up pnpm - name: Docker Login
uses: pnpm/action-setup@v4 run: |
with: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u MythEclipse --password-stdin
version: 11.1.3
run_install: false
- name: Set up Rust (for node-crc native build) - name: Build & Push ${{ matrix.service }}
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Build shared package
run: pnpm --filter './packages/shared' run build
- name: Build backend
run: pnpm run build:backend
- name: Build discord gateway
run: pnpm run build:discord-gateway
- name: Build frontend
run: pnpm run build:web
- name: Prepare production environment file
env: env:
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} DOCKER_BUILDKIT: "1"
run: | run: |
umask 077 cd /tmp/repo
printf '%s\n' "$PRODUCTION_ENV" | tr -d '\r' > .env IMG=${{ vars.REGISTRY }}/mytheclipse/gmw/bete-${{ matrix.service }}
chmod 600 .env docker build \
--file infra/docker/Dockerfile.${{ matrix.service }} \
--tag $IMG:${{ github.sha }} \
--tag $IMG:latest \
--build-arg VITE_BE_API_URL=https://imphnen.asepharyana.my.id \
--build-arg VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id \
.
docker push $IMG:${{ github.sha }}
docker push $IMG:latest
- name: Prepare SSH key deploy:
env: needs: build-and-push
VPS_SSH_KEY_CONTENT: ${{ secrets.VPS_SSH_KEY }} runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Install SSH
run: | run: |
umask 077 apt-get update -qq
key_file="${RUNNER_TEMP:-/tmp}/bete-vps-key" apt-get install -y -qq --no-install-recommends openssh-client ca-certificates
printf '%s\n' "$VPS_SSH_KEY_CONTENT" > "$key_file"
chmod 600 "$key_file"
echo "VPS_SSH_KEY=$key_file" >> "$GITHUB_ENV"
- name: Deploy - name: Deploy
env: env:
VPS_HOST: ${{ secrets.VPS_HOST }} ENV_FILE: ${{ secrets.ENV_FILE }}
VPS_USER: ${{ secrets.VPS_USER }} SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: ./deploy.sh --no-build run: |
set -eu
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ENV_B64=$(echo "$ENV_FILE" | base64 -w0)
ssh -o StrictHostKeyChecking=accept-new \
${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd /opt/imphenbot/infra/docker && echo '$ENV_B64' | base64 -d > .env && docker compose pull && docker compose up -d --remove-orphans && docker image prune -f"
-134
View File
@@ -1,134 +0,0 @@
name: Deploy to VPS
on:
push:
branches:
- master
workflow_dispatch:
# Prevent concurrent deployments from racing
concurrency:
group: deploy-vps-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
OWNER: mytheclipse
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
service: [frontend, backend, discord-gateway, proxy]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v7
with:
context: .
file: infra/docker/Dockerfile.${{ matrix.service }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/bete-${{ matrix.service }}:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/bete-${{ matrix.service }}:${{ github.sha }}
cache-from: type=gha,scope=bete-${{ matrix.service }}
cache-to: type=gha,mode=max,scope=bete-${{ matrix.service }}
build-args: |
VITE_BE_API_URL=https://imphnen.asepharyana.my.id
VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.2.5
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USERNAME }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: GHCR_USERNAME,GHCR_TOKEN,ENV_FILE
script: |
set -eu
APP_DIR=/opt/imphenbot
REPO_URL=https://github.com/MythEclipse/GMW.git
if [ -d "$APP_DIR/.git" ]; then
ORIGIN=$(git -C "$APP_DIR" remote get-url origin 2>/dev/null || true)
if [ "$ORIGIN" != "$REPO_URL" ]; then
rm -rf "$APP_DIR"
fi
fi
if [ ! -d "$APP_DIR/.git" ]; then
mkdir -p "$APP_DIR"
git clone --depth 1 --branch master "$REPO_URL" "$APP_DIR"
else
git -C "$APP_DIR" fetch --depth 1 origin master
git -C "$APP_DIR" checkout master
git -C "$APP_DIR" reset --hard origin/master
fi
cd "$APP_DIR"
mkdir -p infra/docker/recordings
# Set permissions for recordings directory (writable by container app user UID 100)
chmod -R 777 infra/docker/recordings
# Write env file — strip \r to avoid configuration issues
printf '%s\n' "$ENV_FILE" | tr -d '\r' > infra/docker/.env
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin
# Force stop any stale containers from previous deployments
docker rm -f imphenbot-proxy imphenbot-backend imphenbot-frontend imphenbot-discord-gateway 2>/dev/null || true
# Retry docker pull up to 3 times on transient network errors
RETRIES=3
for i in $(seq 1 $RETRIES); do
echo "docker compose pull (attempt $i/$RETRIES)"
if docker compose -f infra/docker/docker-compose.yml pull; then
echo "Pull succeeded"
break
else
echo "Pull failed (attempt $i/$RETRIES)"
if [ "$i" -eq "$RETRIES" ]; then
echo "All pull attempts failed" >&2
exit 1
fi
sleep 5
fi
done
# Remove orphan containers but don't block on healthchecks —
# containers have restart: unless-stopped and will recover on their own
docker compose -f infra/docker/docker-compose.yml up -d --remove-orphans
docker image prune -f
+3
View File
@@ -15,3 +15,6 @@ worktrees/
.worktrees/ .worktrees/
services/frontend/frontend/dist/ services/frontend/frontend/dist/
target/ target/
# Gitea CI runner logs
.gitea/workflows/*.log
-72
View File
@@ -1,72 +0,0 @@
# ─── BETE GitLab CI Pipeline ───────────────────────────────────────────────────
# Builds 3 Docker images (backend, discord-gateway, proxy) and pushes to
# GitLab Container Registry with both `:$CI_COMMIT_SHA` and `:latest` tags.
#
# Deploy manually from your dev machine:
# export VPS_HOST=... VPS_USER=... VPS_SSH_KEY=... GITLAB_TOKEN=...
# ./deploy.sh # build + deploy all
# ./deploy.sh --backend # backend only
# ./deploy.sh --frontend # frontend only
# ./deploy.sh --no-build # skip build, just copy files
#
# ──────────────────────────────────────────────────────────────────────────────
stages:
- build
variables:
# Submodules — discord.js-selfbot-v13 and discord-video-stream
GIT_SUBMODULE_STRATEGY: recursive
REGISTRY: $CI_REGISTRY
REGISTRY_PROJECT: $CI_REGISTRY/$CI_PROJECT_PATH
IMAGE_TAG_COMMIT: $CI_COMMIT_SHA
IMAGE_TAG_LATEST: latest
# Deploy target
SSH_HOST: "${VPS_USERNAME}@${VPS_HOST}"
APP_DIR: /opt/imphenbot
# ── Build stage ───────────────────────────────────────────────────────────────
.docker-build:
stage: build
image: docker:27-cli
services:
- name: docker:27-dind
command: ["--mtu=1400"]
before_script:
- echo "$CI_JOB_TOKEN" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
script:
# Build image
- |
docker build \
--file infra/docker/Dockerfile.$SERVICE_NAME \
--tag $REGISTRY_PROJECT/bete-$SERVICE_NAME:$IMAGE_TAG_COMMIT \
--tag $REGISTRY_PROJECT/bete-$SERVICE_NAME:$IMAGE_TAG_LATEST \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $REGISTRY_PROJECT/bete-$SERVICE_NAME:latest \
.
# Push to GitLab Container Registry
- docker push $REGISTRY_PROJECT/bete-$SERVICE_NAME:$IMAGE_TAG_COMMIT
- docker push $REGISTRY_PROJECT/bete-$SERVICE_NAME:$IMAGE_TAG_LATEST
build-backend:
extends: .docker-build
variables:
SERVICE_NAME: backend
only:
- master
build-discord-gateway:
extends: .docker-build
variables:
SERVICE_NAME: discord-gateway
only:
- master
build-proxy:
extends: .docker-build
variables:
SERVICE_NAME: proxy
only:
- master
+27 -181
View File
@@ -1,185 +1,31 @@
#!/bin/bash #!/bin/bash
# ─── Bete Deploy Script ────────────────────────────────────────────────────── set -euo pipefail
# Builds selected services locally and deploys compiled JS/WASM artifacts to the
# VPS via bind-mounted host directories. Changes survive container restarts
# because the Docker containers use bind mounts, not docker exec tar-pipes.
#
# This script is CI-provider neutral. GitHub/GitLab/Gitea workflows and local
# shells must provide deployment credentials through environment variables.
#
# Usage:
# ./deploy.sh # build + deploy all services
# ./deploy.sh --frontend # frontend (Next.js) only
# ./deploy.sh --backend # backend JS only
# ./deploy.sh --gateway # discord-gateway JS only
# ./deploy.sh --all # same as no-flag (default)
# ./deploy.sh --no-build # skip builds, just copy files
# ./deploy.sh --help # show this message
#
# Required env:
# VPS_HOST — VPS IP/hostname
# VPS_USER — SSH user
# VPS_SSH_KEY — path to SSH private key or raw private-key contents
#
# Optional env:
# ADMIN_PASSWORD — verify backend health after deploy
# ──────────────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/infra/docker"
: "${VPS_HOST:?required}"
: "${VPS_USER:?required}"
: "${VPS_SSH_KEY:?required}"
echo "=== Deploy to $VPS_HOST ==="
# Copy local .env if it exists (overrides CI env)
if [ -f "$INFRA_DIR/.env" ]; then
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
fi
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
set -eu set -eu
cd /opt/imphenbot/infra/docker
echo "=== Pulling images ==="
docker compose pull
echo "=== Restarting containers ==="
docker compose up -d --remove-orphans
echo "=== Cleaning up ==="
docker image prune -f
echo "=== Active containers ==="
docker ps --filter "name=imphenbot" --format "table {{.Names}} {{.Image}} {{.Status}}"
REMOTESCRIPT
# ── Config ──────────────────────────────────────────────────────────────────── echo "=== Deploy complete ==="
APP_DIR="/opt/imphenbot"
COMPOSE_FILE="infra/docker/docker-compose.yml"
# Local build output directories (relative to repo root)
FRONTEND_DIST="services/frontend/out"
BACKEND_DIST="services/backend/dist"
GATEWAY_DIST="services/discord-gateway/dist"
# Remote bind-mount paths (must match infra/docker/docker-compose.yml volumes)
REMOTE_BASE="${APP_DIR}/infra/docker"
REMOTE_FRONTEND="${REMOTE_BASE}/frontend-dist"
REMOTE_BACKEND="${REMOTE_BASE}/backend-dist"
REMOTE_GATEWAY="${REMOTE_BASE}/gateway-dist"
# ── Parse args ────────────────────────────────────────────────────────────────
DO_BUILD=true
DO_ALL=false
DO_FRONTEND=false
DO_BACKEND=false
DO_GATEWAY=false
for arg in "$@"; do
case "$arg" in
--help|-h)
sed -n '2,/^$/ s/^# //p' "$0"
exit 0
;;
--all|--full) DO_ALL=true ;;
--frontend) DO_FRONTEND=true ;;
--backend) DO_BACKEND=true ;;
--gateway) DO_GATEWAY=true ;;
--no-build) DO_BUILD=false ;;
esac
done
# If no service flag given, or --all, default to all
if ! $DO_FRONTEND && ! $DO_BACKEND && ! $DO_GATEWAY || $DO_ALL; then
DO_FRONTEND=true
DO_BACKEND=true
DO_GATEWAY=true
fi
# ── Validate and prepare SSH key ─────────────────────────────────────────────
: "${VPS_HOST:?VPS_HOST not set}"
: "${VPS_USER:?VPS_USER not set}"
: "${VPS_SSH_KEY:?VPS_SSH_KEY not set}"
TEMP_SSH_KEY=""
cleanup() {
if [ -n "$TEMP_SSH_KEY" ]; then
rm -f "$TEMP_SSH_KEY"
fi
}
trap cleanup EXIT
if [ -f "$VPS_SSH_KEY" ]; then
SSH_KEY_PATH="$VPS_SSH_KEY"
else
TEMP_SSH_KEY=$(mktemp)
printf '%s\n' "$VPS_SSH_KEY" > "$TEMP_SSH_KEY"
chmod 600 "$TEMP_SSH_KEY"
SSH_KEY_PATH="$TEMP_SSH_KEY"
fi
SSH_DEST="${VPS_USER}@${VPS_HOST}"
SSH_OPTS="-i $SSH_KEY_PATH -o StrictHostKeyChecking=accept-new"
# ── Helpers ───────────────────────────────────────────────────────────────────
vps() { ssh $SSH_OPTS "$SSH_DEST" "$@"; }
log() { echo "$*"; }
ok() { echo "$*"; }
die() { echo "$*"; exit 1; }
# ── 1. Ensure remote bind-mount directories exist ────────────────────────────
log "Ensuring remote bind-mount directories exist..."
vps "mkdir -p '$REMOTE_FRONTEND' '$REMOTE_BACKEND' '$REMOTE_GATEWAY'"
ok "Remote directories ready"
# ── 2. Build ──────────────────────────────────────────────────────────────────
if $DO_BUILD; then
REPO_ROOT=$(cd "$(dirname "$0")" && pwd)
cd "$REPO_ROOT"
if $DO_BACKEND; then
log "Building backend (TypeScript)..."
pnpm --filter './services/backend' run build 2>&1 | tail -3 || die "Backend build failed"
ok "Backend built"
fi
if $DO_FRONTEND; then
log "Building frontend (Next.js)..."
cd services/frontend
bun run build 2>&1 | tail -5 || die "Frontend build failed"
cd "$REPO_ROOT"
ok "Frontend built"
fi
if $DO_GATEWAY; then
log "Building gateway (TypeScript)..."
pnpm --filter '@bete/discord-gateway' run build 2>&1 | tail -3 || die "Gateway build failed"
ok "Gateway built"
fi
else
log "Skipping build (--no-build)"
fi
# ── 3. Deploy to VPS (via tar pipe to remote host directory) ──────────────────
deploy_to_remote() {
local src="$1"
local remote_dir="$2"
local name="$3"
if [ ! -d "$src" ] || [ -z "$(ls -A "$src" 2>/dev/null)" ]; then
log "WARN: $src is empty or missing — skipping $name"
return
fi
log "Deploying $name..."
# Atomic swap: extract into a temp dir, then rename — avoids partial deploy
vps "rm -rf '${remote_dir}.new' && mkdir -p '${remote_dir}.new'"
tar czf - -C "$src" . | vps "tar xzf - -C '${remote_dir}.new'"
vps "rm -rf '${remote_dir}.old' && mv '${remote_dir}' '${remote_dir}.old' 2>/dev/null; mv '${remote_dir}.new' '${remote_dir}' && rm -rf '${remote_dir}.old'"
log "$name copied to host — restarting container..."
}
if $DO_FRONTEND; then
deploy_to_remote "$FRONTEND_DIST" "$REMOTE_FRONTEND" "frontend"
vps "docker restart imphenbot-proxy" > /dev/null 2>&1
ok "Frontend deployed"
fi
if $DO_BACKEND; then
deploy_to_remote "$BACKEND_DIST" "$REMOTE_BACKEND" "backend"
vps "docker restart imphenbot-backend" > /dev/null 2>&1
ok "Backend deployed"
fi
if $DO_GATEWAY; then
deploy_to_remote "$GATEWAY_DIST" "$REMOTE_GATEWAY" "gateway"
vps "docker restart imphenbot-discord-gateway" > /dev/null 2>&1
ok "Gateway deployed"
fi
# ── 4. Verify ─────────────────────────────────────────────────────────────────
if $DO_BACKEND && [ -n "${ADMIN_PASSWORD:-}" ]; then
log "Verifying backend..."
sleep 3
curl -sf "https://${VPS_HOST}/api/health" -H "X-Admin-Password: $ADMIN_PASSWORD" > /dev/null 2>&1 \
&& ok "Backend health check passed" \
|| log "Backend health check skipped (might need a moment)"
fi
echo ""
echo "✓ Deploy complete"
@@ -0,0 +1,542 @@
# CI/CD Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate from hybrid CI/CD (GitHub Actions + GitLab CI + hot-deploy) to single Gitea CI pipeline with container registry — VPS pulls only.
**Architecture:** Three Docker images (backend, discord-gateway, proxy) built in Gitea CI, pushed to `git.imrnes.team/MythEclipse/GMW/*`, VPS pulls and restarts via SSH. No more hot-deploy bind-mounts.
**Tech Stack:** Gitea CI (Act Runner, GitHub Actions-compatible syntax), Docker Buildx, Gitea Container Registry, appleboy/ssh-action
## Global Constraints
- Docker images must be self-contained (no bind-mount overlay at runtime)
- All three images must be built from monorepo root using `infra/docker/Dockerfile.*`
- Frontend static export built inside proxy Dockerfile (multi-stage, Next.js → Nginx)
- Gitea CI variables: GITEA_REGISTRY_TOKEN (secret), VPS_HOST (secret), VPS_USER (secret), VPS_SSH_KEY (secret), ENV_FILE (secret), GITEA_REGISTRY (variable)
- Registry URL: `git.imrnes.team/MythEclipse/GMW/`
- Work on `main` branch only
- Must preserve voice recordings volume persistence across container restarts
---
### Task 1: Create Gitea CI workflow
**Files:**
- Create: `.gitea/workflows/deploy.yml`
**Interfaces:**
- Consumes: Dockerfiles at `infra/docker/Dockerfile.{backend,discord-gateway,proxy}`
- Produces: Docker images pushed to `git.imrnes.team/MythEclipse/GMW/bete-*:latest` and `:{sha}`
- Depends on: Task 2 (proxy Dockerfile), Task 3 (backend Dockerfile) — but workflow can reference files that are being written in the same commit
- [ ] **Step 1: Create `.gitea/workflows/` directory and `deploy.yml`**
```bash
mkdir -p .gitea/workflows
```
- [ ] **Step 2: Write the workflow file**
Create `.gitea/workflows/deploy.yml`:
```yaml
name: Build & Deploy
run-name: "Build & Deploy ${{ gitea.sha }}"
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
service: [backend, discord-gateway, proxy]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Gitea Registry
uses: docker/login-action@v4
with:
registry: ${{ vars.GITEA_REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
- name: Build & Push ${{ matrix.service }}
uses: docker/build-push-action@v7
with:
context: .
file: infra/docker/Dockerfile.${{ matrix.service }}
push: true
tags: |
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:${{ gitea.sha }}
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:latest
cache-from: type=gha,scope=bete-${{ matrix.service }}
cache-to: type=gha,mode=max,scope=bete-${{ matrix.service }}
build-args: |
VITE_BE_API_URL=https://imphnen.asepharyana.my.id
VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.2.5
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_FILE
script: |
set -eu
APP_DIR=/opt/imphenbot
cd "$APP_DIR/infra/docker"
printf '%s\n' "$ENV_FILE" | tr -d '\r' > .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
```
Note: Gitea's Act Runner supports `gitea.*` context variables (`gitea.sha`, `gitea.actor`, `gitea.ref`). If `gitea.*` vars don't resolve, fall back to `github.*` equivalents (Act Runner emulates GitHub context).
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/deploy.yml
git commit -m "ci: add Gitea CI workflow for build & deploy
Gitea CI builds three Docker images (backend, discord-gateway, proxy),
pushes to Gitea Container Registry, then deploys to VPS via SSH pull.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Rewrite Dockerfile.proxy for Next.js static export
**Files:**
- Rewrite: `infra/docker/Dockerfile.proxy`
**Interfaces:**
- Consumes: `services/frontend/` (Next.js app), `packages/shared/` (workspace dep), `infra/docker/nginx/nginx.conf`
- Produces: Nginx image serving Next.js static export at `/usr/share/nginx/html/`
- [ ] **Step 1: Rewrite Dockerfile.proxy**
Replace entire content with:
```dockerfile
# ---- Stage 1: Build Next.js static export ----
FROM node:22-slim AS frontend-builder
WORKDIR /app
# Install pnpm
RUN corepack enable
# Install build essentials for native deps
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
python3 make g++ && rm -rf /var/lib/apt/lists/*
# Copy dependency manifests first for layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/frontend/package.json ./services/frontend/package.json
COPY services/frontend/tsconfig.json ./services/frontend/tsconfig.json
# Install dependencies (frontend + shared)
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter './packages/shared' --filter './services/frontend'
# Copy source code
COPY packages/shared/ ./packages/shared/
COPY services/frontend/ ./services/frontend/
# Pass API/WS URLs as build args for the frontend
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
# Build shared lib first, then frontend static export
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './packages/shared' run build
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter frontend run build
# ---- Stage 2: Nginx ----
FROM nginx:alpine
# Nginx config (API/WS proxy + static file serving)
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Static export from frontend builder
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
```
- [ ] **Step 2: Validate nginx.conf handles static files correctly**
Read and confirm `infra/docker/nginx/nginx.conf`.
```bash
cat infra/docker/nginx/nginx.conf
```
Verify it has:
- Static file location with `try_files $uri /index.html` (SPA fallback)
- `/api` and `/ws` proxied to `http://backend:3000`
- [ ] **Step 3: Commit**
```bash
git add infra/docker/Dockerfile.proxy
git commit -m "docker(proxy): rewrite for Next.js static export
Replaced stale Rust WASM build with multi-stage Docker build:
stage 1 builds Next.js static export, stage 2 serves via Nginx.
Includes VITE_BE_API_URL/VITE_BE_WS_URL build args.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Add build args to Dockerfile.backend
**Files:**
- Modify: `infra/docker/Dockerfile.backend`
- [ ] **Step 1: Add VITE build args to Dockerfile.backend**
Insert after `WORKDIR /app`:
```dockerfile
# Build args for frontend API URLs (passed through for future use)
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
```
Note: These are consumed by the proxy Dockerfile (Task 2), not needed by backend itself but passed through the CI workflow to all three images for consistency.
- [ ] **Step 2: Commit**
```bash
git add infra/docker/Dockerfile.backend
git commit -m "docker(backend): add VITE_BE_API_URL and VITE_BE_WS_URL build args
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Rewrite docker-compose.yml for Gitea registry + no bind-mounts
**Files:**
- Rewrite: `infra/docker/docker-compose.yml`
- [ ] **Step 1: Write new docker-compose.yml**
Replace entire content:
```yaml
version: '3.8'
services:
proxy:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-proxy:${IMAGE_TAG:-latest}
container_name: imphenbot-proxy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.my.id`)"
- "traefik.http.routers.imphenbot.entrypoints=websecure"
- "traefik.http.routers.imphenbot.tls=true"
- "traefik.http.services.imphenbot.loadbalancer.server.port=80"
depends_on:
- backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 64M
networks:
- app-shared-net
backend:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-backend:${IMAGE_TAG:-latest}
container_name: imphenbot-backend
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
WEBSERVER_PORT: 3000
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
deploy:
resources:
limits:
memory: 256M
networks:
- app-shared-net
discord-gateway:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-discord-gateway:${IMAGE_TAG:-latest}
container_name: imphenbot-discord-gateway
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
volumes:
- recordings:/app/recordings
healthcheck:
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
deploy:
resources:
limits:
memory: 512M
networks:
- app-shared-net
volumes:
recordings:
networks:
app-shared-net:
name: app-shared-net
external: true
```
Key changes:
- Image refs: `registry.gitlab.com/...``${GITEA_REGISTRY}/MythEclipse/GMW/...`
- Removed all bind-mount volumes: `./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`
- Changed `./recordings` bind-mount → named volume `recordings:` (persists across restarts)
- Added `depends_on: backend` to proxy (proxy needs backend for API/WS, though Nginx handles startup gracefully)
- [ ] **Step 2: Commit**
```bash
git add infra/docker/docker-compose.yml
git commit -m "docker(compose): switch to Gitea registry, remove bind-mounts
Images now come from git.imrnes.team/MythEclipse/GMW. All hot-deploy
bind-mounts removed — containers are fully self-contained. Voice
recordings use a named volume instead of bind-mount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Create lightweight deploy.sh
**Files:**
- Create: `deploy.sh`
- [ ] **Step 1: Write deploy.sh**
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/infra/docker"
: "${VPS_HOST:?required}"
: "${VPS_USER:?required}"
: "${VPS_SSH_KEY:?required}"
echo "=== Deploy to $VPS_HOST ==="
# Copy local .env if it exists (overrides CI env)
if [ -f "$INFRA_DIR/.env" ]; then
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
fi
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
set -eu
cd /opt/imphenbot/infra/docker
echo "=== Pulling images ==="
docker compose pull
echo "=== Restarting containers ==="
docker compose up -d --remove-orphans
echo "=== Cleaning up ==="
docker image prune -f
echo "=== Active containers ==="
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
REMOTESCRIPT
echo "=== Deploy complete ==="
```
- [ ] **Step 2: Make executable**
```bash
chmod +x deploy.sh
```
- [ ] **Step 3: Commit**
```bash
git add deploy.sh
git commit -m "chore: rewrite deploy.sh as lightweight SSH pull script
Replaced hot-deploy tar-pipe script with simple SSH-based deploy
that pulls latest images from Gitea registry and restarts containers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Disable old CI files
**Files:**
- Disable: `.github/workflows/deploy-docker.yml`
- Keep: `.gitlab-ci.yml` if exists (already may have been removed)
- [ ] **Step 1: Rename GitHub Actions workflow to .disabled**
```bash
mv .github/workflows/deploy-docker.yml .github/workflows/deploy-docker.yml.disabled
```
- [ ] **Step 2: Remove docker compose file's old frontend-dist directory from git** (if tracked)
```bash
# Check if frontend-dist is tracked (it should be gitignored, but check)
git ls-files infra/docker/frontend-dist 2>/dev/null || echo "Not tracked — OK"
```
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/deploy-docker.yml.disabled
git rm --cached .github/workflows/deploy-docker.yml 2>/dev/null || true
git commit -m "ci: disable GitHub Actions workflow
Renamed to .disabled. All CI now goes through Gitea CI (.gitea/workflows/).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: Update .gitignore
**Files:**
- Modify: `.gitignore`
- [ ] **Step 1: Add .gitea exclusion note and any missing entries**
Read current `.gitignore`:
```bash
cat .gitignore
```
Then append (only if not already present):
```
# Gitea workflow logs (local runners)
.gitea/workflows/*.log
```
The `.gitea/workflows/` YAML files themselves should be tracked in git.
- [ ] **Step 2: Commit**
```bash
git add .gitignore
git commit -m "chore: update gitignore for Gitea CI artifacts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: Push and verify CI pipeline
- [ ] **Step 1: Verify all changes**
```bash
git status
git log --oneline -10
```
Expected: clean working tree, all 7 commits ready to push.
- [ ] **Step 2: Push to main**
```bash
git push origin main
```
- [ ] **Step 3: Monitor CI run**
Watch Gitea CI at `https://git.imrnes.team/MythEclipse/GMW/actions`.
Expected outcome:
1. `build-and-push` job runs 3 matrix builds (backend, discord-gateway, proxy) in parallel (max 2)
2. Each image is pushed to `git.imrnes.team/MythEclipse/GMW/bete-*` with both `:latest` and `:{sha}` tags
3. `deploy` job SSHes into VPS, pulls images, restarts containers
4. All 3 containers `imphenbot-proxy`, `imphenbot-backend`, `imphenbot-discord-gateway` are running
- [ ] **Step 4: Verify containers on VPS**
```bash
# SSH into VPS and check
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
docker ps --filter 'name=imphenbot' --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker compose -f /opt/imphenbot/infra/docker/docker-compose.yml ps
"
```
- [ ] **Step 5: Verify no hot-deploy artifacts remain**
```bash
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
ls -la /opt/imphenbot/infra/docker/ | grep -E 'dist$' || echo 'No dist dirs — clean'
"
```
---
## Rollback
If the pipeline fails at any point:
1. **Fix and re-push**: Edit the broken file, commit, push to main — CI re-runs automatically
2. **Emergency rollback**: SSH to VPS, run `docker compose up -d` with a known-good IMAGE_TAG:
```bash
IMAGE_TAG=<last-working-sha> docker compose up -d
```
3. **Restore old CI**: Move `.github/workflows/deploy-docker.yml.disabled` back and push
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,736 @@
# Backend & Gateway Refactoring — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Clean up ~130 lines of dead/duplicate code, consolidate duplicated database initialization, and simplify the MessageStore layering in discord-gateway.
**Architecture:** Three independent tasks that can be done in any order. Task 1 consolidates database pool/drizzle init into `@bete/shared` so both services use one canonical pattern. Task 2 removes backward-compat function wrappers from `messageStore.ts`. Task 3 deletes dead files and functions.
**Tech Stack:** TypeScript, Node.js, Drizzle ORM, PostgreSQL, pnpm workspace
## Global Constraints
- All imports use `.js` extensions (ESM convention)
- Follow existing code style (Biome, 2-space indent)
- Keep `@bete/shared` as the single source of truth for shared infrastructure
- No package.json changes needed — `@bete/shared` already has `drizzle-orm` and `pg` as dependencies
- Do not change any business logic — only structural refactoring
---
### Task 1: Consolidate Database Initialization into `@bete/shared`
**Files:**
- Create: `packages/shared/src/database/init.ts`
- Modify: `packages/shared/src/database/pool.ts` — add `getPool()` export
- Modify: `packages/shared/src/index.ts` — export new `./database/init.js`
- Modify: `packages/shared/package.json` — add `"./database/init"` export entry
- Modify: `services/backend/src/shared/database/index.ts` — re-export from shared
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — re-export from shared
- Delete: (functions migrate, no file deletion here — both local files stay as thin wrappers)
**Interfaces:**
- Produces:
- `@bete/shared/database/init` exports:
- `let db: ReturnType<typeof drizzle> | null` (module-level, for getDatabase())
- `let rawPool: Pool | null` (module-level, for getPool())
- `initializeDatabase(schema?: Record<string, unknown>): Promise<ReturnType<typeof drizzle>>` — creates pool via `createPoolFromConfig`, wraps with `drizzle()`. Accepts optional schema object (gateway needs it, backend doesn't). Reads config from env/config module internally.
- `getDatabase(): ReturnType<typeof drizzle>` — throws if not initialized
- `getPool(): Pool` — returns raw pool for raw SQL queries, throws if not initialized
- `closeDatabase(): Promise<void>` — closes pool and nullifies references
- `executeAll(sql: string, params?: unknown[]): Promise<unknown[]>` — raw SQL query, returns all rows
- `executeGet(sql: string, params?: unknown[]): Promise<unknown>` — raw SQL query, returns first row or null
- `withDatabaseClient<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`
- [ ] **Step 1: Create `packages/shared/src/database/init.ts`**
This is the canonical database initialization module. It merges what both services currently do:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { closePool, createPoolFromConfig } from "@bete/shared/database/pool";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
import { config } from "../config/index.js";
const logger = createChildLogger("database.init");
let db: ReturnType<typeof drizzle> | null = null;
let rawPool: Pool | null = null;
export async function initializeDatabase(schema?: Record<string, unknown>) {
if (db !== null) return db;
const pool = config.DATABASE_URL
? createPoolFromConfig({
url: config.DATABASE_URL,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
})
: createPoolFromConfig({
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
});
rawPool = pool;
db = drizzle(pool, schema ? { schema } : undefined);
// Test connection
try {
const client = await pool.connect();
client.release();
logger.info("Database connection successful");
} catch (err) {
logger.error({ err }, "Failed to connect to database");
throw err;
}
return db;
}
export function getDatabase() {
if (db === null) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export function getPool() {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return rawPool;
}
export async function closeDatabase() {
if (rawPool !== null) {
await closePool(rawPool);
}
rawPool = null;
db = null;
logger.info("Database connection closed");
}
function convertPlaceholdersForPostgres(sql: string) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function executeAll(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows;
}
export async function executeGet(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows[0] ?? null;
}
export async function withDatabaseClient<T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const client = await rawPool.connect();
try {
return await callback(client);
} finally {
client.release();
}
}
```
**Note:** This uses `config` from `@bete/shared/config`. The backend's config proxies to that already (`services/backend/src/shared/config/index.ts` re-exports from `@bete/shared/config`). The gateway's config at `services/discord-gateway/src/shared/config/config.ts` has the same field names but is its own Zod schema. Since `@bete/shared/config` doesn't have the PostgreSQL pool config fields currently, we need to check what it exports.
Actually — `@bete/shared/config` may not have `POSTGRES_HOST` etc. Let me adjust: the `initializeDatabase` function should accept config values as parameters instead of reading from a shared config.
Revised approach for `packages/shared/src/database/init.ts`:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { closePool, createPoolFromConfig } from "./pool.js";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
const logger = createChildLogger("database.init");
let db: ReturnType<typeof drizzle> | null = null;
let rawPool: Pool | null = null;
export interface DatabaseConfig {
DATABASE_URL?: string;
POSTGRES_HOST?: string;
POSTGRES_PORT?: number;
POSTGRES_USER?: string;
POSTGRES_PASSWORD?: string;
POSTGRES_DB?: string;
POSTGRES_POOL_MIN?: number;
POSTGRES_POOL_MAX?: number;
}
export async function initializeDatabase(
cfg: DatabaseConfig,
schema?: Record<string, unknown>,
) {
if (db !== null) return db;
const pool = cfg.DATABASE_URL
? createPoolFromConfig({
url: cfg.DATABASE_URL,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
})
: createPoolFromConfig({
host: cfg.POSTGRES_HOST,
port: cfg.POSTGRES_PORT,
user: cfg.POSTGRES_USER,
password: cfg.POSTGRES_PASSWORD,
database: cfg.POSTGRES_DB,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
});
rawPool = pool;
db = drizzle(pool, schema ? { schema } : undefined);
try {
const client = await pool.connect();
client.release();
logger.info("Database connection successful");
} catch (err) {
logger.error({ err }, "Failed to connect to database");
throw err;
}
return db;
}
export function getDatabase() {
if (db === null) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export function getPool() {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return rawPool;
}
export async function closeDatabase() {
if (rawPool !== null) {
await closePool(rawPool);
}
rawPool = null;
db = null;
logger.info("Database connection closed");
}
function convertPlaceholdersForPostgres(sql: string) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function executeAll(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows;
}
export async function executeGet(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows[0] ?? null;
}
export async function withDatabaseClient<T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const client = await rawPool.connect();
try {
return await callback(client);
} finally {
client.release();
}
}
```
- [ ] **Step 2: Add export to `packages/shared/src/index.ts`**
```typescript
export * from "./database/init.js";
```
- [ ] **Step 3: Add export to `packages/shared/package.json`**
```json
"./database/init": "./dist/database/init.js",
```
- [ ] **Step 4: Build the shared package to verify it compiles**
```bash
cd /home/code/GMW/packages/shared
pnpm run build
```
- [ ] **Step 5: Rewrite `services/backend/src/shared/database/index.ts`**
Change to a thin wrapper that imports from `@bete/shared/database/init` and passes the backend's config:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, getPool as sharedGetPool, closeDatabase as sharedCloseDb } from "@bete/shared/database/init";
import { config } from "../config/index.js";
const logger = createChildLogger("database");
const dbConfig = {
DATABASE_URL: config.DATABASE_URL,
POSTGRES_HOST: config.POSTGRES_HOST,
POSTGRES_PORT: config.POSTGRES_PORT,
POSTGRES_USER: config.POSTGRES_USER,
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
POSTGRES_DB: config.POSTGRES_DB,
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
};
export async function initializeDatabase() {
logger.info("Initializing database");
return sharedInit(dbConfig);
}
export function getDatabase() {
return sharedGetDb();
}
export function getPool() {
return sharedGetPool();
}
export async function closeDatabase() {
logger.info("Closing database");
return sharedCloseDb();
}
```
- [ ] **Step 6: Rewrite `services/discord-gateway/src/shared/database/drizzle.ts`**
Change to a thin wrapper:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, closeDatabase as sharedCloseDb, executeAll as sharedExecAll, executeGet as sharedExecGet, withDatabaseClient as sharedWithClient } from "@bete/shared/database/init";
import { config } from "../../shared/config/config.js";
import * as schema from "./schema.js";
const logger = createChildLogger("drizzle");
const dbConfig = {
DATABASE_URL: config.DATABASE_URL,
POSTGRES_HOST: config.POSTGRES_HOST,
POSTGRES_PORT: config.POSTGRES_PORT,
POSTGRES_USER: config.POSTGRES_USER,
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
POSTGRES_DB: config.POSTGRES_DB,
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
};
export async function initializeDatabase() {
return sharedInit(dbConfig, schema);
}
export function getDatabase() {
return sharedGetDb();
}
export { sharedCloseDb as closeDatabase };
export { sharedExecAll as executeAll, sharedExecGet as executeGet, sharedWithClient as withDatabaseClient };
```
- [ ] **Step 7: Run typecheck on all packages to verify**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 8: Commit**
```bash
git add packages/shared/src/database/init.ts packages/shared/src/index.ts packages/shared/package.json
git add services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
git commit -m "refactor: consolidate database initialization into @bete/shared/database/init"
```
---
### Task 2: Remove Backward-Compat Function Wrappers from MessageStore
**Files:**
- Modify: `services/discord-gateway/src/modules/message-capture/messageStore.ts` — remove lines 310-398 (backward-compat wrappers), export singleton directly
- Modify: `services/discord-gateway/src/modules/message-capture/index.ts` — update re-exports to use `messageStore` singleton
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` — update imports to use `messageStore.methodName()`
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts` — update imports (uses `getConversationContextBefore` and `updateMessagesAIAnalysisBulk`)
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts` — update imports (uses many functions)
- Possibly modify: other files that import the wrapper functions
**Interfaces:**
- Consumes: Existing `MessageStore` class methods (unchanged signatures)
- Produces: Singleton `messageStore` instance as the single export point
The key insight: the backward-compat wrappers at lines 310-398 of `messageStore.ts` are function-level exports that delegate to `getInstance()`. Every importer can instead import the singleton `messageStore` instance and call methods on it directly.
Current importers of wrapper functions:
| File | Functions Used |
|------|---------------|
| `messageCapture.ts` | `getMessageById`, `insertMessageEdit`, `updateMessageAsEdited`, `updateMessageAsDeleted`, `upsertMessageForCapture` |
| `batchProcessor.ts` | `updateMessagesAIAnalysisBulk` |
| `batchScheduler.ts` | `getPendingMessagesByConversation` |
| `individualFallbackProcessor.ts` | `updateMessagesAIAnalysisBulk` |
| `moderationBuilders.ts` | `getMessageById` |
| `aiAnalysisWorker.ts` | `getConversationContextBefore`, `updateMessagesAIAnalysisBulk` |
| `aiAnalyzer.ts` | `getConversationKeysWithIncompleteAnalysis`, `getIncompleteMessagesByConversation`, `getMessageById`, `getPendingConversationKeys`, `updateMessageAIAnalysis` |
- [ ] **Step 1: Modify `messageStore.ts`** — replace backward-compat wrappers with a singleton export
Replace lines 22-33 (lazy singleton pattern) and lines 310-398 (wrapper functions) with:
```typescript
// ─── Singleton instance ─────────────────────────────────────────────────────
const logger = createChildLogger("message-store");
const database = getDatabase() as unknown as NodePgDatabase<typeof schema>;
export const messageStore = new MessageStore(database, logger);
```
Then remove everything from line 310 onward (the backward-compat function wrappers section).
- [ ] **Step 2: Update `message-capture/index.ts`**
Change the re-exports from individual functions to the `messageStore` singleton:
```typescript
export { messageStore } from "../message-capture/messageStore.js";
export {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "../message-capture/messageMetadata.js";
// ... rest unchanged
```
Also remove the individual function re-exports since they no longer exist.
- [ ] **Step 3: Update `messageCapture.ts`**
Change imports from:
```typescript
import {
getMessageById,
insertMessageEdit,
upsertMessageForCapture,
updateMessageAsDeleted,
updateMessageAsEdited,
} from "./messageStore.js";
```
To:
```typescript
import { messageStore } from "./messageStore.js";
```
Then update every call site:
- `upsertMessageForCapture(messageRecord)``messageStore.upsertMessageForCapture(messageRecord)`
- `insertMessageEdit(...)``messageStore.insertMessageEdit(...)`
- `updateMessageAsEdited(...)``messageStore.updateMessageAsEdited(...)`
- `updateMessageAsDeleted(...)``messageStore.updateMessageAsDeleted(...)`
- `getMessageById(...)``messageStore.getMessageById(...)`
- [ ] **Step 4: Update `batchProcessor.ts`**
Change from:
```typescript
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
```
To:
```typescript
import { messageStore } from "../message-capture/messageStore.js";
```
Then update call sites:
- `updateMessagesAIAnalysisBulk(updates)``messageStore.messages.updateMessagesAIAnalysisBulk(updates)`
Wait — `updateMessagesAIAnalysisBulk` is actually defined in `MessagesAnalysis` class, which is called via `MessageStore``MessagesDb``MessagesAnalysis`. Let me check the actual delegation chain.
Looking at the wrapper functions:
```typescript
export const updateMessagesAIAnalysisBulk = (
updates: Array<{ messageId: string; result: AIAnalysisUpdate }>,
): Promise<MessageRecord[]> =>
getInstance().updateMessagesAIAnalysisBulk(updates);
```
And in the class:
```typescript
class MessageStore {
readonly messages: MessagesDb;
// ...
}
class MessagesDb {
readonly analysis: MessagesAnalysis;
// ...
updateMessagesAIAnalysisBulk(...) {
return this.analysis.updateMessagesAIAnalysisBulk(...)
}
}
```
So the call chain is: `messageStore.messages.updateMessagesAIAnalysisBulk()`. But actually, looking at `MessagesDb`, it might have its own `updateMessagesAIAnalysisBulk` that delegates to `this.analysis.updateMessagesAIAnalysisBulk()`. Let me verify...
Actually, for simplicity and to minimize changes, let me look at whether `MessagesDb` has `updateMessagesAIAnalysisBulk` or if only the wrapper has it.
Let me check:
Actually I already read that `MessagesDb` has methods. Let me look at what methods `MessagesDb` exposes vs the wrapper functions.
Instead of guessing, the safe approach is to keep the thin function wrappers but simplify them. Actually, a better approach for this task:
**Revised approach:** Instead of making all importers use `messageStore.messages.analysis.methodName()`, add all the forwarded methods directly to the `MessageStore` class (which it already does for most), and just have external files import the singleton and call `messageStore.methodName()`.
Let me check what methods `MessageStore` already has vs what's only available as backward-compat wrappers:
Looking at the code:
- `insertMessageEdit` — EXISTS in MessageStore class (line 54)
- `upsertMessageForCapture` — EXISTS in MessageStore class
- `updateMessageAsEdited` — EXISTS in MessageStore class
- `updateMessageAsDeleted` — EXISTS in MessageStore class
- `getMessagesByChannel` — EXISTS in MessageStore class
- `updateMessageAIAnalysis` — EXISTS in MessageStore class
- `updateMessagesAIAnalysisBulk` — EXISTS in MessageStore class
- `getPendingAIAnalysisMessages` — EXISTS in MessageStore class
- `getMessageById` — EXISTS in MessageStore class
- `listMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
- `listReviewMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
- `getConversationContextBefore` — EXISTS in MessageStore class
- `getPendingMessagesByConversation` — EXISTS in MessageStore class
- `getPendingConversationKeys` — EXISTS in MessageStore class
- `getConversationKeysWithIncompleteAnalysis` — EXISTS in MessageStore class
- `getIncompleteMessagesByConversation` — EXISTS in MessageStore class
So every function wrapper has a corresponding method on `MessageStore` class. The change is straightforward.
Now, after creating the singleton `messageStore`, all importers just do `messageStore.updateMessagesAIAnalysisBulk(...)` instead of calling the bare function.
But there's one complication: `MessagesDb.updateMessagesAIAnalysisBulk` is actually calling `this.analysis.updateMessagesAIAnalysisBulk()`. Does the `MessageStore` class have its own direct `updateMessagesAIAnalysisBulk`? Let me check the class definition...
Actually, I already saw from the grep output that `MessageStore` class has `updateMessagesAIAnalysisBulk` — the wrapper says `getInstance().updateMessagesAIAnalysisBulk(updates)`, and the class has that method.
OK so the mapping is 1:1 between wrapper functions and MessageStore class methods. This is safe.
- [ ] **Step 5: Update `batchScheduler.ts`**
```typescript
// Before:
import { getPendingMessagesByConversation } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.getPendingMessagesByConversation(...)`
- [ ] **Step 6: Update `individualFallbackProcessor.ts`**
```typescript
// Before:
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.updateMessagesAIAnalysisBulk(...)`
- [ ] **Step 7: Update `moderationBuilders.ts`**
```typescript
// Before:
import { getMessageById } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.getMessageById(...)`
- [ ] **Step 8: Update `aiAnalysisWorker.ts`**
```typescript
// Before:
import { getConversationContextBefore, updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And update all call sites.
- [ ] **Step 9: Update `aiAnalyzer.ts`**
```typescript
// Before:
import {
getConversationKeysWithIncompleteAnalysis,
getIncompleteMessagesByConversation,
getMessageById,
getPendingConversationKeys,
updateMessageAIAnalysis,
} from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And update all call sites.
- [ ] **Step 10: Update `message-capture/index.ts`**
Remove individual function re-exports, replace with `messageStore`:
```typescript
export { messageStore } from "./messageStore.js";
export {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "./messageMetadata.js";
export type {
AIRecommendedAction,
AISeverity,
AIStatus,
AttachmentRecord,
MessageRecord,
VoiceSegmentRecord,
} from "./types.js";
export type { TextCaptureTarget } from "./messageCapture.js";
export {
captureMessage,
registerMessageCapture,
setEventBroadcaster,
} from "./messageCapture.js";
```
- [ ] **Step 11: Run typecheck**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 12: Commit**
```bash
git add services/discord-gateway/src/modules/message-capture/
git add services/discord-gateway/src/modules/ai-moderation/
git commit -m "refactor: remove backward-compat function wrappers from messageStore"
```
---
### Task 3: Remove Dead Code
**Files:**
- Delete: `services/backend/src/modules/response.ts` — empty deprecated file
- Delete: `services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts`
- Delete: `services/discord-gateway/src/modules/webhook-notifications/index.ts`
- Delete: `services/discord-gateway/src/modules/webhook-notifications/` (directory)
- Modify: `services/backend/src/ws/server.ts` — remove duplicate `broadcastBinaryToFrontend()` function, keep only `broadcastBinary()`
**Interfaces:**
- None — these are deletions only, no consumer impact
- [ ] **Step 1: Delete `modules/response.ts`**
```bash
rm /home/code/GMW/services/backend/src/modules/response.ts
```
- [ ] **Step 2: Fix `ws/server.ts`** — remove duplicate `broadcastBinaryToFrontend`
In `ws/server.ts`, `broadcastBinaryToFrontend` (line 238) and `broadcastBinary` (line 267) do exactly the same thing. Replace the `broadcastBinaryToFrontend(data)` call on line 147 with a call to `broadcastBinary(data)`, then delete the `broadcastBinaryToFrontend` function.
Edit line 147:
```typescript
// Before:
broadcastBinaryToFrontend(data);
// After:
broadcastBinary(data);
```
Remove the `broadcastBinaryToFrontend` function (lines 238-248):
```typescript
// Remove this entire function:
function broadcastBinaryToFrontend(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(data);
} catch (err) {
logger.error({ err }, "Failed to send binary to frontend client");
}
}
}
}
```
- [ ] **Step 3: Check if anything imports `webhook-notifications`**
```bash
grep -rn "webhook-notifications\|webhookNotifier\|triggerWebhook" /home/code/GMW/services/ --include='*.ts' | grep -v "node_modules" | grep -v "services/discord-gateway/src/modules/webhook-notifications/"
```
Expected: empty (confirmed earlier)
- [ ] **Step 4: Delete webhook-notifications module**
```bash
rm -rf /home/code/GMW/services/discord-gateway/src/modules/webhook-notifications/
```
- [ ] **Step 5: Run typecheck to verify no broken imports**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 6: Commit**
```bash
git add services/backend/src/modules/response.ts services/backend/src/ws/server.ts
git add services/discord-gateway/src/modules/webhook-notifications/
git commit -m "chore: remove dead code (response.ts, broadcastBinaryToFrontend, webhook-notifications)"
```
@@ -0,0 +1,579 @@
# Services Refactoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor backend (4.2k lines) and discord-gateway (17.9k lines) for consistency, reduced file sizes, deduplication, and pattern uniformity across 11 phases.
**Architecture:** Phase1-3 target backend unchanged; Phase4-8 split large gateway files; Phase9 deduplicates shared database init; Phase10-11 are minor consolidation. Each phase is independently testable by verifying the service still compiles and runs.
**Tech Stack:** TypeScript (ESM), Express 5, ws, Discord.js selfbot, Drizzle ORM, Redis (ioredis), pino logger, Biome (formatter)
## Global Constraints
- All files use ESM (`.js` extensions in imports)
- Biome formatter handles formatting — run `pnpm run format` after each phase
- TypeScript strict mode — run `pnpm run typecheck` after each phase (for node services)
- Logging uses `createChildLogger(context)` from `@bete/shared/logger`
- Import via barrel files where available
- No logic changes — pure refactoring
---
## Task 1: Fix messages.controller.ts pattern (Phase 1)
**Files:**
- Modify: `services/backend/src/modules/messages/messages.controller.ts`
**Interfaces:**
- Consumes: `asyncHandler` from `../../shared/middlewares/index.js`
- Produces: Same exported handler functions, but using decorator pattern
- [ ] **Step 1: Read current messages.controller.ts**
The file currently uses the convoluted pattern:
```ts
export function handleListMessages(req, res, next) {
return asyncHandler(async (req, res) => {
// ...
})(req, res, next);
}
```
- [ ] **Step 2: Rewrite all handlers to decorator pattern**
Replace every handler to use the clean decorator pattern:
```ts
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response } from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.controller");
export const handleListMessages = asyncHandler(async (req: Request, res: Response) => {
const query = messageQuerySchema.parse(req.query);
logger.debug({ query }, "Handling list messages request");
const result = await messagesService.listMessages(query);
res.json(result);
});
export const handleGetMessagesByChannel = asyncHandler(async (req: Request, res: Response) => {
const channelId = String(req.params.channelId ?? "");
if (!channelId) {
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
return;
}
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
res.json(result);
});
export const handleGetMessageById = asyncHandler(async (req: Request, res: Response) => {
const id = String(req.params.id ?? "");
if (!id) {
res.status(400).json({ error: "MISSING_ID" });
return;
}
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
});
export const handleGetImageMessages = asyncHandler(async (req: Request, res: Response) => {
const guildId = String(req.query.guildId ?? "");
if (!guildId) {
res.status(400).json({ error: "MISSING_GUILD_ID" });
return;
}
const limit = Number(req.query.limit) || 50;
logger.debug({ guildId, limit }, "Handling get image messages");
const result = await messagesService.getImageMessages(guildId, limit);
res.json(result);
});
export const handleGetAttachmentsByChannel = asyncHandler(async (req: Request, res: Response) => {
const channelId = String(req.params.channelId ?? "");
if (!channelId) {
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
return;
}
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(channelId, query);
res.json(result);
});
```
NOTE: The old pattern used `requireParam` from middlewares to validate params. The new pattern uses simple string checks with early returns. This is equivalent since `requireParam` threw `ValidationError` which the errorHandler middleware catches — but for these handlers the decorator pattern can't throw synchronously in the handler wrapper; the `asyncHandler` catches async rejects. Early return with explicit error response is cleaner.
- [ ] **Step 3: Verify the module still compiles**
Run: `cd /home/code/GMW && pnpm run typecheck`
Expected: No TypeScript errors
- [ ] **Step 4: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 5: Commit**
```bash
git add services/backend/src/modules/messages/messages.controller.ts
git commit -m "refactor(backend): fix messages.controller.ts to use decorator pattern
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Clean up response.ts usage (Phase 2)
**Files:**
- Modify: `services/backend/src/modules/health/health.controller.ts` (remove `success()` usage, use plain `res.json()`)
- Modify: `services/backend/src/modules/response.ts` (deprecate/remove)
**Interfaces:**
- Consumes: all response-producing route files
- Produces: consistent plain `res.json()` pattern everywhere
- [ ] **Step 1: Check all places that import from response.ts**
Run: `grep -r 'from.*response\.js' services/backend/src/`
- [ ] **Step 2: Remove `success()` usage from health.controller.ts**
Replace:
```ts
import { success } from "../response.js";
// ...
res.status(status).json(success(result));
```
With:
```ts
res.status(status).json({ success: true, data: result });
```
- [ ] **Step 3: Run biome format + typecheck**
Run: `cd /home/code/GMW && pnpm run format && pnpm run typecheck`
- [ ] **Step 4: Commit**
```bash
git add services/backend/src/modules/health/health.controller.ts services/backend/src/modules/response.ts
git commit -m "refactor(backend): remove response.ts helpers, inline health response
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Add ws/ barrel (Phase 3)
**Files:**
- Create: `services/backend/src/ws/index.ts`
- [ ] **Step 1: Create barrel file**
```ts
export { setBroadcastFunctions, clearBroadcastFunctions, broadcastEvent, broadcastBinary } from "./broadcast.js";
export { startRedisBridge, stopRedisBridge } from "./redis-bridge.js";
export { createWebSocketServer, closeWebSocketServer } from "./server.js";
```
- [ ] **Step 2: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 3: Commit**
```bash
git add services/backend/src/ws/index.ts
git commit -m "refactor(backend): add ws barrel index
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Split moderationPrompt.ts (Phase 4)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/text-analysis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/media-analysis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/stickers.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/emojis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/system.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts` (become barrel re-export)
- [ ] **Step 1: Read the full moderationPrompt.ts**
Read the file to identify all exports and their dependencies.
- [ ] **Step 2: Create `prompts/system.ts` — system prompt builder + shared helpers**
Move: `buildSystemPrompt` function, `sanitizeAiContent`, `escapeXml`, `buildCustomEmojiVisionPrompt`, any shared helper functions.
- [ ] **Step 3: Create `prompts/text-analysis.ts` — text moderation prompts**
Move: All text-specific prompt strings and builders.
- [ ] **Step 4: Create `prompts/media-analysis.ts` — image/video prompts**
Move: `buildGeneralImageVisionPrompt` and related media prompt builders.
- [ ] **Step 5: Create `prompts/stickers.ts` — sticker prompts**
Move: `buildStickerVisionPrompt`, `buildStickerTextOnlyWarning`.
- [ ] **Step 6: Create `prompts/emojis.ts` — emoji prompts**
Move: `buildCustomEmojiVisionPrompt` if it exists separately.
- [ ] **Step 7: Replace moderationPrompt.ts with barrel re-exports**
```ts
export { buildSystemPrompt, sanitizeAiContent } from "./prompts/system.js";
export { buildGeneralImageVisionPrompt } from "./prompts/media-analysis.js";
export { buildStickerVisionPrompt, buildStickerTextOnlyWarning } from "./prompts/stickers.js";
export { buildCustomEmojiVisionPrompt } from "./prompts/emojis.js";
```
- [ ] **Step 8: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
Expected: No errors. Existing importers continue to work via the barrel.
- [ ] **Step 9: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 10: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/prompts/ services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts
git commit -m "refactor(gateway): split moderationPrompt.ts into domain-specific prompt files
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Split moderationOrchestrator.ts (Phase 5)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts` (extract & re-export)
- Modify: `services/discord-gateway/src/modules/ai-moderation/index.ts` (update exports if needed)
- [ ] **Step 1: Read full moderationOrchestrator.ts**
Map all exports and dependencies.
- [ ] **Step 2: Extract `runTextOnlyBatch` into `textBatchProcessor.ts`**
Move the function and its helper `buildCorrectedFewShotExamples`. Export it.
- [ ] **Step 3: Extract `runMediaBatch` into `mediaBatchProcessor.ts`**
Move the function and all its dependencies. Export it.
- [ ] **Step 4: Extract `runSimpleTextFallback` into `simpleFallback.ts`**
Move the function. Export it.
- [ ] **Step 5: Update moderationOrchestrator.ts**
Replace extracted functions with imports:
```ts
export { runTextOnlyBatch } from "./textBatchProcessor.js";
export { runMediaBatch } from "./mediaBatchProcessor.js";
export { runSimpleTextFallback } from "./simpleFallback.js";
```
Keep the `runModerationAnalysis` entry point function which orchestrates text + media + caching.
- [ ] **Step 6: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 7: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 8: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts
git commit -m "refactor(gateway): split moderationOrchestrator into dedicated processors
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: Split mediaAnalysisClient.ts (Phase 6)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaCache.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts` (become barrel)
- [ ] **Step 1: Read full mediaAnalysisClient.ts**
Map all exports and dependencies across the 826 lines.
- [ ] **Step 2: Extract all cache logic into `mediaCache.ts`**
Move: LRU cache, phash dedup, `getCachedMediaAnalysis`, `setCachedMediaAnalysis`, `computeImagePhash`, `deleteCachedMediaAnalysis`, `acquireMediaAnalysisLock`.
- [ ] **Step 3: Extract all download logic into `mediaDownloader.ts`**
Move: Image download, video download, ffmpeg frame extraction, temporary file handling.
- [ ] **Step 4: Extract vision LLM logic into `visionAnalyzer.ts`**
Move: Vision LLM calls, message preparation for vision, `prepareMediaMessage`.
- [ ] **Step 5: Update mediaAnalysisClient.ts to re-export**
```ts
export { getCachedMediaAnalysis, setCachedMediaAnalysis, computeImagePhash } from "./mediaCache.js";
export { downloadAndExtractFrame } from "./mediaDownloader.js";
export { prepareMediaMessage, hasMediaContent } from "./visionAnalyzer.js";
```
- [ ] **Step 6: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 7: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 8: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/mediaCache.ts services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts
git commit -m "refactor(gateway): split mediaAnalysisClient into cache, downloader, and vision analyzer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: Extract retention cleanup from bootstrap.ts (Phase 7)
**Files:**
- Create: `services/discord-gateway/src/app/retention.ts`
- Modify: `services/discord-gateway/src/app/bootstrap.ts`
- [ ] **Step 1: Create `app/retention.ts`**
Move `deleteExpiredRecords` and `startRetentionCleanup` from `bootstrap.ts`:
```ts
import { createChildLogger } from "@bete/shared/logger";
import { lt, inArray } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js";
import * as schema from "../shared/database/schema.js";
import { messagesTable, attachmentsTable, voiceRecordingsTable } from "../shared/database/schema.js";
const log = createChildLogger("retention");
// ... move deleteExpiredRecords here ...
// ... move startRetentionCleanup here ...
export { startRetentionCleanup };
```
- [ ] **Step 2: Remove inline retention code from bootstrap.ts**
- Remove the `deleteExpiredRecords` function
- Remove the `startRetentionCleanup` function
- Add: `import { startRetentionCleanup } from "./retention.js";`
- Replace the call: call `startRetentionCleanup()` directly
- [ ] **Step 3: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 4: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 5: Commit**
```bash
git add services/discord-gateway/src/app/retention.ts services/discord-gateway/src/app/bootstrap.ts
git commit -m "refactor(gateway): extract retention cleanup from bootstrap into dedicated module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: Consolidate EventBroadcaster (Phase 8)
**Files:**
- Modify: `services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts`
- Modify: `services/discord-gateway/src/modules/event-broadcaster/index.ts`
- [ ] **Step 1: Read current eventBroadcaster.ts**
Identify `RedisEventPublisher` and `EventBroadcaster` classes.
- [ ] **Step 2: Merge RedisEventPublisher into EventBroadcaster**
Inline `RedisEventPublisher` as a private detail inside `EventBroadcaster`. Keep the public API unchanged.
- [ ] **Step 3: Update index.ts if needed**
Ensure the barrel still exports `EventBroadcaster`.
- [ ] **Step 4: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 5: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 6: Commit**
```bash
git add services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts services/discord-gateway/src/modules/event-broadcaster/index.ts
git commit -m "refactor(gateway): merge RedisEventPublisher into EventBroadcaster
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: Cross-cutting database initialization dedup (Phase 9)
**Files:**
- Modify: `packages/shared/src/database/schema.ts` — add database lifecycle helpers
- Modify: `services/backend/src/shared/database/index.ts` — use shared helpers
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — use shared helpers
- [ ] **Step 1: Check current shared database setup**
Read `packages/shared/` structure to see if there's already a database module.
- [ ] **Step 2: Add pool creation helper in @bete/shared**
In `packages/shared/src/database/schema.ts` or create `packages/shared/src/database/pool.ts`:
```ts
import { Pool } from "pg";
export function createPostgresPool(url: string, opts?: { min?: number; max?: number }): Pool {
return new Pool({
connectionString: url,
min: opts?.min ?? 2,
max: opts?.max ?? 10,
});
}
export interface PoolConfig {
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
url?: string;
min?: number;
max?: number;
}
export function createPoolFromConfig(cfg: PoolConfig): Pool {
if (cfg.url) return createPostgresPool(cfg.url, { min: cfg.min, max: cfg.max });
return new Pool({
host: cfg.host,
port: cfg.port,
user: cfg.user,
password: cfg.password,
database: cfg.database,
min: cfg.min ?? 2,
max: cfg.max ?? 10,
});
}
```
Export from `packages/shared/src/database/schema.ts` or create a barrel.
- [ ] **Step 3: Update backend's shared/database/index.ts**
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
- [ ] **Step 4: Update gateway's shared/database/drizzle.ts**
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
- [ ] **Step 5: Run typecheck across all services**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 6: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 7: Commit**
```bash
git add packages/shared/src/database/ services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
git commit -m "refactor: extract shared database pool creation into @bete/shared
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 10: Audit moderationState vs conversationState overlap (Phase 10)
**Files:**
- Read: `services/discord-gateway/src/modules/ai-moderation/moderationState.ts`
- Read: `services/discord-gateway/src/modules/ai-moderation/conversationState.ts`
- [ ] **Step 1: Read both files and identify overlap**
Look for duplicated state management (maps, sets, timers).
- [ ] **Step 2: If overlap found, merge into one file**
Otherwise, just add comments documenting the boundary.
- [ ] **Step 3: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/
git commit -m "refactor(gateway): consolidate conversattion/moderation state management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 11: Redis connection audit (Phase 11)
**Files:**
- Read: all Redis connection sites in gateway
- [ ] **Step 1: Identify all Redis connections**
Search for `new Redis(` patterns in gateway.
- [ ] **Step 2: Verify each has a valid reason for a separate connection**
Document with comments if needed.
- [ ] **Step 3: Commit (if any changes made)**
@@ -0,0 +1,455 @@
# CI/CD Overhaul: Gitea CI + Container Registry Design
**Status:** Draft
**Last updated:** 2026-07-27
## 1. Problem Statement
The current CI/CD pipeline has multiple issues:
1. **Split across 3 CI systems**: GitHub Actions (build + deploy), GitLab CI (build only, no deploy), and `deploy.sh` (hot-deploy bind-mounts)
2. **Registry mismatch**: GitHub Actions pushes to `ghcr.io` but `docker-compose.yml` references `registry.gitlab.com` — the deploy route is unclear
3. **Hot-deploy complexity**: `deploy.sh` builds locally, tars dist files, SSH pipes, and binds into containers at runtime. Fragile and not reproducible
4. **No frontend in Docker**: Frontend is never built into an image — only hot-deployed via bind-mounts
5. **Stale Dockerfile**: `Dockerfile.proxy` builds a Rust WASM frontend that no longer exists
6. **Dockerfile.frontend is missing**: Frontend image doesn't exist at all
7. **Shared package fragility**: The previous refactor added `@bete/shared/database/init` export, but Docker images built from `master` don't have it — containers crash
## 2. Goal
Single CI/CD pipeline that:
- Builds Docker images for all 3 services (backend, discord-gateway, proxy-serving-frontend)
- Pushes them to Gitea's built-in Container Registry
- On the VPS, only pulls images and restarts containers — no more hot-deploy bind-mounts
- All 3 services built in one pipeline, deployed together atomically
## 3. Architecture
```
Developer pushes to main
┌────────────────────────────┐
│ Gitea Runner (server X) │
│ │
│ Job 1: build-and-push │
│ ├── bete-backend:latest │──────────▶ Gitea Container Registry
│ ├── bete-discord-gateway │──────────▶ git.imrnes.team/MythEclipse/GMW/
│ │ :latest │ bete-backend:{sha,latest}
│ └── bete-proxy:latest │──────────▶ bete-discord-gateway:{sha,latest}
│ │──────────▶ bete-proxy:{sha,latest}
│ Job 2: deploy (SSH) │
│ └─── SSH ke VPS ──────────┤
└────────────────────────────┘
┌────────────────────────────┐
│ VPS Production │
│ /opt/imphenbot/infra/ │
│ docker/ │
│ │
│ docker compose pull │
│ docker compose up -d │
│ docker image prune -f │
│ │
│ 3 containers: │
│ ┌────────┐ ┌──────────┐ │
│ │ proxy │ │ backend │ │
│ │ :80 │ │ :3000 │ │
│ └───┬────┘ └──────────┘ │
│ │ ┌─────────────┐ │
│ └────┤discord- │ │
│ │gateway │ │
│ └─────────────┘ │
└────────────────────────────┘
```
### 3.1 Service Images
| Image | From | Runs |
|-------|------|------|
| `bete-backend` | `Dockerfile.backend` | Express HTTP/WS on port 3000 |
| `bete-discord-gateway` | `Dockerfile.discord-gateway` | Discord client, internal only |
| `bete-proxy` | `Dockerfile.proxy` (rewritten) | Nginx serving frontend + proxying `/api` and `/ws` to backend |
### 3.2 Registry
Gitea provides a built-in container registry per repository at:
```
git.imrnes.team/MythEclipse/GMW/<image-name>:<tag>
```
Images are tagged with both `latest` and the commit SHA for traceability.
## 4. Files to Create / Modify
### 4.1 Create: `.gitea/workflows/deploy.yml`
One workflow, two jobs:
```yaml
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, discord-gateway, proxy]
max-parallel: 2
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.GITEA_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
- name: Build & Push
uses: docker/build-push-action@v6
with:
context: .
file: infra/docker/Dockerfile.${{ matrix.service }}
push: true
tags: |
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:${{ github.sha }}
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
steps:
- name: SSH & Deploy
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/imphenbot/infra/docker
echo "${{ secrets.ENV_FILE }}" > .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
```
Note: Gitea CI uses GitHub Actions-compatible syntax (Act Runner). The above uses the standard `actions/*` actions and `docker/*` actions that work with both GitHub and Gitea. If Gitea's runner doesn't fully support `docker/build-push-action`, fallback to inline `docker build` and `docker push` commands.
Sensitive variables: `GITEA_REGISTRY_TOKEN`, `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `ENV_FILE` set in Gitea repo Settings → Actions → Secrets. Non-sensitive: `GITEA_REGISTRY` as a Variable.
### 4.2 Rewrite: `Dockerfile.proxy`
Current proxy Dockerfile builds a Rust WASM frontend (stale — no longer exists in codebase). Replace with multi-stage build:
```dockerfile
# Stage 1: Build frontend (Next.js 16 static export)
FROM node:22-slim AS frontend-builder
WORKDIR /app
# Install pnpm
RUN corepack enable
# Copy dependency manifests
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/frontend/package.json ./services/frontend/package.json
# Install dependencies
RUN pnpm install --frozen-lockfile --filter './services/frontend' --filter '@bete/shared'
# Copy source code
COPY packages/shared/ ./packages/shared/
COPY services/frontend/ ./services/frontend/
# Build Next.js static export
RUN pnpm --filter frontend run build
# Result in services/frontend/out/
# Stage 2: Nginx
FROM nginx:alpine
# Nginx config
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Static frontend files
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
```
### 4.3 Modify: `Dockerfile.backend`
Add `VITE_BE_API_URL` and `VITE_BE_WS_URL` build args (already listed in GitHub Actions but not in Dockerfile):
```dockerfile
# Add to existing Dockerfile.backend — after FROM, before WORKDIR
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
```
These build args are now consumed at build time for future-proofing even though they were previously only needed for frontend builds (which now lives in the proxy Dockerfile).
### 4.4 Modify: `Dockerfile.discord-gateway`
No structural changes needed — verify Drizzle migrations path:
```dockerfile
# COPY drizzle, line in existing Dockerfile.discord-gateway:
COPY services/discord-gateway/drizzle/ ./services/discord-gateway/drizzle/
# This should work as-is since workspace is copied at /app
```
### 4.5 Rewrite: `deploy.sh`
From hot-deploy tar-pipe SSH to lightweight SSH exec:
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/infra/docker"
: "${VPS_HOST:?required}"
: "${VPS_USER:?required}"
: "${VPS_SSH_KEY:?required}"
echo "=== Deploy to $VPS_HOST ==="
# Copy .env if it exists locally
if [ -f "$INFRA_DIR/.env" ]; then
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
fi
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
set -e
cd /opt/imphenbot/infra/docker
echo "=== Pulling images ==="
docker compose pull
echo "=== Restarting containers ==="
docker compose up -d --remove-orphans
echo "=== Cleaning up ==="
docker image prune -f
echo "=== Verify ==="
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
REMOTESCRIPT
echo "=== Deploy complete ==="
```
### 4.6 Rewrite: `infra/docker/docker-compose.yml`
Replace all GitLab registry image references with Gitea registry. Remove bind-mounts. Add recordings named volume.
```yaml
version: "3.8"
services:
proxy:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-proxy:${IMAGE_TAG:-latest}
container_name: imphenbot-proxy
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
networks:
- app-shared-net
healthcheck:
test: wget -qO- http://localhost:80/ || exit 1
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
deploy:
resources:
limits:
memory: 64M
labels:
traefik.enable: "true"
traefik.http.routers.imphenbot.rule: "Host(`imphnen.asepharyana.my.id`)"
traefik.http.routers.imphenbot.entrypoints: websecure
traefik.http.routers.imphenbot.tls: "true"
traefik.http.services.imphenbot.loadbalancer.server.port: "80"
backend:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-backend:${IMAGE_TAG:-latest}
container_name: imphenbot-backend
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
WEBSERVER_PORT: 3000
networks:
- app-shared-net
healthcheck:
test: wget -qO- http://localhost:3000/api/health || exit 1
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
deploy:
resources:
limits:
memory: 256M
depends_on:
- proxy
discord-gateway:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-discord-gateway:${IMAGE_TAG:-latest}
container_name: imphenbot-discord-gateway
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
volumes:
- recordings:/app/recordings
networks:
- app-shared-net
healthcheck:
test: sh -c "kill -0 1"
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
deploy:
resources:
limits:
memory: 512M
volumes:
recordings:
networks:
app-shared-net:
external: true
```
Key changes:
- Image refs: `registry.gitlab.com/mytheclipse-group/gmw/...``${GITEA_REGISTRY}/${GITEA_REPO}/...`
- **All bind-mounts removed** (`./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`)
- `recordings` → named volume (persists across container restarts/recreates)
- `proxy` binds to `127.0.0.1:8080` instead of host port 80 (Traefik handles external routing)
- Added `depends_on: proxy` to backend for startup ordering
### 4.7 Remove: GitHub Actions & GitLab CI files
After Gitea CI is verified working:
- Delete `.github/workflows/deploy-docker.yml` (or rename to `.github/workflows/deploy-docker.yml.disabled`)
- Delete `.gitlab-ci.yml` (or rename to `.gitlab-ci.yml.disabled`)
### 4.8 Ensure: `.gitea/workflows/` directory
The directory must exist in git. Some setups ignore `.gitea/` — verify `.gitignore` does not exclude it.
## 5. Gitea Registry Integration
### 5.1 Enable Container Registry in Gitea
In Gitea Admin Settings:
- Go to Settings → Repository → Enable "Container Registry"
- Default registry URL format: `gitea.<domain>/<owner>/<repo>`
### 5.2 Registry Token
Create a Gitea access token with `read` and `write` access to packages:
- Settings → Applications → Generate Token → `registry-token` → scope: `write:packages`
### 5.3 CI Variables
Set these in Gitea repo → Settings → Actions → Secrets:
| Name | Example Value | Notes |
|------|---------------|-------|
| `GITEA_REGISTRY_TOKEN` | `gitea_token_abc123` | Docker login password |
| `VPS_HOST` | `123.123.123.123` | VPS IP/hostname |
| `VPS_USER` | `root` | SSH user |
| `VPS_SSH_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | Private key |
| `ENV_FILE` | full .env content | Written to VPS before compose |
As Variables (not secrets, visible but non-sensitive):
| Name | Example Value | Notes |
|------|---------------|-------|
| `GITEA_REGISTRY` | `git.imrnes.team` | Registry hostname — no protocol prefix |
### 5.4 VPS Setup (one-time)
```bash
# 1. Docker login to Gitea registry
docker login git.imrnes.team
# Use Gitea username + access token (with write:packages scope)
# 2. Create recordings named volume
docker volume create imphenbot_recordings
# 3. Remove old bind-mount directories (after verifying old containers stopped)
rm -rf /opt/imphenbot/infra/docker/backend-dist
rm -rf /opt/imphenbot/infra/docker/gateway-dist
rm -rf /opt/imphenbot/infra/docker/shared-dist
rm -rf /opt/imphenbot/infra/docker/frontend-dist
# 4. Ensure compose file is updated (via git pull)
cd /opt/imphenbot && git pull origin main
```
## 6. Migration Plan
### Phase 1: Prepare (this session)
1. Write `.gitea/workflows/deploy.yml`
2. Rewrite `Dockerfile.proxy` for Next.js
3. Modify `infra/docker/docker-compose.yml` for Gitea registry + named volumes
4. Rewrite `deploy.sh` to SSH-only
5. Mark old CI files as disabled (rename, not delete yet)
6. Add VITE_BE_API_URL/VITE_BE_WS_URL build args to backend Dockerfile
### Phase 2: VPS Preparation (one-time SSH)
7. User runs `docker login` to Gitea registry on VPS
8. User sets CI secrets in Gitea UI
9. User creates `imphenbot_recordings` named volume
### Phase 3: Deploy
10. Commit and push to `main`
11. Gitea CI triggers — builds 3 images, pushes to registry
12. Deploy job SSHes into VPS, pulls images, restarts containers
13. Verify with `docker ps` and health checks
### Phase 4: Cleanup
14. After all services running stably for 1-2 pushes: delete old CI files
15. Remove old Dockerfiles if no longer referenced
## 7. Rollback Plan
If something goes wrong:
1. **Quick rollback**: `docker compose up -d` with previous `IMAGE_TAG` (pin to last working SHA)
2. **Full rollback**: Revert git changes, push to `main` — Gitea CI will rebuild with old config
3. **Emergency**: SSH to VPS, use `docker compose` commands to restart specific containers
## 8. Future Considerations
- **Auto-deploy on tag**: Optionally trigger CI only on version tags (`v*`) instead of every `main` push
- **Health check notifications**: Add webhook notification on deploy failure
- **Multi-architecture builds**: Add `--platform linux/amd64,linux/arm64` for future ARM VPS migration
- **Secrets management**: Consider HashiCorp Vault or Gitea's built-in encrypted secrets for larger teams
@@ -0,0 +1,134 @@
# Refactoring Backend & Discord-Gateway Services
**Date:** 2026-07-27
**Status:** Draft
## Overview
Comprehensive refactoring of `services/backend` (4.2k lines) and `services/discord-gateway` (17.9k lines) targeting code consistency, file-size reduction, deduplication, and pattern uniformity.
## Scope
### Phase 1 — Backend Controller Consistency
**Problem:** Two competing controller patterns.
- `messages.controller.ts`, `mascot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
**Fix:** Convert all controllers to **Gaya B** (decorator pattern).
Before (Gaya A):
```ts
export function handleListMessages(req, res, next) {
return asyncHandler(async (req, res) => {
// ...
})(req, res, next);
}
```
After (Gaya B):
```ts
export const handleListMessages = asyncHandler(async (req, res) => {
// ...
});
```
**Files affected:**
- `modules/messages/messages.controller.ts`
- `modules/mascot-chat/mascot-chat.controller.ts`
### Phase 2 — Backend `response.ts` Cleanup
**Problem:** `success()`/`error()` helpers exist but are unused (except health controller).
**Fix:** Apply `success()` consistently to all API responses that are successful data returns. Remove `error()` if unused after audit.
**Files affected:** All route/service files that `res.json()` data.
### Phase 3 — Backend `ws/` Barrel
**Problem:** `ws/broadcast.ts`, `ws/redis-bridge.ts`, `ws/server.ts` — no barrel.
**Fix:** Add `ws/index.ts` barrel.
### Phase 4 — Gateway: Split `moderationPrompt.ts` (1015 lines)
**Problem:** Monolithic prompt file mixing all prompt types.
**Fix:** Split into:
- `prompts/text-analysis.ts` — Text moderation prompts
- `prompts/media-analysis.ts` — Image/video analysis prompts
- `prompts/stickers.ts` — Sticker analysis prompts
- `prompts/emojis.ts` — Custom emoji prompts
- `prompts/system.ts` — System prompt builder and shared helpers
### Phase 5 — Gateway: Split `moderationOrchestrator.ts` (955 lines)
**Problem:** Entry point that also contains inline text-only batch, media batch, and simple fallback.
**Fix:** Extract into:
- `textBatchProcessor.ts` — All text-only batching logic
- `mediaBatchProcessor.ts` — All media batching logic
- `simpleFallback.ts` — The `runSimpleTextFallback` function
### Phase 6 — Gateway: Split `mediaAnalysisClient.ts` (826 lines)
**Problem:** Cache logic (LRU + phash + DB), download logic (image/video + ffmpeg), and vision LLM in one file.
**Fix:** Extract into:
- `mediaCache.ts` — All caching layers (LRU, phash dedup, DB)
- `mediaDownloader.ts` — Image/video download, ffmpeg frame extraction
- `visionAnalyzer.ts` — Vision LLM orchestration
### Phase 7 — Gateway: Consolidate `bootstrap.ts`
**Problem:** 304-line bootstrap that embeds retention cleanup inline.
**Fix:** Extract `startRetentionCleanup` into `app/retention.ts`. Leave event registrations in bootstrap as they're inherently app-wide wiring.
### Phase 8 — Gateway: Simplify EventBroadcaster
**Problem:** `RedisEventPublisher` wrapping is thin — only adds a `publish` wrapper.
**Fix:** Merge `RedisEventPublisher` into `EventBroadcaster` as a private inner detail.
### Phase 9 — Cross-cutting: Database initialization dedup
**Problem:** Backend (`shared/database/index.ts`) and gateway (`shared/database/drizzle.ts`) have near-identical pool creation and lifecycle code.
**Fix:** Extract common pool/drizzle lifecycle into `@bete/shared`:
```ts
// packages/shared/src/database/index.ts
export function createDatabasePool(url: string, opts?: PoolOpts): Pool
export function createDrizzleClient(pool: Pool): DrizzleClient
export function closePool(pool: Pool): Promise<void>
```
Both services keep their own getDatabase/close wrappers but delegate pool creation to shared.
### Phase 10 — Gateway: Consolidate `moderationState.ts` / `conversationState.ts`
**Problem:** Two state files with overlapping concerns.
**Fix:** Audit both for overlap, merge if significant duplication found.
### Phase 11 — Gateway: Redis connection usage audit
**Problem:** Multiple independent Redis connections for EventBroadcaster and CommandHandler.
**Fix:** Both already need separate connections (Redis pub/sub limits). Document the pattern. No structural change.
## Files Changed
| Phase | Files | Type |
|-------|-------|------|
| 1 | 3 | edit |
| 2 | ~15 | edit |
| 3 | 1 | create |
| 4 | ~6 | split |
| 5 | ~4 | split |
| 6 | ~4 | split |
| 7 | 2 | split |
| 8 | 2 | refactor |
| 9 | 2 | refactor |
| 10 | 1-2 | audit+merge |
@@ -0,0 +1,37 @@
# Visual Redesign: Discord Automod Dashboard
## Design Direction
**Vibe:** "Monitoring hub" — deep, technical, trustworthy. Think security operations center meets modern dev tool.
## Palette
**Dark (primary):**
| Token | Value | Role |
|-------|-------|------|
| `--bg` | `oklch(0.09 0.015 245)` | Deeper navy canvas |
| `--card` | `oklch(0.13 0.02 245)` | Surface with subtle separation |
| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent (shift from sky blue) |
| `--accent` | `oklch(0.7 0.18 260)` | Electric blue-purple for secondary highlights |
| `--warn` | `oklch(0.7 0.17 75)` | Amber-gold for warnings (distinct from red) |
| `--border` | `oklch(1 0 0 / 0.06)` | Softer borders |
## Typography
- Geist Sans (body) + Geist Mono (code/data) — already loaded
- H1: `text-lg font-semibold tracking-tight`
- Card titles: `text-sm font-semibold tracking-tight`
- Labels/captions: `text-xs text-muted-foreground tracking-wide uppercase`
## Layout Changes
1. **Background**: Subtle dot-grid pattern (`radial-gradient(circle, oklch(1 0 0 / 0.03) 1px, transparent 1px)`) — monitoring station feel
2. **Sidebar**: Slightly wider (w-64), active item gets a glow bar + subtle teal tint background, connection dot with breathing animation
3. **Cards**: Hover state adds a thin teal border-top glow, softer shadow
4. **Stat cards**: Gradient background per stat type (like live-stats had), with icon in colored bubble
5. **Severity indicators**: Colored dot + label instead of just colored border
6. **Mobile nav**: Tighter spacing, active indicator as dot above icon
7. **Header**: Clean, thin bottom border glow, page title larger
## Signature Element
- **Grid background** + **teal glow** on active/interactive elements
- **Gradient accent bar** on sidebar active item (wider, glowing)
+6
View File
@@ -2,6 +2,12 @@ FROM node:22-slim
WORKDIR /app WORKDIR /app
# Build args for frontend API URLs
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
# Install pnpm # Install pnpm
RUN npm install -g pnpm RUN npm install -g pnpm
+60 -54
View File
@@ -1,67 +1,73 @@
# ---- Stage 1: Build native deps + TypeScript ----
FROM node:22-slim AS builder
WORKDIR /app
RUN corepack enable
# Build tools + Rust for native compilation
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
python3 make g++ curl ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
# Dependency manifests
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/discord-gateway/package.json ./services/discord-gateway/package.json
# Install ALL deps (including devDeps needed for build)
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Source code
COPY packages/shared ./packages/shared
COPY services/discord-gateway ./services/discord-gateway
COPY services/discord-gateway/drizzle ./drizzle
# Build
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './packages/shared' run build
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './services/discord-gateway' run build
# Create production-only deployment (no devDeps)
RUN pnpm deploy --filter '@bete/discord-gateway' /tmp/deploy \
&& rm -rf /tmp/deploy/node_modules/.pnpm/@biomejs+biome@* \
/tmp/deploy/node_modules/.pnpm/@biomejs+cli-linux-x64@* \
/tmp/deploy/node_modules/.pnpm/typescript@* \
/tmp/deploy/node_modules/.pnpm/drizzle-kit@* \
/tmp/deploy/node_modules/.pnpm/vitest@* \
/tmp/deploy/node_modules/.pnpm/esbuild@* \
/tmp/deploy/node_modules/.pnpm/tsx@* \
/tmp/deploy/node_modules/.pnpm/@types* \
/tmp/deploy/node_modules/.pnpm/@rolldown* \
/tmp/deploy/node_modules/.pnpm/@vitest*
# ---- Stage 2: Runtime (minimal) ----
FROM node:22-slim FROM node:22-slim
WORKDIR /app WORKDIR /app
# Install pnpm # Runtime system deps only
RUN npm install -g pnpm
# Create non-root user
RUN groupadd --system app && useradd --system -g app app
# Install build tools for native dependencies (node-crc, @discordjs/opus)
# and ffmpeg for voice transmit (PCM to OggOpus encoding)
# This is done early to cache this expensive layer
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
python3 make g++ ffmpeg curl ca-certificates \ ffmpeg ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Rust via rustup (Debian's rustc/cargo is too old for node-crc) # Copy production-only artifacts from deploy
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable COPY --from=builder /tmp/deploy/node_modules ./node_modules
ENV PATH="/root/.cargo/bin:${PATH}" COPY --from=builder /tmp/deploy/package.json ./package.json
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/services/discord-gateway/dist ./services/discord-gateway/dist
COPY --from=builder /app/drizzle ./drizzle
# Copy dependency definition files first for better caching # Create recordings dir and set ownership
COPY pnpm-workspace.yaml . RUN mkdir -p /app/recordings && chown -R node:node /app
COPY pnpm-lock.yaml . USER node
COPY package.json .
# Copy patches (pnpm patchedDependencies)
COPY patches ./patches
# Copy packages (workspace dependencies)
COPY packages/shared ./packages/shared
# Copy Drizzle migrations (relative path used by migrator)
COPY services/discord-gateway/drizzle ./drizzle
# Copy service source (last — changes most often)
COPY services/discord-gateway ./services/discord-gateway
# Install dependencies with build cache
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Build shared workspace dependency first
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './packages/shared' run build
# Build discord gateway
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './services/discord-gateway' run build
# Create recordings directory
RUN mkdir -p /app/recordings
# Own everything by non-root user
RUN chown -R app:app /app
# Switch to non-root user
USER app
# Expose no HTTP port (gateway is internal-only)
# Healthcheck — verify PID 1 (node) is still running (no HTTP server in this service)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD sh -c "kill -0 1" CMD sh -c "kill -0 1"
# Start discord gateway
CMD ["node", "services/discord-gateway/dist/index.js"] CMD ["node", "services/discord-gateway/dist/index.js"]
+41 -17
View File
@@ -1,32 +1,56 @@
# ---- Builder Stage (Frontend WASM) ---- # ---- Stage 1: Build Next.js static export ----
FROM rust:alpine AS frontend-builder FROM node:22-slim AS frontend-builder
RUN apk add --no-cache musl-dev
RUN rustup target add wasm32-unknown-unknown
RUN cargo install trunk --version 0.22.0-beta.1 --locked
WORKDIR /app WORKDIR /app
# Copy workspace definition and lock file for dependency caching # Install pnpm
COPY services/frontend/Cargo.toml services/frontend/Cargo.lock ./ RUN corepack enable
# Copy shared-types library # Install build essentials for native deps
COPY services/frontend/shared-types ./shared-types/ RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
python3 make g++ && rm -rf /var/lib/apt/lists/*
# Copy frontend source # Copy dependency manifests first for layer caching
COPY services/frontend/frontend ./frontend/ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/frontend/package.json ./services/frontend/package.json
COPY services/frontend/tsconfig.json ./services/frontend/tsconfig.json
# Build WASM bundle via trunk # Copy patches (pnpm patchedDependencies)
RUN cd frontend && trunk build --release COPY patches/ ./patches/
# ---- Runner Stage ---- # Install dependencies (frontend + shared)
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile --filter './packages/shared' --filter './services/frontend'
# Copy source code
COPY packages/shared/ ./packages/shared/
COPY services/frontend/ ./services/frontend/
# Pass API/WS URLs as build args
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
# Build shared lib first, then frontend static export
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './packages/shared' run build
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter frontend run build
# ---- Stage 2: Nginx ----
FROM nginx:alpine FROM nginx:alpine
# Nginx config (API/WS proxy + static file serving)
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy frontend static files from builder stage # Static export from frontend builder
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+7 -19
View File
@@ -1,10 +1,8 @@
version: '3.8' version: '3.8'
services: services:
# Nginx Reverse Proxy + Frontend Static Files
# Routes /api and /ws to backend, serves frontend WASM directly
proxy: proxy:
image: registry.gitlab.com/mytheclipse-group/gmw/bete-proxy:${IMAGE_TAG:-latest} image: ${REGISTRY}/mytheclipse/gmw/bete-proxy:${IMAGE_TAG:-latest}
container_name: imphenbot-proxy container_name: imphenbot-proxy
restart: unless-stopped restart: unless-stopped
labels: labels:
@@ -20,10 +18,6 @@ services:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
volumes:
# Bind mount for hot-deploy frontend — deploy.sh writes here,
# survives container restart. The image default serves as fallback.
- ./frontend-dist:/usr/share/nginx/html
deploy: deploy:
resources: resources:
limits: limits:
@@ -31,9 +25,8 @@ services:
networks: networks:
- app-shared-net - app-shared-net
# Backend Service (REST API + WebSocket)
backend: backend:
image: registry.gitlab.com/mytheclipse-group/gmw/bete-backend:${IMAGE_TAG:-latest} image: ${REGISTRY}/mytheclipse/gmw/bete-backend:${IMAGE_TAG:-latest}
container_name: imphenbot-backend container_name: imphenbot-backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -41,10 +34,6 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
WEBSERVER_PORT: 3000 WEBSERVER_PORT: 3000
volumes:
# Bind mount for hot-deploy backend JS — deploy.sh writes here,
# survives container restart.
- ./backend-dist:/app/services/backend/dist
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s interval: 30s
@@ -58,9 +47,8 @@ services:
networks: networks:
- app-shared-net - app-shared-net
# Discord Gateway Service (Event capture and processing — no HTTP)
discord-gateway: discord-gateway:
image: registry.gitlab.com/mytheclipse-group/gmw/bete-discord-gateway:${IMAGE_TAG:-latest} image: ${REGISTRY}/mytheclipse/gmw/bete-discord-gateway:${IMAGE_TAG:-latest}
container_name: imphenbot-discord-gateway container_name: imphenbot-discord-gateway
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -68,10 +56,7 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
volumes: volumes:
- ./recordings:/app/recordings - recordings:/app/recordings
# Bind mount for hot-deploy gateway JS — deploy.sh writes here,
# survives container restart.
- ./gateway-dist:/app/services/discord-gateway/dist
healthcheck: healthcheck:
test: ["CMD-SHELL", "kill -0 1 || exit 1"] test: ["CMD-SHELL", "kill -0 1 || exit 1"]
interval: 30s interval: 30s
@@ -85,6 +70,9 @@ services:
networks: networks:
- app-shared-net - app-shared-net
volumes:
recordings:
networks: networks:
app-shared-net: app-shared-net:
name: app-shared-net name: app-shared-net
+4
View File
@@ -8,7 +8,9 @@
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
"./config": "./dist/config/index.js", "./config": "./dist/config/index.js",
"./database/pool": "./dist/database/pool.js",
"./database/schema": "./dist/database/schema.js", "./database/schema": "./dist/database/schema.js",
"./database/init": "./dist/database/init.js",
"./errors": "./dist/errors/index.js", "./errors": "./dist/errors/index.js",
"./logger": "./dist/logger/index.js", "./logger": "./dist/logger/index.js",
"./moderation-types": "./dist/moderation-types.js", "./moderation-types": "./dist/moderation-types.js",
@@ -21,11 +23,13 @@
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pg": "^8.13.0",
"pino": "^9.0.0", "pino": "^9.0.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.0", "@types/node": "^25.9.0",
"@types/pg": "^8.11.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
+10
View File
@@ -22,6 +22,16 @@ export const configSchema = z
MONITOR_GUILD_ID: z.string().min(1).optional(), MONITOR_GUILD_ID: z.string().min(1).optional(),
TEXT_GUILD_ID: z.string().min(1).optional(), TEXT_GUILD_ID: z.string().min(1).optional(),
TEXT_CHANNEL_ID: z.string().min(1).optional(), TEXT_CHANNEL_ID: z.string().min(1).optional(),
EXCLUDED_CHANNEL_IDS: z
.string()
.default("")
.transform((v) => v.split(",").filter(Boolean))
.describe("Channel IDs to exclude from capture"),
EXCLUDED_THREAD_IDS: z
.string()
.default("")
.transform((v) => v.split(",").filter(Boolean))
.describe("Thread IDs to exclude from capture"),
// ── Legacy voice ───────────────────────────────────────────────────── // ── Legacy voice ─────────────────────────────────────────────────────
VOICE_GUILD_ID: z.string().min(1).optional(), VOICE_GUILD_ID: z.string().min(1).optional(),
+131
View File
@@ -0,0 +1,131 @@
import { createChildLogger } from "@bete/shared/logger";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
import { closePool, createPoolFromConfig } from "./pool.js";
const logger = createChildLogger("database.init");
let db: ReturnType<typeof drizzle> | null = null;
let rawPool: Pool | null = null;
export interface DatabaseConfig {
DATABASE_URL?: string;
POSTGRES_HOST?: string;
POSTGRES_PORT?: number;
POSTGRES_USER?: string;
POSTGRES_PASSWORD?: string;
POSTGRES_DB?: string;
POSTGRES_POOL_MIN?: number;
POSTGRES_POOL_MAX?: number;
}
export async function initializeDatabase(
cfg: DatabaseConfig,
schema?: Record<string, unknown>,
) {
if (db !== null) return db;
const pool = cfg.DATABASE_URL
? createPoolFromConfig({
url: cfg.DATABASE_URL,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
})
: createPoolFromConfig({
host: cfg.POSTGRES_HOST,
port: cfg.POSTGRES_PORT,
user: cfg.POSTGRES_USER,
password: cfg.POSTGRES_PASSWORD,
database: cfg.POSTGRES_DB,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
});
rawPool = pool;
if (schema) {
db = drizzle(pool, { schema });
} else {
db = drizzle(pool);
}
try {
const client = await pool.connect();
client.release();
logger.info("Database connection successful");
} catch (err) {
logger.error({ err }, "Failed to connect to database");
throw err;
}
return db;
}
export function getDatabase() {
if (db === null) {
throw new Error(
"Database not initialized. Call initializeDatabase() first.",
);
}
return db;
}
export function getPool() {
if (!rawPool) {
throw new Error(
"Database not initialized. Call initializeDatabase() first.",
);
}
return rawPool;
}
export async function closeDatabase() {
if (rawPool !== null) {
await closePool(rawPool);
}
rawPool = null;
db = null;
logger.info("Database connection closed");
}
function convertPlaceholdersForPostgres(sql: string) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function executeAll(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error(
"Database not initialized. Call initializeDatabase() first.",
);
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows;
}
export async function executeGet(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error(
"Database not initialized. Call initializeDatabase() first.",
);
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows[0] ?? null;
}
export async function withDatabaseClient<T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!rawPool) {
throw new Error(
"Database not initialized. Call initializeDatabase() first.",
);
}
const client = await rawPool.connect();
try {
return await callback(client);
} finally {
client.release();
}
}
+36
View File
@@ -0,0 +1,36 @@
import { type PoolConfig as PgPoolConfig, Pool } from "pg";
export interface PoolConfig {
url?: string;
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
min?: number;
max?: number;
}
export function createPoolFromConfig(cfg: PoolConfig): Pool {
const opts: PgPoolConfig = {
min: cfg.min ?? 2,
max: cfg.max ?? 10,
};
if (cfg.url) {
opts.connectionString = cfg.url;
} else {
opts.host = cfg.host;
opts.port = cfg.port;
opts.user = cfg.user;
opts.password = cfg.password;
opts.database = cfg.database;
}
return new Pool(opts);
}
export function closePool(pool: Pool | null): Promise<void> {
if (!pool) return Promise.resolve();
return pool.end();
}
+444 -30
View File
@@ -4,11 +4,18 @@ import {
foreignKey as pgForeignKey, foreignKey as pgForeignKey,
index as pgIndex, index as pgIndex,
integer as pgInteger, integer as pgInteger,
jsonb as pgJsonb,
real as pgReal, real as pgReal,
pgTable, pgTable,
text as pgText, text as pgText,
timestamp as pgTimestamp,
uuid as pgUuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// =============================================================================
// Messages
// =============================================================================
/** /**
* Messages Table (PostgreSQL) * Messages Table (PostgreSQL)
* Stores text messages with AI moderation analysis * Stores text messages with AI moderation analysis
@@ -101,36 +108,7 @@ export const pgMessagesTable = pgTable(
}), }),
); );
/** export const messagesTable = pgMessagesTable;
* Corrected Moderations Table (PostgreSQL)
* Stores manual corrections of AI moderation false positives
* for few-shot injection into LLM moderation prompts.
*/
export const pgCorrectedModerationsTable = pgTable(
"corrected_moderations",
{
id: pgText("id").primaryKey(),
message_id: pgText("message_id").notNull(),
original_flags: pgText("original_flags").notNull(),
corrected_flags: pgText("corrected_flags").notNull(),
correction_notes: pgText("correction_notes"),
content_snippet: pgText("content_snippet").notNull(),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
},
(table) => ({
createdAtIdx: pgIndex("idx_corrected_moderations_created_at").on(
table.created_at,
),
messageIdx: pgIndex("idx_corrected_moderations_message_id").on(
table.message_id,
),
}),
);
export type CorrectedModeration =
typeof pgCorrectedModerationsTable.$inferSelect;
export type CorrectedModerationInsert =
typeof pgCorrectedModerationsTable.$inferInsert;
/** /**
* Attachments Table (PostgreSQL) * Attachments Table (PostgreSQL)
@@ -180,3 +158,439 @@ export const pgAttachmentsTable = pgTable(
}).onDelete("cascade"), }).onDelete("cascade"),
}), }),
); );
export const attachmentsTable = pgAttachmentsTable;
/**
* Message Reviews Table (PostgreSQL)
* Tracks manual reviews of messages flagged by AI moderation
*/
export const pgMessageReviewsTable = pgTable(
"message_reviews",
{
id: pgText("id").primaryKey(),
message_id: pgText("message_id").notNull(),
guild_id: pgText("guild_id").notNull(),
channel_id: pgText("channel_id").notNull(),
reviewer_id: pgText("reviewer_id"),
status: pgText("status", {
enum: ["pending", "approved", "rejected", "escalated"],
})
.notNull()
.default("pending"),
notes: pgText("notes"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
reviewed_at: pgBigint("reviewed_at", { mode: "number" }),
},
(table) => ({
messageIdIdx: pgIndex("idx_message_reviews_message_id").on(
table.message_id,
),
statusIdx: pgIndex("idx_message_reviews_status").on(table.status),
createdAtIdx: pgIndex("idx_message_reviews_created_at").on(
table.created_at,
),
guildStatusIdx: pgIndex("idx_message_reviews_guild_status").on(
table.guild_id,
table.status,
table.created_at,
),
}),
);
export const messageReviewsTable = pgMessageReviewsTable;
// =============================================================================
// Moderation / Corrections
// =============================================================================
/**
* Corrected Moderations Table (PostgreSQL)
* Stores manual corrections of AI moderation false positives
* for few-shot injection into LLM moderation prompts.
*/
export const pgCorrectedModerationsTable = pgTable(
"corrected_moderations",
{
id: pgText("id").primaryKey(),
message_id: pgText("message_id").notNull(),
original_flags: pgText("original_flags").notNull(),
corrected_flags: pgText("corrected_flags").notNull(),
correction_notes: pgText("correction_notes"),
content_snippet: pgText("content_snippet").notNull(),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
},
(table) => ({
createdAtIdx: pgIndex("idx_corrected_moderations_created_at").on(
table.created_at,
),
messageIdIdx: pgIndex("idx_corrected_moderations_message_id").on(
table.message_id,
),
}),
);
export const correctedModerationsTable = pgCorrectedModerationsTable;
// =============================================================================
// Voice Recordings
// =============================================================================
/**
* Voice Recordings Table (PostgreSQL)
* Stores voice recording segment metadata and upload status
*/
export const pgVoiceRecordingsTable = pgTable(
"voice_recordings",
{
id: pgText("id").primaryKey(),
user_id: pgText("user_id").notNull(),
username: pgText("username").notNull(),
avatar_url: pgText("avatar_url"),
guild_id: pgText("guild_id"),
channel_id: pgText("channel_id"),
channel_name: pgText("channel_name"),
filename: pgText("filename").notNull(),
size_bytes: pgInteger("size_bytes").notNull(),
download_url: pgText("download_url"),
upload_status: pgText("upload_status", {
enum: ["pending", "uploaded", "failed"],
})
.notNull()
.default("pending"),
upload_error: pgText("upload_error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
uploaded_at: pgBigint("uploaded_at", { mode: "number" }),
transcription: pgText("transcription"),
},
(table) => ({
userIdIdx: pgIndex("idx_voice_recordings_user_id").on(table.user_id),
channelIdIdx: pgIndex("idx_voice_recordings_channel_id").on(
table.channel_id,
),
createdIdx: pgIndex("idx_voice_recordings_created_at").on(table.created_at),
}),
);
export const voiceRecordingsTable = pgVoiceRecordingsTable;
// =============================================================================
// AI Analysis / Analytics
// =============================================================================
/**
* AI Analysis Runs Table (PostgreSQL)
* Tracks AI analysis batch runs for conversation-level moderation
*/
export const pgAIAnalysisRunsTable = pgTable(
"ai_analysis_runs",
{
id: pgText("id").primaryKey(),
conversation_key: pgText("conversation_key").notNull(),
target_message_ids: pgText("target_message_ids").notNull(),
model: pgText("model").notNull(),
request_tokens_estimate: pgInteger("request_tokens_estimate"),
response_raw: pgText("response_raw"),
status: pgText("status", {
enum: ["pending", "processing", "completed", "failed"],
})
.notNull()
.default("pending"),
error: pgText("error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
completed_at: pgBigint("completed_at", { mode: "number" }),
},
(table) => ({
conversationKeyIdx: pgIndex("idx_ai_analysis_runs_conversation_key").on(
table.conversation_key,
),
statusIdx: pgIndex("idx_ai_analysis_runs_status").on(table.status),
createdAtIdx: pgIndex("idx_ai_analysis_runs_created_at").on(
table.created_at,
),
}),
);
export const aiAnalysisRunsTable = pgAIAnalysisRunsTable;
/**
* User Profiles Table (PostgreSQL)
* Stores AI-generated summaries of user behavior patterns.
*/
export const pgUserProfilesTable = pgTable(
"user_profiles",
{
user_id: pgText("user_id").primaryKey(),
guild_id: pgText("guild_id").notNull(),
profile_summary: pgText("profile_summary").notNull(),
last_analyzed_at: pgBigint("last_analyzed_at", {
mode: "number",
}).notNull(),
},
(table) => ({
guildIdx: pgIndex("idx_user_profiles_guild_id").on(table.guild_id),
}),
);
export const userProfilesTable = pgUserProfilesTable;
/**
* User Reputations Table (PostgreSQL)
* Tracks user trust score and infractions to provide context to AI.
*/
export const pgUserReputationsTable = pgTable(
"user_reputations",
{
user_id: pgText("user_id").primaryKey(),
guild_id: pgText("guild_id").notNull(),
trust_score: pgInteger("trust_score").notNull().default(50),
clean_message_streak: pgInteger("clean_message_streak")
.notNull()
.default(0),
total_infractions: pgInteger("total_infractions").notNull().default(0),
last_infraction_at: pgBigint("last_infraction_at", { mode: "number" }),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
},
(table) => ({
guildIdx: pgIndex("idx_user_reputations_guild_id").on(table.guild_id),
scoreIdx: pgIndex("idx_user_reputations_trust_score").on(table.trust_score),
}),
);
export const userReputationsTable = pgUserReputationsTable;
/**
* Channel Cultures Table (PostgreSQL)
* Stores AI-generated summaries of channel norms and slang to inject as context.
*/
export const pgChannelCulturesTable = pgTable(
"channel_cultures",
{
channel_id: pgText("channel_id").primaryKey(),
guild_id: pgText("guild_id").notNull(),
culture_summary: pgText("culture_summary").notNull(),
last_analyzed_at: pgBigint("last_analyzed_at", {
mode: "number",
}).notNull(),
},
(table) => ({
guildIdx: pgIndex("idx_channel_cultures_guild_id").on(table.guild_id),
}),
);
export const channelCulturesTable = pgChannelCulturesTable;
// =============================================================================
// Cache (text analysis + stickers)
// =============================================================================
/**
* Text Analysis Cache Table (PostgreSQL)
* Caches per-normalized-text moderation analysis results.
*/
export const pgTextAnalysisCacheTable = pgTable(
"text_analysis_cache",
{
text: pgText("text").primaryKey(),
flags: pgText("flags").notNull().default("[]"),
source: pgText("source", {
enum: ["local", "primary_ai", "vision_llm"],
})
.notNull()
.default("local"),
analyzed_at: pgBigint("analyzed_at", { mode: "number" }).notNull(),
expires_at: pgBigint("expires_at", { mode: "number" }).notNull(),
hit_count: pgInteger("hit_count").notNull().default(0),
},
(table) => ({
expiresAtIdx: pgIndex("idx_text_analysis_cache_expires_at").on(
table.expires_at,
),
sourceIdx: pgIndex("idx_text_analysis_cache_source").on(table.source),
}),
);
export const textAnalysisCacheTable = pgTextAnalysisCacheTable;
/**
* Sticker Cache Table (PostgreSQL)
* Stores uploaded sticker image URLs for vision analysis.
*/
export const pgStickerCacheTable = pgTable(
"sticker_cache",
{
name: pgText("name").primaryKey(),
imageUrl: pgText("image_url").notNull().default(""),
mime_type: pgText("mime_type").notNull(),
fetched_at: pgBigint("fetched_at", { mode: "number" }).notNull(),
},
(table) => ({
fetchedAtIdx: pgIndex("idx_sticker_cache_fetched_at").on(table.fetched_at),
}),
);
export const stickerCacheTable = pgStickerCacheTable;
// =============================================================================
// Meta / System
// =============================================================================
/**
* Muxer Jobs Table (PostgreSQL)
* Tracks audio post-processing jobs with status and retry logic
*/
export const pgMuxerJobsTable = pgTable(
"muxer_jobs",
{
id: pgText("id").primaryKey(),
data: pgText("data").notNull(),
status: pgText("status", {
enum: ["pending", "processing", "completed", "failed"],
})
.notNull()
.default("pending"),
attempts: pgInteger("attempts").notNull().default(0),
maxAttempts: pgInteger("maxAttempts").notNull().default(3),
createdAt: pgBigint("createdAt", { mode: "number" }).notNull(),
updatedAt: pgBigint("updatedAt", { mode: "number" }).notNull(),
error: pgText("error"),
},
(table) => ({
statusIdx: pgIndex("idx_muxer_jobs_status").on(table.status),
createdAtIdx: pgIndex("idx_muxer_jobs_createdAt").on(table.createdAt),
}),
);
export const muxerJobsTable = pgMuxerJobsTable;
/**
* UI State Table (PostgreSQL)
* Stores persistent UI state (e.g., selected channel, filter preferences)
*/
export const pgUIStateTable = pgTable("ui_state", {
key: pgText("key").primaryKey(),
value: pgText("value").notNull(),
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
});
export const uiStateTable = pgUIStateTable;
/**
* Retention Policies Table (PostgreSQL)
* Defines data retention rules per guild/channel
*/
export const pgRetentionPoliciesTable = pgTable(
"retention_policies",
{
id: pgText("id").primaryKey(),
guild_id: pgText("guild_id").notNull(),
channel_id: pgText("channel_id"),
retention_days: pgInteger("retention_days").notNull().default(90),
apply_to_media: pgBoolean("apply_to_media").notNull().default(true),
apply_to_voice: pgBoolean("apply_to_voice").notNull().default(true),
enabled: pgBoolean("enabled").notNull().default(true),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
},
(table) => ({
guildIdIdx: pgIndex("idx_retention_policies_guild_id").on(table.guild_id),
enabledIdx: pgIndex("idx_retention_policies_enabled").on(table.enabled),
}),
);
export const retentionPoliciesTable = pgRetentionPoliciesTable;
/**
* Mascot Chat Messages Table (PostgreSQL)
* Stores AI mascot chat conversation history
*/
export const pgMascotChatMessagesTable = pgTable(
"mascot_chat_messages",
{
id: pgUuid("id").defaultRandom().primaryKey(),
user_id: pgText("user_id").notNull(),
user_message: pgText("user_message").notNull(),
mascot_response: pgText("mascot_response").notNull(),
context: pgJsonb("context").notNull().default("{}"),
created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
},
(table) => ({
userCreatedIdx: pgIndex("idx_mascot_chat_messages_user_created").on(
table.user_id,
table.created_at.desc(),
),
}),
);
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
// =============================================================================
// Type Exports
// =============================================================================
// Messages
export type Message = typeof messagesTable.$inferSelect;
export type MessageInsert = typeof messagesTable.$inferInsert;
// Attachments
export type Attachment = typeof attachmentsTable.$inferSelect;
export type AttachmentInsert = typeof attachmentsTable.$inferInsert;
// Message Reviews
export type DbMessageReview = typeof messageReviewsTable.$inferSelect;
export type DbMessageReviewInsert = typeof messageReviewsTable.$inferInsert;
// Corrected Moderations
export type CorrectedModeration = typeof correctedModerationsTable.$inferSelect;
export type CorrectedModerationInsert =
typeof correctedModerationsTable.$inferInsert;
// Voice Recordings
export type VoiceRecording = typeof voiceRecordingsTable.$inferSelect;
export type VoiceRecordingInsert = typeof voiceRecordingsTable.$inferInsert;
// AI Analysis Runs
export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect;
export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
// User Profiles
export type UserProfile = typeof userProfilesTable.$inferSelect;
export type UserProfileInsert = typeof userProfilesTable.$inferInsert;
// User Reputations
export type UserReputation = typeof userReputationsTable.$inferSelect;
export type UserReputationInsert = typeof userReputationsTable.$inferInsert;
// Channel Cultures
export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
// Text Analysis Cache
export type TextAnalysisCache = typeof textAnalysisCacheTable.$inferSelect;
export type TextAnalysisCacheInsert =
typeof textAnalysisCacheTable.$inferInsert;
// Sticker Cache
export type StickerCacheRecord = typeof stickerCacheTable.$inferSelect;
export type StickerCacheInsert = typeof stickerCacheTable.$inferInsert;
// Muxer Jobs
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
export type MuxerJobInsert = typeof muxerJobsTable.$inferInsert;
// UI State
export type UIState = typeof uiStateTable.$inferSelect;
export type UIStateInsert = typeof uiStateTable.$inferInsert;
// Retention Policies
export type DbRetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
export type DbRetentionPolicyInsert =
typeof retentionPoliciesTable.$inferInsert;
// Mascot Chat Messages
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
export type MascotChatMessageInsert =
typeof mascotChatMessagesTable.$inferInsert;
+2
View File
@@ -1,4 +1,6 @@
export * from "./config/index.js"; export * from "./config/index.js";
export * from "./database/init.js";
export * from "./database/pool.js";
export * from "./database/schema.js"; export * from "./database/schema.js";
export * from "./errors/index.js"; export * from "./errors/index.js";
export * from "./logger/index.js"; export * from "./logger/index.js";
+33
View File
@@ -93,3 +93,36 @@ export interface CommandReply<T = unknown> {
data?: T; data?: T;
error?: string; error?: string;
} }
// ---------------------------------------------------------------------------
// Discord Redis channel → WebSocket event type mapping (single source of truth)
// ---------------------------------------------------------------------------
/**
* Maps each Discord Redis channel to its corresponding WebSocket event type.
* Used by the backend Redis bridge to dispatch events to frontend WS clients.
*/
export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
[DISCORD_MESSAGE_CREATED]: "message_created",
[DISCORD_MESSAGE_UPDATED]: "message_updated",
[DISCORD_MESSAGE_DELETED]: "message_deleted",
[DISCORD_MESSAGE_ANALYZED]: "message_analyzed",
[DISCORD_ATTACHMENT_CREATED]: "attachment_created",
[DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded",
[DISCORD_VOICE_STARTED]: "voice_recording_started",
[DISCORD_VOICE_STOPPED]: "voice_recording_stopped",
[DISCORD_VOICE_UPLOADED]: "voice_recording_uploaded",
[DISCORD_ANALYSIS_QUEUE_STATUS]: "analysis_queue_status",
[DISCORD_VOICE_ACTIVE_USER]: "voice_active_user",
[DISCORD_VOICE_PCM]: "voice_pcm_data",
[DISCORD_VOICE_ANALYZED]: "voice_analyzed",
[DISCORD_REACTION_ADDED]: "reaction_added",
[DISCORD_REACTION_REMOVED]: "reaction_removed",
[DISCORD_THREAD_CREATED]: "thread_created",
[DISCORD_THREAD_DELETED]: "thread_deleted",
[DISCORD_THREAD_UPDATED]: "thread_updated",
[DISCORD_CHANNEL_TOPIC_UPDATED]: "channel_topic_updated",
[DISCORD_PRESENCE_UPDATED]: "presence_updated",
[DISCORD_GUILD_MEMBER_ADDED]: "guild_member_added",
[DISCORD_GUILD_MEMBER_REMOVED]: "guild_member_removed",
};
+8 -7
View File
@@ -29,6 +29,9 @@ importers:
drizzle-orm: drizzle-orm:
specifier: ^0.45.2 specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
pg:
specifier: ^8.13.0
version: 8.21.0
pino: pino:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.14.0 version: 9.14.0
@@ -39,6 +42,9 @@ importers:
'@types/node': '@types/node':
specifier: ^25.9.0 specifier: ^25.9.0
version: 25.9.0 version: 25.9.0
'@types/pg':
specifier: ^8.11.0
version: 8.20.0
pino-pretty: pino-pretty:
specifier: ^13.1.3 specifier: ^13.1.3
version: 13.1.3 version: 13.1.3
@@ -3977,9 +3983,6 @@ packages:
peerDependencies: peerDependencies:
pg: '>=8.0' pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-protocol@1.14.0: pg-protocol@1.14.0:
resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==}
@@ -6546,8 +6549,8 @@ snapshots:
'@types/pg@8.20.0': '@types/pg@8.20.0':
dependencies: dependencies:
'@types/node': 25.8.0 '@types/node': 25.9.0
pg-protocol: 1.13.0 pg-protocol: 1.14.0
pg-types: 2.2.0 pg-types: 2.2.0
'@types/qs@6.15.1': {} '@types/qs@6.15.1': {}
@@ -8130,8 +8133,6 @@ snapshots:
dependencies: dependencies:
pg: 8.21.0 pg: 8.21.0
pg-protocol@1.13.0: {}
pg-protocol@1.14.0: {} pg-protocol@1.14.0: {}
pg-types@2.2.0: pg-types@2.2.0:
+2 -2
View File
@@ -1,10 +1,10 @@
/** /**
* E2E API tests runs against a running backend instance. * E2E API tests runs against a running backend instance.
* Usage: vitest run (or: API_BASE=http://localhost:3001 vitest run) * Usage: API_BASE=http://localhost:3001 vitest run
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "https://imphnen.asepharyana.my.id/api"; const BASE = process.env.API_BASE ?? "http://localhost:3001/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}`, {
+10 -13
View File
@@ -6,17 +6,16 @@ import express, {
type Response, type Response,
} from "express"; } from "express";
import helmet from "helmet"; import helmet from "helmet";
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js"; import { createAnalysisRouter } from "../modules/analysis/index.js";
import { createConfigRouter } from "../modules/config/config.routes.js"; import { createConfigRouter } from "../modules/config/index.js";
import { createDashboardRouter } from "../modules/dashboard/dashboard.routes.js"; import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/health.routes.js"; import { createHealthRouter } from "../modules/health/index.js";
import { createMascotChatRouter } from "../modules/mascot-chat/mascot-chat.routes.js"; import { createMascotChatRouter } from "../modules/mascot-chat/index.js";
import { createMediaRouter } from "../modules/media/media.routes.js"; import { createMediaRouter } from "../modules/media/index.js";
import { createMessagesRouter } from "../modules/messages/messages.routes.js"; import { createMessagesRouter } from "../modules/messages/index.js";
import { createRecordingsRouter } from "../modules/recordings/recordings.routes.js"; import { createRecordingsRouter } from "../modules/recordings/index.js";
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js"; import { createUiStateRouter } from "../modules/ui-state/index.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js"; import { createVoiceRouter } from "../modules/voice/index.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import { errorHandler } from "../shared/middlewares/index.js"; import { errorHandler } from "../shared/middlewares/index.js";
// Auth removed — dashboard is public // Auth removed — dashboard is public
@@ -68,8 +67,6 @@ export function createHttpApp(): Express {
app.use("/api", createMascotChatRouter()); app.use("/api", createMascotChatRouter());
app.use("/api", createRecordingsRouter()); app.use("/api", createRecordingsRouter());
app.use("/api", createUiStateRouter()); app.use("/api", createUiStateRouter());
app.use("/api/guilds", createGuildsRouter());
app.use("/api", createMediaRouter()); app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter()); app.use("/api", createVoiceRouter());
@@ -1,5 +1,7 @@
import { pgMessagesTable } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getPool } from "../../shared/database/index.js"; import { and, desc, eq, ilike, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { import {
type MappedMessage, type MappedMessage,
mapMessageRow, mapMessageRow,
@@ -19,44 +21,27 @@ export type AnalysisSearchResult = MappedMessage;
export class AnalysisRepository { export class AnalysisRepository {
async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> { async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> {
const pool = getPool(); const db = getDatabase();
const { q = "", channelId, guildId, limit = 20 } = query; const { q = "", channelId, guildId, limit = 20 } = query;
logger.debug({ q, channelId, guildId, limit }, "Searching analysis"); logger.debug({ q, channelId, guildId, limit }, "Searching analysis");
const searchPattern = `%${q}%`; const conditions: SQL[] = [ilike(pgMessagesTable.content, `%${q}%`)];
const clauses: string[] = ["content ILIKE $1"];
const params: (string | number)[] = [searchPattern];
let p = 2;
if (guildId) { if (guildId) {
clauses.push(`guild_id = $${p}`); conditions.push(eq(pgMessagesTable.guild_id, guildId));
params.push(guildId);
p++;
} }
if (channelId) { if (channelId) {
clauses.push(`channel_id = $${p}`); conditions.push(eq(pgMessagesTable.channel_id, channelId));
params.push(channelId);
p++;
} }
const where = clauses.join(" AND "); const rows = await db
const { rows } = await pool.query( .select()
`SELECT .from(pgMessagesTable)
id, guild_id, channel_id, thread_id, .where(and(...conditions))
user_id, username, avatar_url, .orderBy(desc(pgMessagesTable.created_at))
content, edited_content, created_at, edited_at, deleted_at, .limit(limit);
type, metadata,
ai_status, ai_moderation_flags, ai_moderation_score,
ai_analysis, ai_categories, ai_severity, ai_confidence,
ai_recommended_action, ai_analyzed_at, ai_error
FROM messages
WHERE ${where}
ORDER BY created_at DESC
LIMIT $${p}`,
[...params, limit],
);
return rows.map((r) => mapMessageRow(r as Record<string, unknown>)); return rows.map((r) => mapMessageRow(r as Record<string, unknown>));
} }
@@ -0,0 +1 @@
export { createAnalysisRouter } from "./analysis.routes.js";
@@ -0,0 +1 @@
export { createConfigRouter } from "./config.routes.js";
@@ -1,16 +1,23 @@
import { createChildLogger } from "@bete/shared/logger"; import {
import { getPool } from "../../shared/database/index.js"; pgChannelCulturesTable,
pgMessagesTable,
pgUserProfilesTable,
pgUserReputationsTable,
pgVoiceRecordingsTable,
} from "@bete/shared";
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";
const _logger = createChildLogger("dashboard.repository");
export class DashboardRepository { export class DashboardRepository {
async getStats() { async getStats() {
const pool = getPool(); const db = getDatabase();
const oneDayAgo = Date.now() - 86400000;
// Total messages and breakdown by ai_status // Total messages and breakdown by ai_status
const msgResult = await pool.query( const msgResult = await db.execute(sql`
`
SELECT SELECT
COUNT(*)::int AS total_messages, COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS total_flagged, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS total_flagged,
@@ -20,32 +27,30 @@ export class DashboardRepository {
COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS total_pending, COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS total_pending,
COUNT(*) FILTER (WHERE ai_status = 'processing')::int AS total_processing, COUNT(*) FILTER (WHERE ai_status = 'processing')::int AS total_processing,
COUNT(DISTINCT user_id)::int AS total_users, COUNT(DISTINCT user_id)::int AS total_users,
COUNT(*) FILTER (WHERE created_at >= $1)::int AS today_messages, COUNT(*) FILTER (WHERE created_at >= ${oneDayAgo})::int AS today_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged, COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= ${oneDayAgo})::int AS today_flagged,
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h COUNT(DISTINCT user_id) FILTER (WHERE created_at >= ${oneDayAgo})::int AS active_users_24h
FROM messages FROM ${pgMessagesTable}
`, `);
[Date.now() - 86400000, Date.now() - 86400000],
);
const msgRow = msgResult.rows[0]; const msgRow = msgResult.rows[0] as Record<string, unknown> | undefined;
// Total voice recordings // Total voice recordings
const voiceResult = await pool.query(` const voiceResult = await db.execute(sql`
SELECT COUNT(*)::int AS count FROM voice_recordings SELECT COUNT(*)::int AS count FROM ${pgVoiceRecordingsTable}
`); `);
// Total AI user profiles // Total AI user profiles
const profileResult = await pool.query(` const profileResult = await db.execute(sql`
SELECT COUNT(*)::int AS count FROM user_profiles SELECT COUNT(*)::int AS count FROM ${pgUserProfilesTable}
`); `);
// Top channels by message count // Top channels by message count
const topChannels = await pool.query(` const topChannels = await db.execute(sql`
SELECT channel_id, SELECT channel_id,
COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name,
COUNT(*)::int AS message_count COUNT(*)::int AS message_count
FROM messages FROM ${pgMessagesTable}
WHERE metadata IS NOT NULL AND metadata != '' WHERE metadata IS NOT NULL AND metadata != ''
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName') GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
@@ -78,31 +83,26 @@ export class DashboardRepository {
} }
async listUsers(query: ListUsersQuery) { async listUsers(query: ListUsersQuery) {
const pool = getPool(); const db = getDatabase();
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const conditions: string[] = []; const conditions: SQL[] = [];
const params: unknown[] = [];
let paramIdx = 1;
if (query.search) { if (query.search) {
conditions.push( conditions.push(
`(m.user_id ILIKE $${paramIdx} OR m.username ILIKE $${paramIdx})`, sql`(m.user_id ILIKE ${`%${query.search}%`} OR m.username ILIKE ${`%${query.search}%`})`,
); );
params.push(`%${query.search}%`);
paramIdx++;
} }
if (query.cursor) { if (query.cursor) {
conditions.push(`m.last_message_at < $${paramIdx}`); conditions.push(sql`m.last_message_at < ${Number(query.cursor)}`);
params.push(Number(query.cursor));
paramIdx++;
} }
const whereClause = const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; conditions.length > 0
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
: sql``;
const { rows } = await pool.query( const { rows } = await db.execute(sql`
`
SELECT SELECT
m.user_id, m.user_id,
m.username, m.username,
@@ -120,17 +120,15 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages, COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
MAX(created_at) AS last_message_at MAX(created_at) AS last_message_at
FROM messages FROM ${pgMessagesTable}
GROUP BY user_id, username, avatar_url GROUP BY user_id, username, avatar_url
) m ) m
LEFT JOIN user_profiles p ON p.user_id = m.user_id LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN user_reputations r ON r.user_id = m.user_id LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
${whereClause} ${whereClause}
ORDER BY m.last_message_at DESC NULLS LAST ORDER BY m.last_message_at DESC NULLS LAST
LIMIT $${paramIdx} LIMIT ${limit + 1}
`, `);
[...params, limit + 1],
);
const data = (rows as Record<string, unknown>[]) const data = (rows as Record<string, unknown>[])
.slice(0, limit) .slice(0, limit)
@@ -158,31 +156,26 @@ export class DashboardRepository {
} }
async listChannels(query: ListUsersQuery & { guildId?: string }) { async listChannels(query: ListUsersQuery & { guildId?: string }) {
const pool = getPool(); const db = getDatabase();
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const conditions: string[] = []; const conditions: SQL[] = [];
const params: unknown[] = [];
let paramIdx = 1;
if (query.search) { if (query.search) {
conditions.push( conditions.push(
`(m.channel_id ILIKE $${paramIdx} OR m.channel_name ILIKE $${paramIdx})`, sql`(m.channel_id ILIKE ${`%${query.search}%`} OR m.channel_name ILIKE ${`%${query.search}%`})`,
); );
params.push(`%${query.search}%`);
paramIdx++;
} }
if (query.guildId) { if (query.guildId) {
conditions.push(`m.guild_id = $${paramIdx}`); conditions.push(sql`m.guild_id = ${query.guildId}`);
params.push(query.guildId);
paramIdx++;
} }
const whereClause = const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; conditions.length > 0
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
: sql``;
const { rows } = await pool.query( const { rows } = await db.execute(sql`
`
SELECT SELECT
m.channel_id, m.channel_id,
m.channel_name, m.channel_name,
@@ -200,16 +193,14 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages, COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
MAX(created_at) AS last_message_at MAX(created_at) AS last_message_at
FROM messages FROM ${pgMessagesTable}
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
) m ) m
LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id
${whereClause} ${whereClause}
ORDER BY m.total_messages DESC ORDER BY m.total_messages DESC
LIMIT $${paramIdx} LIMIT ${limit + 1}
`, `);
[...params, limit + 1],
);
const data = ((rows as Record<string, unknown>[]) || []) const data = ((rows as Record<string, unknown>[]) || [])
.slice(0, limit) .slice(0, limit)
@@ -234,10 +225,9 @@ export class DashboardRepository {
} }
async getChannelDetail(channelId: string) { async getChannelDetail(channelId: string) {
const pool = getPool(); const db = getDatabase();
const channelResult = await pool.query( const channelResult = await db.execute(sql`
`
SELECT SELECT
m.channel_id, m.channel_id,
m.channel_name, m.channel_name,
@@ -255,28 +245,23 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages, COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
FROM messages FROM ${pgMessagesTable}
WHERE channel_id = $1 WHERE channel_id = ${channelId}
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
) m ) m
LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id
`, `);
[channelId],
);
const row = channelResult.rows[0] as Record<string, unknown> | undefined; const row = channelResult.rows[0] as Record<string, unknown> | undefined;
if (!row) return null; if (!row) return null;
const recent = await pool.query( const recent = await db.execute(sql`
`
SELECT id, content, channel_id, created_at, ai_status, username SELECT id, content, channel_id, created_at, ai_status, username
FROM messages FROM ${pgMessagesTable}
WHERE channel_id = $1 WHERE channel_id = ${channelId}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20 LIMIT 20
`, `);
[channelId],
);
return { return {
channel_id: String(row.channel_id), channel_id: String(row.channel_id),
@@ -301,11 +286,9 @@ export class DashboardRepository {
} }
async getUserDetail(userId: string) { async getUserDetail(userId: string) {
const pool = getPool(); const db = getDatabase();
// Basic user info + profile + reputation const userResult = await db.execute(sql`
const userResult = await pool.query(
`
SELECT SELECT
m.user_id, m.user_id,
m.username, m.username,
@@ -326,32 +309,26 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages, COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
FROM messages FROM ${pgMessagesTable}
WHERE user_id = $1 WHERE user_id = ${userId}
GROUP BY user_id, username, avatar_url GROUP BY user_id, username, avatar_url
) m ) m
LEFT JOIN user_profiles p ON p.user_id = m.user_id LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN user_reputations r ON r.user_id = m.user_id LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
`, `);
[userId],
);
const row = userResult.rows[0] as Record<string, unknown> | undefined; const row = userResult.rows[0] as Record<string, unknown> | undefined;
if (!row) { if (!row) {
return null; return null;
} }
// Recent messages const recent = await db.execute(sql`
const recent = await pool.query(
`
SELECT id, content, channel_id, created_at, ai_status SELECT id, content, channel_id, created_at, ai_status
FROM messages FROM ${pgMessagesTable}
WHERE user_id = $1 WHERE user_id = ${userId}
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 20 LIMIT 20
`, `);
[userId],
);
return { return {
user_id: String(row.user_id), user_id: String(row.user_id),
@@ -364,13 +341,13 @@ export class DashboardRepository {
last_analyzed_at: row.last_analyzed_at last_analyzed_at: row.last_analyzed_at
? Number(row.last_analyzed_at) ? Number(row.last_analyzed_at)
: null, : null,
trust_score: row.trust_score !== null ? Number(row.trust_score) : null, trust_score: row.trust_score != null ? Number(row.trust_score) : null,
clean_message_streak: clean_message_streak:
row.clean_message_streak !== null row.clean_message_streak != null
? Number(row.clean_message_streak) ? Number(row.clean_message_streak)
: null, : null,
total_infractions: total_infractions:
row.total_infractions !== null ? Number(row.total_infractions) : null, row.total_infractions != null ? Number(row.total_infractions) : null,
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({ recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
id: String(r.id), id: String(r.id),
content: String(r.content), content: String(r.content),
@@ -0,0 +1 @@
export { createDashboardRouter } from "./dashboard.routes.js";
@@ -1,5 +1,6 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getPool } from "../../shared/database/index.js"; import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("health.repository"); const logger = createChildLogger("health.repository");
@@ -7,8 +8,8 @@ export class HealthRepository {
async checkDatabaseConnection() { async checkDatabaseConnection() {
try { try {
logger.debug("Running database health check"); logger.debug("Running database health check");
const pool = getPool(); const db = getDatabase();
await pool.query("SELECT 1 AS result"); await db.execute(sql`SELECT 1 AS result`);
logger.debug("Database health check passed"); logger.debug("Database health check passed");
return { connected: true }; return { connected: true };
} catch (err: unknown) { } catch (err: unknown) {
@@ -1,8 +1,5 @@
import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js"; import { healthRepository } from "./health.repository.js";
const _logger = createChildLogger("health.service");
export class HealthService { export class HealthService {
async getHealth(verbose = false) { async getHealth(verbose = false) {
const dbStatus = await healthRepository.checkDatabaseConnection(); const dbStatus = await healthRepository.checkDatabaseConnection();
@@ -0,0 +1 @@
export { createHealthRouter } from "./health.routes.js";
@@ -0,0 +1 @@
export { createMascotChatRouter } from "./mascot-chat.routes.js";
@@ -11,8 +11,12 @@ interface AuthenticatedRequest extends Request {
export const handleMascotChat = asyncHandler( export const handleMascotChat = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { message, context } = req.body; const { message, context } = req.body as {
message: string;
context?: Record<string, unknown>;
};
// Validate required fields
if (!message || typeof message !== "string") { if (!message || typeof message !== "string") {
return res.status(400).json({ return res.status(400).json({
error: "INVALID_INPUT", error: "INVALID_INPUT",
@@ -1,5 +1,7 @@
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { getPool } from "../../shared/database/index.js"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("mascot-chat.repository"); const logger = createChildLogger("mascot-chat.repository");
@@ -38,22 +40,15 @@ export interface ServerInsights {
export class MascotChatRepository { export class MascotChatRepository {
async saveConversation(input: SaveConversationInput): Promise<void> { async saveConversation(input: SaveConversationInput): Promise<void> {
const pool = getPool(); const db = getDatabase();
await pool.query( await db.insert(pgMascotChatMessagesTable).values({
` user_id: input.userId,
INSERT INTO mascot_chat_messages user_message: input.userMessage,
(user_id, user_message, mascot_response, context, created_at) mascot_response: input.mascotResponse,
VALUES ($1, $2, $3, $4::jsonb, $5) context: (input.context ?? {}) as Record<string, unknown>,
`, created_at: input.timestamp,
[ });
input.userId,
input.userMessage,
input.mascotResponse,
JSON.stringify(input.context ?? {}),
input.timestamp.toISOString(),
],
);
logger.debug({ userId: input.userId }, "Conversation saved"); logger.debug({ userId: input.userId }, "Conversation saved");
} }
@@ -62,69 +57,61 @@ export class MascotChatRepository {
userId: string, userId: string,
limit: number, limit: number,
): Promise<MascotChatHistoryRow[]> { ): Promise<MascotChatHistoryRow[]> {
const pool = getPool(); const db = getDatabase();
const { rows } = await pool.query<MascotChatHistoryRow>( const rows = await db
` .select()
SELECT id, user_id, user_message, mascot_response, context, created_at .from(pgMascotChatMessagesTable)
FROM mascot_chat_messages .where(eq(pgMascotChatMessagesTable.user_id, userId))
WHERE user_id = $1 .orderBy(desc(pgMascotChatMessagesTable.created_at))
ORDER BY created_at DESC .limit(limit);
LIMIT $2
`,
[userId, limit],
);
logger.debug({ userId, count: rows.length }, "Chat history fetched"); logger.debug({ userId, count: rows.length }, "Chat history fetched");
return rows.reverse(); return rows.reverse() as unknown as MascotChatHistoryRow[];
} }
async clearChatHistory(userId: string): Promise<void> { async clearChatHistory(userId: string): Promise<void> {
const pool = getPool(); const db = getDatabase();
const { rowCount } = await pool.query( const deleted = await db
`DELETE FROM mascot_chat_messages WHERE user_id = $1`, .delete(pgMascotChatMessagesTable)
[userId], .where(eq(pgMascotChatMessagesTable.user_id, userId))
.returning({ id: pgMascotChatMessagesTable.id });
logger.info(
{ userId, deletedRows: deleted.length },
"Chat history cleared",
); );
logger.info({ userId, deletedRows: rowCount ?? 0 }, "Chat history cleared");
} }
async getServerInsights( async getServerInsights(
guildId?: string, guildId?: string,
channelId?: string, channelId?: string,
): Promise<ServerInsights> { ): Promise<ServerInsights> {
const pool = getPool();
try { try {
const params: string[] = []; const db = getDatabase();
const clauses: string[] = []; const conditions: SQL[] = [];
if (guildId) { if (guildId) {
params.push(guildId); conditions.push(eq(pgMessagesTable.guild_id, guildId));
clauses.push(`guild_id = $${params.length}`);
} }
if (channelId) { if (channelId) {
params.push(channelId); conditions.push(eq(pgMessagesTable.channel_id, channelId));
clauses.push(`channel_id = $${params.length}`);
} }
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; const where = conditions.length > 0 ? and(...conditions) : undefined;
const { rows } = await pool.query<ServerInsights>( const [result] = await db
` .select({
SELECT total_messages: sql<number>`COUNT(*)::int`,
COUNT(*)::int AS total_messages, active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
COUNT(DISTINCT user_id)::int AS active_users, flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged, warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned })
FROM messages .from(pgMessagesTable)
${where} .where(where);
`,
params,
);
const insights = rows[0] ?? { const insights = result ?? {
total_messages: 0, total_messages: 0,
active_users: 0, active_users: 0,
flagged: 0, flagged: 0,
@@ -1,14 +1,20 @@
import express, { type Router } from "express"; import express, { type Router } from "express";
import { validateBody } from "../../shared/middlewares/index.js";
import { import {
clearMascotChatHistory, clearMascotChatHistory,
getMascotChatHistory, getMascotChatHistory,
handleMascotChat, handleMascotChat,
} from "./mascot-chat.controller.js"; } from "./mascot-chat.controller.js";
import { chatRequestSchema } from "./mascot-chat.schema.js";
export function createMascotChatRouter(): Router { export function createMascotChatRouter(): Router {
const router = express.Router(); const router = express.Router();
router.post("/mascot/chat", handleMascotChat); router.post(
"/mascot/chat",
validateBody(chatRequestSchema),
handleMascotChat,
);
router.get("/mascot/chat/history", getMascotChatHistory); router.get("/mascot/chat/history", getMascotChatHistory);
router.delete("/mascot/chat/history", clearMascotChatHistory); router.delete("/mascot/chat/history", clearMascotChatHistory);
@@ -0,0 +1 @@
export { createMediaRouter } from "./media.routes.js";
@@ -1,7 +1,8 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js"; import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
const logger = createChildLogger("media.routes"); const logger = createChildLogger("media.routes");
@@ -22,16 +23,12 @@ export function createMediaRouter(): Router {
// POST /api/media/queue // POST /api/media/queue
router.post( router.post(
"/media/queue", "/media/queue",
validateBody(mediaQueueSchema),
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
const source = req.body?.source as string | undefined; const { source, mode } = req.body as {
if (!source) { source: string;
res.status(400).json({ mode: "music" | "screen";
error: "VALIDATION_ERROR", };
message: "source is required",
});
return;
}
const mode = (req.body?.mode as "music" | "screen") ?? "music";
logger.debug({ source, mode }, "Media queue requested"); logger.debug({ source, mode }, "Media queue requested");
const state = await queue(source, mode); const state = await queue(source, mode);
res.json(state); res.json(state);
@@ -61,15 +58,9 @@ export function createMediaRouter(): Router {
// POST /api/media/volume // POST /api/media/volume
router.post( router.post(
"/media/volume", "/media/volume",
validateBody(mediaVolumeSchema),
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
const volume = Number(req.body?.volume ?? 1.0); const { volume } = req.body as { volume: number };
if (Number.isNaN(volume) || volume < 0 || volume > 1) {
res.status(400).json({
error: "VALIDATION_ERROR",
message: "volume must be a number between 0 and 1",
});
return;
}
logger.debug({ volume }, "Media volume requested"); logger.debug({ volume }, "Media volume requested");
const state = await setVolume(volume); const state = await setVolume(volume);
res.json(state); res.json(state);
@@ -0,0 +1,13 @@
import { z } from "zod";
export const mediaQueueSchema = z.object({
source: z.string().min(1, "source is required"),
mode: z.enum(["music", "screen"]).default("music"),
});
export const mediaVolumeSchema = z.object({
volume: z.number().min(0).max(1).default(1.0),
});
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>;
@@ -0,0 +1 @@
export { createMessagesRouter } from "./messages.routes.js";
@@ -1,84 +1,68 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { NextFunction, Request, Response } from "express"; import type { Request, Response } from "express";
import { asyncHandler, requireParam } 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";
const logger = createChildLogger("messages.controller"); const logger = createChildLogger("messages.controller");
export function handleListMessages( export const handleListMessages = asyncHandler(
req: Request, async (req: Request, res: Response) => {
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const query = messageQuerySchema.parse(req.query); const query = messageQuerySchema.parse(req.query);
logger.debug({ query }, "Handling list messages request"); logger.debug({ query }, "Handling list messages request");
const result = await messagesService.listMessages(query); const result = await messagesService.listMessages(query);
res.json(result); res.json(result);
})(req, res, next); },
}
export function handleGetMessagesByChannel(
req: Request,
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireParam(
req.params.channelId,
"route parameter",
"channelId",
); );
export const handleGetMessagesByChannel = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.channelId) {
res.status(400).json({ error: "Missing route parameter: channelId" });
return;
}
const channelId = req.params.channelId as string;
const query = messageQuerySchema.parse(req.query); const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel"); logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query); const result = await messagesService.getMessagesByChannel(channelId, query);
res.json(result); res.json(result);
})(req, res, next); },
} );
export function handleGetMessageById( export const handleGetMessageById = asyncHandler(
req: Request, async (req: Request, res: Response) => {
res: Response, if (!req.params.id) {
next: NextFunction, res.status(400).json({ error: "Missing route parameter: id" });
) { return;
return asyncHandler(async (req: Request, res: Response) => { }
const id = requireParam(req.params.id, "route parameter", "id"); const id = req.params.id as string;
logger.debug({ id }, "Handling get message by ID"); logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id); const result = await messagesService.getMessageById(id);
res.json(result); res.json(result);
})(req, res, next); },
}
export function handleGetImageMessages(
req: Request,
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const guildId = requireParam(
req.query.guildId as string,
"query parameter",
"guildId",
); );
export const handleGetImageMessages = asyncHandler(
async (req: Request, res: Response) => {
const guildId = req.query.guildId as string | undefined;
if (!guildId) {
res.status(400).json({ error: "Missing query parameter: guildId" });
return;
}
const limit = Number(req.query.limit) || 50; const limit = Number(req.query.limit) || 50;
logger.debug({ guildId, limit }, "Handling get image messages"); logger.debug({ guildId, limit }, "Handling get image messages");
const result = await messagesService.getImageMessages(guildId, limit); const result = await messagesService.getImageMessages(guildId, limit);
res.json(result); res.json(result);
})(req, res, next); },
}
export function handleGetAttachmentsByChannel(
req: Request,
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const channelId = requireParam(
req.params.channelId,
"route parameter",
"channelId",
); );
export const handleGetAttachmentsByChannel = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.channelId) {
res.status(400).json({ error: "Missing route parameter: channelId" });
return;
}
const channelId = req.params.channelId as string;
const query = messageQuerySchema.parse(req.query); const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel"); logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel( const result = await messagesService.getAttachmentsByChannel(
@@ -86,5 +70,5 @@ export function handleGetAttachmentsByChannel(
query, query,
); );
res.json(result); res.json(result);
})(req, res, next); },
} );
@@ -14,6 +14,7 @@ import {
or, or,
type SQL, type SQL,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import { mapMessageRow } from "../../shared/utils/messageMapper.js"; import { mapMessageRow } from "../../shared/utils/messageMapper.js";
import type { import type {
@@ -23,12 +24,12 @@ import type {
} from "./messages.schema.js"; } from "./messages.schema.js";
/** /**
* Thread IDs to exclude from all message queries. * Thread/channel IDs to exclude from all message queries.
* Messages in these threads (e.g. bot/selfbot spam) are skipped * Messages in these threads (e.g. bot/selfbot spam) are skipped
* both at capture time (discord-gateway) and when serving data * both at capture time (discord-gateway) and when serving data
* (backend API). * (backend API). Configured via EXCLUDED_THREAD_IDS and EXCLUDED_CHANNEL_IDS.
*/ */
const EXCLUDED_THREAD_IDS = ["1522077685508083893"]; const EXCLUDED_THREAD_IDS = config.EXCLUDED_THREAD_IDS;
const logger = createChildLogger("messages.repository"); const logger = createChildLogger("messages.repository");
@@ -1,7 +1,7 @@
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { import {
handleGetAttachmentsByChannel, handleGetAttachmentsByChannel,
handleGetImageMessages, handleGetImageMessages,
@@ -9,6 +9,7 @@ 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");
@@ -55,8 +56,9 @@ export function createMessagesRouter(): Router {
// is not captured as an :id param. // is not captured as an :id param.
router.post( router.post(
"/messages/reanalyze-batch", "/messages/reanalyze-batch",
validateBody(reanalyzeBatchSchema),
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
const { guildId, channelId, messageIds } = (req.body ?? {}) as { const { guildId, channelId, messageIds } = req.body as {
guildId?: string; guildId?: string;
channelId?: string; channelId?: string;
messageIds?: string[]; messageIds?: string[];
@@ -36,6 +36,13 @@ 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>;
@@ -0,0 +1 @@
export { createRecordingsRouter } from "./recordings.routes.js";
@@ -1,5 +1,6 @@
import { pgVoiceRecordingsTable } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger"; import { createChildLogger } from "@bete/shared/logger";
import { 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";
const logger = createChildLogger("recordings.service"); const logger = createChildLogger("recordings.service");
@@ -36,37 +37,47 @@ export class RecordingsService {
logger.info({ limit }, "getRecent called"); logger.info({ limit }, "getRecent called");
const db = getDatabase(); const db = getDatabase();
const conditions: ReturnType<typeof sql>[] = []; const conditions: SQL[] = [];
if (filters?.cursor) { if (filters?.cursor) {
conditions.push(sql`created_at < ${filters.cursor}::numeric`); conditions.push(
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
);
} }
if (filters?.channelId) { if (filters?.channelId) {
conditions.push(sql`channel_id = ${filters.channelId}`); conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
} }
if (filters?.userId) { if (filters?.userId) {
conditions.push(sql`user_id = ${filters.userId}`); conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
} }
const whereClause = const where = conditions.length > 0 ? and(...conditions) : undefined;
conditions.length > 0
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
: sql``;
const { rows } = await db.execute(sql` const allRows = await db
SELECT .select({
id, user_id, username, avatar_url, guild_id, channel_id, id: pgVoiceRecordingsTable.id,
channel_name, filename, size_bytes, download_url, user_id: pgVoiceRecordingsTable.user_id,
upload_status, upload_error, created_at, uploaded_at, username: pgVoiceRecordingsTable.username,
COALESCE(size_bytes, 0) AS duration_bytes avatar_url: pgVoiceRecordingsTable.avatar_url,
FROM voice_recordings guild_id: pgVoiceRecordingsTable.guild_id,
${whereClause} channel_id: pgVoiceRecordingsTable.channel_id,
ORDER BY created_at DESC channel_name: pgVoiceRecordingsTable.channel_name,
LIMIT ${limit + 1} filename: pgVoiceRecordingsTable.filename,
`); size_bytes: pgVoiceRecordingsTable.size_bytes,
download_url: pgVoiceRecordingsTable.download_url,
upload_status: pgVoiceRecordingsTable.upload_status,
upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
duration_bytes: pgVoiceRecordingsTable.size_bytes,
})
.from(pgVoiceRecordingsTable)
.where(where)
.orderBy(desc(pgVoiceRecordingsTable.created_at))
.limit(limit + 1);
const items = rows.slice(0, limit) as unknown as RecordingRow[]; const items = allRows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = rows.length > limit; const hasMore = allRows.length > limit;
const nextCursor = hasMore const nextCursor = hasMore
? String(items[items.length - 1]?.created_at) ? String(items[items.length - 1]?.created_at)
: null; : null;
@@ -76,7 +87,9 @@ export class RecordingsService {
async deleteById(id: string): Promise<void> { async deleteById(id: string): Promise<void> {
const db = getDatabase(); const db = getDatabase();
await db.execute(sql`DELETE FROM voice_recordings WHERE id = ${id}`); await db
.delete(pgVoiceRecordingsTable)
.where(eq(pgVoiceRecordingsTable.id, id));
} }
} }
@@ -0,0 +1 @@
export { createUiStateRouter } from "./ui-state.routes.js";
@@ -1,49 +0,0 @@
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express";
import express from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
getGuilds,
getTextChannels,
getVoiceChannels,
} from "./voice.service.js";
const logger = createChildLogger("guilds.routes");
export function createGuildsRouter(): Router {
const router = express.Router();
// GET /api/guilds
router.get(
"/",
asyncHandler(async (_req: Request, res: Response) => {
logger.debug("Fetching guilds");
const guilds = await getGuilds();
res.json(guilds);
}),
);
// GET /api/guilds/:guildId/channels
router.get(
"/:guildId/channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = req.params.guildId as string;
logger.debug({ guildId }, "Fetching text channels");
const channels = await getTextChannels(guildId);
res.json(channels);
}),
);
// GET /api/guilds/:guildId/voice-channels
router.get(
"/:guildId/voice-channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = req.params.guildId as string;
logger.debug({ guildId }, "Fetching voice channels");
const channels = await getVoiceChannels(guildId);
res.json(channels);
}),
);
return router;
}

Some files were not shown because too many files have changed in this diff Show More