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
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:
co-authored by
Claude Opus 4.8
parent
63f21513bd
commit
5802d02e29
+54
-65
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -15,3 +15,6 @@ worktrees/
|
||||
.worktrees/
|
||||
services/frontend/frontend/dist/
|
||||
target/
|
||||
|
||||
# Gitea CI runner logs
|
||||
.gitea/workflows/*.log
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
Generated
+8
-7
@@ -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:
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user