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:
push:
branches:
- main
branches: [main]
jobs:
deploy:
build-and-push:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
service: [backend, discord-gateway, proxy]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install system dependencies
- name: Setup
run: |
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
curl \
libssl-dev \
pkg-config \
python3
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates git docker.io
git config --global http.sslVerify false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Checkout
run: |
cd /tmp
git clone --depth 1 https://MythEclipse:${{ secrets.REGISTRY_TOKEN }}@git.imrnes.team/MythEclipse/GMW.git repo
cd /tmp/repo
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.3
run_install: false
- name: Docker Login
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY }} -u MythEclipse --password-stdin
- name: Set up Rust (for node-crc native build)
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
- name: Build & Push ${{ matrix.service }}
env:
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
DOCKER_BUILDKIT: "1"
run: |
umask 077
printf '%s\n' "$PRODUCTION_ENV" | tr -d '\r' > .env
chmod 600 .env
cd /tmp/repo
IMG=${{ vars.REGISTRY }}/mytheclipse/gmw/bete-${{ matrix.service }}
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
env:
VPS_SSH_KEY_CONTENT: ${{ secrets.VPS_SSH_KEY }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Install SSH
run: |
umask 077
key_file="${RUNNER_TEMP:-/tmp}/bete-vps-key"
printf '%s\n' "$VPS_SSH_KEY_CONTENT" > "$key_file"
chmod 600 "$key_file"
echo "VPS_SSH_KEY=$key_file" >> "$GITHUB_ENV"
apt-get update -qq
apt-get install -y -qq --no-install-recommends openssh-client ca-certificates
- name: Deploy
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
run: ./deploy.sh --no-build
ENV_FILE: ${{ secrets.ENV_FILE }}
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
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/
services/frontend/frontend/dist/
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
# ─── Bete Deploy Script ──────────────────────────────────────────────────────
# 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
# ──────────────────────────────────────────────────────────────────────────────
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}} {{.Image}} {{.Status}}"
REMOTESCRIPT
# ── Config ────────────────────────────────────────────────────────────────────
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"
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
# 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
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
WORKDIR /app
# Install pnpm
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
# Runtime system deps only
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/*
# Install Rust via rustup (Debian's rustc/cargo is too old for node-crc)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
# Copy production-only artifacts from deploy
COPY --from=builder /tmp/deploy/node_modules ./node_modules
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
COPY pnpm-workspace.yaml .
COPY pnpm-lock.yaml .
COPY package.json .
# Create recordings dir and set ownership
RUN mkdir -p /app/recordings && chown -R node:node /app
USER node
# 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 \
CMD sh -c "kill -0 1"
# Start discord gateway
CMD ["node", "services/discord-gateway/dist/index.js"]
+41 -17
View File
@@ -1,32 +1,56 @@
# ---- Builder Stage (Frontend WASM) ----
FROM rust:alpine 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
# ---- Stage 1: Build Next.js static export ----
FROM node:22-slim AS frontend-builder
WORKDIR /app
# Copy workspace definition and lock file for dependency caching
COPY services/frontend/Cargo.toml services/frontend/Cargo.lock ./
# Install pnpm
RUN corepack enable
# Copy shared-types library
COPY services/frontend/shared-types ./shared-types/
# 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 frontend source
COPY services/frontend/frontend ./frontend/
# 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
# Build WASM bundle via trunk
RUN cd frontend && trunk build --release
# Copy patches (pnpm patchedDependencies)
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
# Nginx config (API/WS proxy + static file serving)
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy frontend static files from builder stage
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
# 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;"]
+7 -19
View File
@@ -1,10 +1,8 @@
version: '3.8'
services:
# Nginx Reverse Proxy + Frontend Static Files
# Routes /api and /ws to backend, serves frontend WASM directly
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
restart: unless-stopped
labels:
@@ -20,10 +18,6 @@ services:
interval: 30s
timeout: 5s
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:
resources:
limits:
@@ -31,9 +25,8 @@ services:
networks:
- app-shared-net
# Backend Service (REST API + WebSocket)
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
restart: unless-stopped
env_file:
@@ -41,10 +34,6 @@ services:
environment:
NODE_ENV: production
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:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
@@ -58,9 +47,8 @@ services:
networks:
- app-shared-net
# Discord Gateway Service (Event capture and processing — no HTTP)
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
restart: unless-stopped
env_file:
@@ -68,10 +56,7 @@ services:
environment:
NODE_ENV: production
volumes:
- ./recordings:/app/recordings
# Bind mount for hot-deploy gateway JS — deploy.sh writes here,
# survives container restart.
- ./gateway-dist:/app/services/discord-gateway/dist
- recordings:/app/recordings
healthcheck:
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
interval: 30s
@@ -85,6 +70,9 @@ services:
networks:
- app-shared-net
volumes:
recordings:
networks:
app-shared-net:
name: app-shared-net
+4
View File
@@ -8,7 +8,9 @@
"exports": {
".": "./dist/index.js",
"./config": "./dist/config/index.js",
"./database/pool": "./dist/database/pool.js",
"./database/schema": "./dist/database/schema.js",
"./database/init": "./dist/database/init.js",
"./errors": "./dist/errors/index.js",
"./logger": "./dist/logger/index.js",
"./moderation-types": "./dist/moderation-types.js",
@@ -21,11 +23,13 @@
},
"dependencies": {
"drizzle-orm": "^0.45.2",
"pg": "^8.13.0",
"pino": "^9.0.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.9.0",
"@types/pg": "^8.11.0",
"pino-pretty": "^13.1.3",
"typescript": "^5.9.3"
}
+10
View File
@@ -22,6 +22,16 @@ export const configSchema = z
MONITOR_GUILD_ID: z.string().min(1).optional(),
TEXT_GUILD_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 ─────────────────────────────────────────────────────
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,
index as pgIndex,
integer as pgInteger,
jsonb as pgJsonb,
real as pgReal,
pgTable,
text as pgText,
timestamp as pgTimestamp,
uuid as pgUuid,
} from "drizzle-orm/pg-core";
// =============================================================================
// Messages
// =============================================================================
/**
* Messages Table (PostgreSQL)
* Stores text messages with AI moderation analysis
@@ -101,36 +108,7 @@ export const pgMessagesTable = pgTable(
}),
);
/**
* 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;
export const messagesTable = pgMessagesTable;
/**
* Attachments Table (PostgreSQL)
@@ -180,3 +158,439 @@ export const pgAttachmentsTable = pgTable(
}).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 "./database/init.js";
export * from "./database/pool.js";
export * from "./database/schema.js";
export * from "./errors/index.js";
export * from "./logger/index.js";
+33
View File
@@ -93,3 +93,36 @@ export interface CommandReply<T = unknown> {
data?: T;
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:
specifier: ^0.45.2
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:
specifier: ^9.0.0
version: 9.14.0
@@ -39,6 +42,9 @@ importers:
'@types/node':
specifier: ^25.9.0
version: 25.9.0
'@types/pg':
specifier: ^8.11.0
version: 8.20.0
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
@@ -3977,9 +3983,6 @@ packages:
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-protocol@1.14.0:
resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==}
@@ -6546,8 +6549,8 @@ snapshots:
'@types/pg@8.20.0':
dependencies:
'@types/node': 25.8.0
pg-protocol: 1.13.0
'@types/node': 25.9.0
pg-protocol: 1.14.0
pg-types: 2.2.0
'@types/qs@6.15.1': {}
@@ -8130,8 +8133,6 @@ snapshots:
dependencies:
pg: 8.21.0
pg-protocol@1.13.0: {}
pg-protocol@1.14.0: {}
pg-types@2.2.0:
+2 -2
View File
@@ -1,10 +1,10 @@
/**
* 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";
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) {
const res = await fetch(`${BASE}${path}`, {
+10 -13
View File
@@ -6,17 +6,16 @@ import express, {
type Response,
} from "express";
import helmet from "helmet";
import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js";
import { createConfigRouter } from "../modules/config/config.routes.js";
import { createDashboardRouter } from "../modules/dashboard/dashboard.routes.js";
import { createHealthRouter } from "../modules/health/health.routes.js";
import { createMascotChatRouter } from "../modules/mascot-chat/mascot-chat.routes.js";
import { createMediaRouter } from "../modules/media/media.routes.js";
import { createMessagesRouter } from "../modules/messages/messages.routes.js";
import { createRecordingsRouter } from "../modules/recordings/recordings.routes.js";
import { createUiStateRouter } from "../modules/ui-state/ui-state.routes.js";
import { createGuildsRouter } from "../modules/voice/guilds.routes.js";
import { createVoiceRouter } from "../modules/voice/voice.routes.js";
import { createAnalysisRouter } from "../modules/analysis/index.js";
import { createConfigRouter } from "../modules/config/index.js";
import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/index.js";
import { createMascotChatRouter } from "../modules/mascot-chat/index.js";
import { createMediaRouter } from "../modules/media/index.js";
import { createMessagesRouter } from "../modules/messages/index.js";
import { createRecordingsRouter } from "../modules/recordings/index.js";
import { createUiStateRouter } from "../modules/ui-state/index.js";
import { createVoiceRouter } from "../modules/voice/index.js";
import { errorHandler } from "../shared/middlewares/index.js";
// Auth removed — dashboard is public
@@ -68,8 +67,6 @@ export function createHttpApp(): Express {
app.use("/api", createMascotChatRouter());
app.use("/api", createRecordingsRouter());
app.use("/api", createUiStateRouter());
app.use("/api/guilds", createGuildsRouter());
app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter());
@@ -1,5 +1,7 @@
import { pgMessagesTable } from "@bete/shared";
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 {
type MappedMessage,
mapMessageRow,
@@ -19,44 +21,27 @@ export type AnalysisSearchResult = MappedMessage;
export class AnalysisRepository {
async search(query: AnalysisSearchQuery): Promise<AnalysisSearchResult[]> {
const pool = getPool();
const db = getDatabase();
const { q = "", channelId, guildId, limit = 20 } = query;
logger.debug({ q, channelId, guildId, limit }, "Searching analysis");
const searchPattern = `%${q}%`;
const clauses: string[] = ["content ILIKE $1"];
const params: (string | number)[] = [searchPattern];
let p = 2;
const conditions: SQL[] = [ilike(pgMessagesTable.content, `%${q}%`)];
if (guildId) {
clauses.push(`guild_id = $${p}`);
params.push(guildId);
p++;
conditions.push(eq(pgMessagesTable.guild_id, guildId));
}
if (channelId) {
clauses.push(`channel_id = $${p}`);
params.push(channelId);
p++;
conditions.push(eq(pgMessagesTable.channel_id, channelId));
}
const where = clauses.join(" AND ");
const { rows } = await pool.query(
`SELECT
id, guild_id, channel_id, thread_id,
user_id, username, avatar_url,
content, edited_content, created_at, edited_at, deleted_at,
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],
);
const rows = await db
.select()
.from(pgMessagesTable)
.where(and(...conditions))
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit);
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 { getPool } from "../../shared/database/index.js";
import {
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";
const _logger = createChildLogger("dashboard.repository");
export class DashboardRepository {
async getStats() {
const pool = getPool();
const db = getDatabase();
const oneDayAgo = Date.now() - 86400000;
// Total messages and breakdown by ai_status
const msgResult = await pool.query(
`
const msgResult = await db.execute(sql`
SELECT
COUNT(*)::int AS total_messages,
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 = 'processing')::int AS total_processing,
COUNT(DISTINCT user_id)::int AS total_users,
COUNT(*) FILTER (WHERE created_at >= $1)::int AS today_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged,
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h
FROM messages
`,
[Date.now() - 86400000, Date.now() - 86400000],
);
COUNT(*) FILTER (WHERE created_at >= ${oneDayAgo})::int AS today_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= ${oneDayAgo})::int AS today_flagged,
COUNT(DISTINCT user_id) FILTER (WHERE created_at >= ${oneDayAgo})::int AS active_users_24h
FROM ${pgMessagesTable}
`);
const msgRow = msgResult.rows[0];
const msgRow = msgResult.rows[0] as Record<string, unknown> | undefined;
// Total voice recordings
const voiceResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM voice_recordings
const voiceResult = await db.execute(sql`
SELECT COUNT(*)::int AS count FROM ${pgVoiceRecordingsTable}
`);
// Total AI user profiles
const profileResult = await pool.query(`
SELECT COUNT(*)::int AS count FROM user_profiles
const profileResult = await db.execute(sql`
SELECT COUNT(*)::int AS count FROM ${pgUserProfilesTable}
`);
// Top channels by message count
const topChannels = await pool.query(`
const topChannels = await db.execute(sql`
SELECT channel_id,
COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name,
COUNT(*)::int AS message_count
FROM messages
FROM ${pgMessagesTable}
WHERE metadata IS NOT NULL AND metadata != ''
GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName')
ORDER BY COUNT(*) DESC
@@ -78,31 +83,26 @@ export class DashboardRepository {
}
async listUsers(query: ListUsersQuery) {
const pool = getPool();
const db = getDatabase();
const limit = query.limit ?? 20;
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
const conditions: SQL[] = [];
if (query.search) {
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) {
conditions.push(`m.last_message_at < $${paramIdx}`);
params.push(Number(query.cursor));
paramIdx++;
conditions.push(sql`m.last_message_at < ${Number(query.cursor)}`);
}
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
m.user_id,
m.username,
@@ -120,17 +120,15 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
MAX(created_at) AS last_message_at
FROM messages
FROM ${pgMessagesTable}
GROUP BY user_id, username, avatar_url
) m
LEFT JOIN user_profiles p ON p.user_id = m.user_id
LEFT JOIN user_reputations r ON r.user_id = m.user_id
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
${whereClause}
ORDER BY m.last_message_at DESC NULLS LAST
LIMIT $${paramIdx}
`,
[...params, limit + 1],
);
LIMIT ${limit + 1}
`);
const data = (rows as Record<string, unknown>[])
.slice(0, limit)
@@ -158,31 +156,26 @@ export class DashboardRepository {
}
async listChannels(query: ListUsersQuery & { guildId?: string }) {
const pool = getPool();
const db = getDatabase();
const limit = query.limit ?? 20;
const conditions: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
const conditions: SQL[] = [];
if (query.search) {
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) {
conditions.push(`m.guild_id = $${paramIdx}`);
params.push(query.guildId);
paramIdx++;
conditions.push(sql`m.guild_id = ${query.guildId}`);
}
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
m.channel_id,
m.channel_name,
@@ -200,16 +193,14 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
MAX(created_at) AS last_message_at
FROM messages
FROM ${pgMessagesTable}
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
) 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}
ORDER BY m.total_messages DESC
LIMIT $${paramIdx}
`,
[...params, limit + 1],
);
LIMIT ${limit + 1}
`);
const data = ((rows as Record<string, unknown>[]) || [])
.slice(0, limit)
@@ -234,10 +225,9 @@ export class DashboardRepository {
}
async getChannelDetail(channelId: string) {
const pool = getPool();
const db = getDatabase();
const channelResult = await pool.query(
`
const channelResult = await db.execute(sql`
SELECT
m.channel_id,
m.channel_name,
@@ -255,28 +245,23 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
FROM messages
WHERE channel_id = $1
FROM ${pgMessagesTable}
WHERE channel_id = ${channelId}
GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName')
) m
LEFT JOIN channel_cultures c ON c.channel_id = m.channel_id
`,
[channelId],
);
LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id
`);
const row = channelResult.rows[0] as Record<string, unknown> | undefined;
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
FROM messages
WHERE channel_id = $1
FROM ${pgMessagesTable}
WHERE channel_id = ${channelId}
ORDER BY created_at DESC
LIMIT 20
`,
[channelId],
);
`);
return {
channel_id: String(row.channel_id),
@@ -301,11 +286,9 @@ export class DashboardRepository {
}
async getUserDetail(userId: string) {
const pool = getPool();
const db = getDatabase();
// Basic user info + profile + reputation
const userResult = await pool.query(
`
const userResult = await db.execute(sql`
SELECT
m.user_id,
m.username,
@@ -326,32 +309,26 @@ export class DashboardRepository {
COUNT(*)::int AS total_messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
FROM messages
WHERE user_id = $1
FROM ${pgMessagesTable}
WHERE user_id = ${userId}
GROUP BY user_id, username, avatar_url
) m
LEFT JOIN user_profiles p ON p.user_id = m.user_id
LEFT JOIN user_reputations r ON r.user_id = m.user_id
`,
[userId],
);
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
`);
const row = userResult.rows[0] as Record<string, unknown> | undefined;
if (!row) {
return null;
}
// Recent messages
const recent = await pool.query(
`
const recent = await db.execute(sql`
SELECT id, content, channel_id, created_at, ai_status
FROM messages
WHERE user_id = $1
FROM ${pgMessagesTable}
WHERE user_id = ${userId}
ORDER BY created_at DESC
LIMIT 20
`,
[userId],
);
`);
return {
user_id: String(row.user_id),
@@ -364,13 +341,13 @@ export class DashboardRepository {
last_analyzed_at: row.last_analyzed_at
? Number(row.last_analyzed_at)
: 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:
row.clean_message_streak !== null
row.clean_message_streak != null
? Number(row.clean_message_streak)
: null,
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) => ({
id: String(r.id),
content: String(r.content),
@@ -0,0 +1 @@
export { createDashboardRouter } from "./dashboard.routes.js";
@@ -1,5 +1,6 @@
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");
@@ -7,8 +8,8 @@ export class HealthRepository {
async checkDatabaseConnection() {
try {
logger.debug("Running database health check");
const pool = getPool();
await pool.query("SELECT 1 AS result");
const db = getDatabase();
await db.execute(sql`SELECT 1 AS result`);
logger.debug("Database health check passed");
return { connected: true };
} catch (err: unknown) {
@@ -1,8 +1,5 @@
import { createChildLogger } from "@bete/shared/logger";
import { healthRepository } from "./health.repository.js";
const _logger = createChildLogger("health.service");
export class HealthService {
async getHealth(verbose = false) {
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(
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") {
return res.status(400).json({
error: "INVALID_INPUT",
@@ -1,5 +1,7 @@
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared";
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");
@@ -38,22 +40,15 @@ export interface ServerInsights {
export class MascotChatRepository {
async saveConversation(input: SaveConversationInput): Promise<void> {
const pool = getPool();
const db = getDatabase();
await pool.query(
`
INSERT INTO mascot_chat_messages
(user_id, user_message, mascot_response, context, created_at)
VALUES ($1, $2, $3, $4::jsonb, $5)
`,
[
input.userId,
input.userMessage,
input.mascotResponse,
JSON.stringify(input.context ?? {}),
input.timestamp.toISOString(),
],
);
await db.insert(pgMascotChatMessagesTable).values({
user_id: input.userId,
user_message: input.userMessage,
mascot_response: input.mascotResponse,
context: (input.context ?? {}) as Record<string, unknown>,
created_at: input.timestamp,
});
logger.debug({ userId: input.userId }, "Conversation saved");
}
@@ -62,69 +57,61 @@ export class MascotChatRepository {
userId: string,
limit: number,
): Promise<MascotChatHistoryRow[]> {
const pool = getPool();
const db = getDatabase();
const { rows } = await pool.query<MascotChatHistoryRow>(
`
SELECT id, user_id, user_message, mascot_response, context, created_at
FROM mascot_chat_messages
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`,
[userId, limit],
);
const rows = await db
.select()
.from(pgMascotChatMessagesTable)
.where(eq(pgMascotChatMessagesTable.user_id, userId))
.orderBy(desc(pgMascotChatMessagesTable.created_at))
.limit(limit);
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> {
const pool = getPool();
const db = getDatabase();
const { rowCount } = await pool.query(
`DELETE FROM mascot_chat_messages WHERE user_id = $1`,
[userId],
const deleted = await db
.delete(pgMascotChatMessagesTable)
.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(
guildId?: string,
channelId?: string,
): Promise<ServerInsights> {
const pool = getPool();
try {
const params: string[] = [];
const clauses: string[] = [];
const db = getDatabase();
const conditions: SQL[] = [];
if (guildId) {
params.push(guildId);
clauses.push(`guild_id = $${params.length}`);
conditions.push(eq(pgMessagesTable.guild_id, guildId));
}
if (channelId) {
params.push(channelId);
clauses.push(`channel_id = $${params.length}`);
conditions.push(eq(pgMessagesTable.channel_id, channelId));
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const where = conditions.length > 0 ? and(...conditions) : undefined;
const { rows } = await pool.query<ServerInsights>(
`
SELECT
COUNT(*)::int AS total_messages,
COUNT(DISTINCT user_id)::int AS active_users,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
FROM messages
${where}
`,
params,
);
const [result] = await db
.select({
total_messages: sql<number>`COUNT(*)::int`,
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
})
.from(pgMessagesTable)
.where(where);
const insights = rows[0] ?? {
const insights = result ?? {
total_messages: 0,
active_users: 0,
flagged: 0,
@@ -1,14 +1,20 @@
import express, { type Router } from "express";
import { validateBody } from "../../shared/middlewares/index.js";
import {
clearMascotChatHistory,
getMascotChatHistory,
handleMascotChat,
} from "./mascot-chat.controller.js";
import { chatRequestSchema } from "./mascot-chat.schema.js";
export function createMascotChatRouter(): Router {
const router = express.Router();
router.post("/mascot/chat", handleMascotChat);
router.post(
"/mascot/chat",
validateBody(chatRequestSchema),
handleMascotChat,
);
router.get("/mascot/chat/history", getMascotChatHistory);
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 type { Request, Response, Router } 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";
const logger = createChildLogger("media.routes");
@@ -22,16 +23,12 @@ export function createMediaRouter(): Router {
// POST /api/media/queue
router.post(
"/media/queue",
validateBody(mediaQueueSchema),
asyncHandler(async (req: Request, res: Response) => {
const source = req.body?.source as string | undefined;
if (!source) {
res.status(400).json({
error: "VALIDATION_ERROR",
message: "source is required",
});
return;
}
const mode = (req.body?.mode as "music" | "screen") ?? "music";
const { source, mode } = req.body as {
source: string;
mode: "music" | "screen";
};
logger.debug({ source, mode }, "Media queue requested");
const state = await queue(source, mode);
res.json(state);
@@ -61,15 +58,9 @@ export function createMediaRouter(): Router {
// POST /api/media/volume
router.post(
"/media/volume",
validateBody(mediaVolumeSchema),
asyncHandler(async (req: Request, res: Response) => {
const volume = Number(req.body?.volume ?? 1.0);
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;
}
const { volume } = req.body as { volume: number };
logger.debug({ volume }, "Media volume requested");
const state = await setVolume(volume);
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 type { NextFunction, Request, Response } from "express";
import { asyncHandler, requireParam } from "../../shared/middlewares/index.js";
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 function handleListMessages(
req: Request,
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
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);
})(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);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
res.json(result);
})(req, res, next);
}
},
);
export function handleGetMessageById(
req: Request,
res: Response,
next: NextFunction,
) {
return asyncHandler(async (req: Request, res: Response) => {
const id = requireParam(req.params.id, "route parameter", "id");
export const handleGetMessageById = asyncHandler(
async (req: Request, res: Response) => {
if (!req.params.id) {
res.status(400).json({ error: "Missing route parameter: id" });
return;
}
const id = req.params.id as string;
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
})(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;
logger.debug({ guildId, limit }, "Handling get image messages");
const result = await messagesService.getImageMessages(guildId, limit);
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);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(
@@ -86,5 +70,5 @@ export function handleGetAttachmentsByChannel(
query,
);
res.json(result);
})(req, res, next);
}
},
);
@@ -14,6 +14,7 @@ import {
or,
type SQL,
} from "drizzle-orm";
import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js";
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
import type {
@@ -23,12 +24,12 @@ import type {
} 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
* 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");
@@ -1,7 +1,7 @@
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 { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import {
handleGetAttachmentsByChannel,
handleGetImageMessages,
@@ -9,6 +9,7 @@ import {
handleGetMessagesByChannel,
handleListMessages,
} from "./messages.controller.js";
import { reanalyzeBatchSchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.routes");
@@ -55,8 +56,9 @@ export function createMessagesRouter(): Router {
// is not captured as an :id param.
router.post(
"/messages/reanalyze-batch",
validateBody(reanalyzeBatchSchema),
asyncHandler(async (req: Request, res: Response) => {
const { guildId, channelId, messageIds } = (req.body ?? {}) as {
const { guildId, channelId, messageIds } = req.body as {
guildId?: string;
channelId?: string;
messageIds?: string[];
@@ -36,6 +36,13 @@ export const messageUpdateSchema = z.object({
aiConfidence: z.number().optional(),
});
export const reanalyzeBatchSchema = z.object({
guildId: z.string().optional(),
channelId: z.string().optional(),
messageIds: z.array(z.string()).optional(),
});
export type MessageQuery = z.infer<typeof messageQuerySchema>;
export type MessageCreate = z.infer<typeof messageCreateSchema>;
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
export type ReanalyzeBatchInput = z.infer<typeof reanalyzeBatchSchema>;
@@ -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 { sql } from "drizzle-orm";
import { and, desc, eq, lt, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("recordings.service");
@@ -36,37 +37,47 @@ export class RecordingsService {
logger.info({ limit }, "getRecent called");
const db = getDatabase();
const conditions: ReturnType<typeof sql>[] = [];
const conditions: SQL[] = [];
if (filters?.cursor) {
conditions.push(sql`created_at < ${filters.cursor}::numeric`);
conditions.push(
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
);
}
if (filters?.channelId) {
conditions.push(sql`channel_id = ${filters.channelId}`);
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
}
if (filters?.userId) {
conditions.push(sql`user_id = ${filters.userId}`);
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
}
const whereClause =
conditions.length > 0
? sql`WHERE ${sql.join(conditions, sql` AND `)}`
: sql``;
const where = conditions.length > 0 ? and(...conditions) : undefined;
const { rows } = await db.execute(sql`
SELECT
id, user_id, username, avatar_url, guild_id, channel_id,
channel_name, filename, size_bytes, download_url,
upload_status, upload_error, created_at, uploaded_at,
COALESCE(size_bytes, 0) AS duration_bytes
FROM voice_recordings
${whereClause}
ORDER BY created_at DESC
LIMIT ${limit + 1}
`);
const allRows = await db
.select({
id: pgVoiceRecordingsTable.id,
user_id: pgVoiceRecordingsTable.user_id,
username: pgVoiceRecordingsTable.username,
avatar_url: pgVoiceRecordingsTable.avatar_url,
guild_id: pgVoiceRecordingsTable.guild_id,
channel_id: pgVoiceRecordingsTable.channel_id,
channel_name: pgVoiceRecordingsTable.channel_name,
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 hasMore = rows.length > limit;
const items = allRows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = allRows.length > limit;
const nextCursor = hasMore
? String(items[items.length - 1]?.created_at)
: null;
@@ -76,7 +87,9 @@ export class RecordingsService {
async deleteById(id: string): Promise<void> {
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