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