mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
feat: introduce web dashboard with multi-bot management
- add Discord OAuth2 authentication - allow users to register their bots via token - implement dynamic bot start/stop system - store bots in database (multi-tenant) - replace single-bot env setup with scalable architecture
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
.next
|
||||
apps/*/dist
|
||||
apps/*/.next
|
||||
packages/*/dist
|
||||
*.tsbuildinfo
|
||||
**/*.tsbuildinfo
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
|
||||
+29
-24
@@ -1,26 +1,31 @@
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CLIENT_ID=
|
||||
DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots
|
||||
DATABASE_SSL=false
|
||||
DATABASE_SSL_REJECT_UNAUTHORIZED=true
|
||||
DATABASE_SSL_CA=
|
||||
ALLOW_INSECURE_DB_SSL=false
|
||||
PRESENCE_STREAM_URL=https://twitch.tv/discord
|
||||
POSTGRES_DB=discord_bots
|
||||
POSTGRES_USER=discord_bot
|
||||
NODE_ENV=development
|
||||
|
||||
# Shared infrastructure
|
||||
POSTGRES_DB=discord_saas
|
||||
POSTGRES_USER=discord_saas
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_POSTGRES_PASSWORD
|
||||
REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD
|
||||
POSTGRES_PORT=5432
|
||||
PREFIX=+
|
||||
DEFAULT_LANG=en
|
||||
DEV_GUILD_ID=
|
||||
AUTO_DEPLOY_SLASH=false
|
||||
LOG_LEVEL=info
|
||||
STATE_BACKEND=memory
|
||||
REDIS_URL=
|
||||
COMMAND_DISPATCH_MODE=local
|
||||
COMMAND_QUEUE_NAME=bot:${botId}:command-jobs
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS=20
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10
|
||||
RATE_LIMIT_FAIL_OPEN=false
|
||||
ENABLE_LEADER_ELECTION=true
|
||||
REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD
|
||||
DATABASE_URL=postgresql://discord_saas:CHANGE_ME_STRONG_POSTGRES_PASSWORD@localhost:5432/discord_saas
|
||||
DATABASE_SSL=false
|
||||
|
||||
# OAuth2 + auth API
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_REDIRECT_URI=http://localhost:4000/auth/discord/callback
|
||||
JWT_SECRET=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
JWT_EXPIRES_IN=7d
|
||||
SESSION_COOKIE_NAME=saas_session
|
||||
COOKIE_SECURE=false
|
||||
WEB_URL=http://localhost:3000
|
||||
API_BASE_URL=http://localhost:4000
|
||||
|
||||
# Encryption key for bot tokens (32 bytes base64)
|
||||
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_BYTE_BASE64_KEY
|
||||
|
||||
# Multi-tenant API limits
|
||||
TENANT_RATE_LIMIT_MAX=120
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# Web
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.next/
|
||||
apps/*/dist/
|
||||
apps/*/.next/
|
||||
packages/*/dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -1,116 +1,64 @@
|
||||
# AGENT.md - Guide du projet template_discordjs
|
||||
# AGENT.md - Guide SaaS Multi-Tenant
|
||||
|
||||
Version: 2.0
|
||||
Version: 3.0
|
||||
|
||||
But
|
||||
---
|
||||
Ce fichier decrit la structure actuelle du projet, les conventions d'architecture et le workflow recommande pour contribuer sans introduire de dette technique.
|
||||
Decrire l'architecture cible du monorepo SaaS pour bot Discord multi-tenant.
|
||||
|
||||
Vue d'ensemble
|
||||
--------------
|
||||
- Stack: TypeScript + Discord.js 14.
|
||||
- Build: esbuild + typecheck TypeScript strict.
|
||||
- Localisation: `src/i18n/en.json`, `src/i18n/fr.json`, `src/i18n/es.json`.
|
||||
- Base de donnees: PostgreSQL, stores injectes via lifecycle centralise.
|
||||
- Stack backend: Node.js + TypeScript + Express + PostgreSQL + Redis + BullMQ.
|
||||
- Bot runtime: Discord.js avec gestion dynamique multi-instance.
|
||||
- Frontend: Next.js App Router.
|
||||
- Structure: monorepo `apps/*` + `packages/*`.
|
||||
|
||||
Organisation des dossiers
|
||||
-------------------------
|
||||
- Racine:
|
||||
- `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml`.
|
||||
- `src/app/`
|
||||
- `bootstrap.ts`: wiring des dependances, init DB, handlers, shutdown.
|
||||
- `container.ts`: contrats de services injectes.
|
||||
- `src/commands/`
|
||||
- Wrappers de commandes uniquement.
|
||||
- Chaque fichier retourne une commande via `defineCommand(...)`.
|
||||
- Aucune logique metier lourde dans cette couche.
|
||||
- `src/modules/`
|
||||
- `help/`: module help (service + commande).
|
||||
- `presence/`: point d'entree module presence (services et contrats).
|
||||
- `memberMessages/`: point d'entree module member messages (services et contrats).
|
||||
- `src/features/`
|
||||
- Implementation interne des modules (detail technique derriere `src/modules/*`).
|
||||
- `src/core/`
|
||||
- `commands/`: parser, registry, slash builder, usage.
|
||||
- `execution/`: pipeline d'execution des commandes (dispatch local/worker, cooldown/rate-limit stores).
|
||||
- `runtime/`: coordination multi-instance (leader election / startup locks).
|
||||
- `logging/`: logger structure JSON (`pino`).
|
||||
- `discord/`: helpers partages (resolveReplyMessage, session registry).
|
||||
- `src/database/`
|
||||
- `stores/`: implementations PostgreSQL concretes.
|
||||
- `migrations/`: fichiers SQL versionnes appliques via `npm run migrate`.
|
||||
- `dbLifecycle.ts`: init/shutdown centralise.
|
||||
- `src/validators/`
|
||||
- Validation et sanitation metier (presence, member messages).
|
||||
- `src/types/`
|
||||
- Types purs partages (pas de logique metier).
|
||||
- `src/events/`
|
||||
- Enregistrement des listeners Discord, relies aux services injectes.
|
||||
- `src/handlers/`
|
||||
- Entrees prefix/slash et adaptation du contexte d'execution.
|
||||
- `src/utils/`
|
||||
- Utilitaires generiques transverses.
|
||||
- `apps/api/`
|
||||
- OAuth2 Discord, JWT cookie, routes multi-tenant, validation token bot, publication jobs BullMQ.
|
||||
- `apps/bot/`
|
||||
- BotManager dynamique (`Map<botId, Client>`), worker de controle (`start/stop/restart`).
|
||||
- `apps/web/`
|
||||
- Dashboard utilisateur (login, liste bots, ajout bot, actions runtime).
|
||||
- `packages/shared/`
|
||||
- Types partages, constantes queue, helpers Redis namespacing, chiffrement token AES-GCM.
|
||||
- `database/migrations/`
|
||||
- SQL versionne (`schema_migrations`) avec schema multi-tenant.
|
||||
|
||||
Principes d'architecture
|
||||
------------------------
|
||||
- Commands UI only:
|
||||
- Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`.
|
||||
- Modules own business logic:
|
||||
- Toute logique metier testable va dans `src/modules/*` (les wrappers `src/commands/*` restent minces).
|
||||
- Core is shared infra:
|
||||
- Helpers communs Discord, execution pipeline, parser, registry.
|
||||
- Execution pipeline is decoupled:
|
||||
- Parsing reste dans `src/handlers/*`.
|
||||
- Dispatch est route par `src/core/execution/dispatch.ts` (`local` ou `worker`).
|
||||
- Execution metier passe par `CommandExecutor` avec stores abstraits (memory/Redis).
|
||||
- Contexts are split for low coupling:
|
||||
- `ExecutionContext` (core), `TransportContext` (Discord), `I18nContext` (localization).
|
||||
- `CommandExecutionContext` conserve des alias legacy pour ne pas casser les commandes existantes.
|
||||
- Validators isolate rules:
|
||||
- Les regles de validation/sanitation ne vont pas dans `src/types`.
|
||||
- Database via repository contracts:
|
||||
- Les modules dependent d'interfaces, pas de singletons globaux.
|
||||
- Bootstrap owns lifecycle:
|
||||
- Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`.
|
||||
- Multi-tenant strict:
|
||||
- toutes les requetes de lecture/ecriture passent par `tenant_id`.
|
||||
- tables critiques liees a `tenant_id` et/ou `owner_user_id`.
|
||||
- Pas de secret en clair:
|
||||
- token bot chiffre en DB (`token_ciphertext`, `token_iv`, `token_tag`).
|
||||
- Controle runtime decouple:
|
||||
- API publie des jobs, bot manager consomme et execute.
|
||||
- Separation responsabilites:
|
||||
- API: auth + orchestration.
|
||||
- Bot: runtime Discord.
|
||||
- Web: UX dashboard.
|
||||
|
||||
Conventions de code
|
||||
-------------------
|
||||
- TypeScript strict obligatoire.
|
||||
- Exports nommes preferes.
|
||||
- Fichiers en lowerCamelCase.
|
||||
- Commentaires courts uniquement pour clarifier un bloc non trivial.
|
||||
- Eviter tout couplage direct d'une feature vers une autre sans passer par contrats explicites.
|
||||
- TypeScript strict.
|
||||
- Exports nommes.
|
||||
- Erreurs API explicites et codes HTTP coherents.
|
||||
- Logs sans fuite de secrets.
|
||||
|
||||
Procedure: ajouter une commande
|
||||
--------------------------------
|
||||
1. Creer un wrapper dans `src/commands/`.
|
||||
2. Declarer la commande avec `defineCommand({ ... })`.
|
||||
3. Deleguer `execute` vers un module `src/modules/...`.
|
||||
4. Ajouter la commande dans `createCommandList` (`src/commands/index.ts`).
|
||||
5. Ajouter les cles i18n dans `src/i18n/*.json`.
|
||||
6. Ajouter les tests cibles (`tests/`) selon la logique introduite.
|
||||
|
||||
Procedure: ajouter une feature
|
||||
------------------------------
|
||||
1. Creer `src/modules/<feature>/`.
|
||||
2. Definir les contrats repository dans la feature.
|
||||
3. Implementer le service metier independant de Discord quand possible.
|
||||
4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`.
|
||||
5. Ajouter une migration SQL versionnee dans `database/migrations/`.
|
||||
6. Cablage d'injection dans `src/app/bootstrap.ts`.
|
||||
7. Ajouter tests unitaires et, si necessaire, integration.
|
||||
|
||||
Tests et verification
|
||||
---------------------
|
||||
- Verification minimale avant PR:
|
||||
- `npm run migrate`
|
||||
- `npm run typecheck`
|
||||
- `npm test`
|
||||
- En cas de refactor de structure:
|
||||
- verifier README + AGENT.md + i18n + imports.
|
||||
Workflow recommande
|
||||
-------------------
|
||||
1. Ajouter/adapter migration SQL dans `database/migrations`.
|
||||
2. Adapter repositories API/Bot avec filtre tenant.
|
||||
3. Ajouter endpoint API + validation zod.
|
||||
4. Brancher action queue si runtime impacte.
|
||||
5. Mettre a jour le dashboard web.
|
||||
6. Valider `npm run typecheck` puis `docker compose up -d --build`.
|
||||
|
||||
Securite
|
||||
--------
|
||||
- Ne jamais committer de secrets.
|
||||
- `.env*` (sauf `.env.example`) doit rester ignore.
|
||||
- Eviter d'exposer des credentials dans logs/scripts de debug.
|
||||
- Ne jamais committer `.env`.
|
||||
- Garder `TOKEN_ENCRYPTION_KEY` hors depot.
|
||||
- Appliquer `httpOnly` + `sameSite` sur cookie session.
|
||||
- Eviter les logs de payloads sensibles (token, secrets).
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk add --no-cache fontconfig ttf-dejavu
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY scripts ./scripts
|
||||
COPY database ./database
|
||||
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
RUN chown -R app:app /app
|
||||
|
||||
USER app
|
||||
|
||||
CMD ["sh", "-c", "node scripts/migrate.mjs && node build/index.js"]
|
||||
@@ -1,124 +1,173 @@
|
||||
# Discord.js v14 Framework Template
|
||||
# Discord Bot SaaS Platform (Multi-Tenant)
|
||||
|
||||
Professional command framework template for Discord.js `14.26.2` with:
|
||||
Plateforme SaaS multi-tenant pour gérer des bots Discord avec un seul service bot dynamique.
|
||||
|
||||
- Single command object schema
|
||||
- Minimal command authoring (`name`, `category`, `execute`)
|
||||
- Optional per-user command cooldown (`cooldown` in seconds)
|
||||
- Shared execution logic for prefix and slash
|
||||
- Dispatch pipeline ready for local execution or worker queue mode
|
||||
- Global user rate limiting (memory or Redis backend)
|
||||
- Structured JSON logging with `pino`
|
||||
- External JSON i18n
|
||||
- Automatic prefix and slash localizations from locale files
|
||||
- One localized `name` per language drives both prefix and slash triggers
|
||||
- Typed argument schema
|
||||
- User permission checks
|
||||
- Auto-generated help from command metadata
|
||||
## Vue d'ensemble
|
||||
|
||||
## Presence Storage
|
||||
Cette version transforme le modèle "1 conteneur = 1 bot" en architecture scalable:
|
||||
|
||||
- The `presence` command is persisted in PostgreSQL.
|
||||
- Storage is keyed by `bot_id` (Discord user id), so one PostgreSQL instance can serve multiple bots.
|
||||
- Presence survives bot restarts and container restarts.
|
||||
- Un seul `apps/bot` qui gère `N` bots dynamiquement
|
||||
- `apps/api` pour OAuth2 Discord + API sécurisée + orchestration des bots
|
||||
- `apps/web` (Next.js) pour le dashboard
|
||||
- PostgreSQL pour les données tenant-scopées
|
||||
- Redis + BullMQ pour la file d'actions de contrôle (`start`, `stop`, `restart`)
|
||||
|
||||
## Setup
|
||||
## Structure Monorepo
|
||||
|
||||
1. Install dependencies:
|
||||
npm install
|
||||
2. Create environment file:
|
||||
```text
|
||||
/apps
|
||||
/web # Next.js dashboard
|
||||
/api # Backend API (OAuth2, JWT, gestion bots)
|
||||
/bot # Bot manager multi-instance dynamique
|
||||
/packages
|
||||
/shared # Types, crypto token, helpers Redis namespacés
|
||||
/database
|
||||
/migrations # SQL versionné (inclut multi-tenant SaaS)
|
||||
docker-compose.yml
|
||||
.env
|
||||
```
|
||||
|
||||
## Architecture Runtime
|
||||
|
||||
### 1) API (`apps/api`)
|
||||
|
||||
- OAuth2 Discord (`/auth/discord/login`, `/auth/discord/callback`)
|
||||
- Session JWT en cookie `httpOnly`
|
||||
- Endpoints multi-tenant pour bots (`/api/bots`)
|
||||
- Validation du token bot via l'API Discord avant stockage
|
||||
- Chiffrement AES-256-GCM des tokens en base
|
||||
- Publication des actions de contrôle via BullMQ (Redis)
|
||||
- Rate limit par tenant (clé Redis namespacée `tenant:{tenantId}:...`)
|
||||
|
||||
### 2) Bot Manager (`apps/bot`)
|
||||
|
||||
- Charge les bots à relancer depuis PostgreSQL au démarrage
|
||||
- Maintient une map en mémoire:
|
||||
|
||||
```ts
|
||||
Map<botId, DiscordClient>
|
||||
```
|
||||
|
||||
- Worker BullMQ: consomme les jobs `start|stop|restart`
|
||||
- Met à jour l'état runtime (`starting`, `running`, `stopping`, `error`)
|
||||
- Journalise les événements runtime en base (`bot_runtime_events`)
|
||||
|
||||
### 3) Web (`apps/web`)
|
||||
|
||||
- Pages principales:
|
||||
- `/login`
|
||||
- `/dashboard`
|
||||
- Dashboard utilisateur:
|
||||
- Ajouter un bot (token)
|
||||
- Voir la liste des bots du tenant
|
||||
- `start / stop / restart`
|
||||
|
||||
## Schéma PostgreSQL (core SaaS)
|
||||
|
||||
Migration: `database/migrations/0004_saas_multitenant.sql`
|
||||
|
||||
- `tenants`
|
||||
- `id` (UUID)
|
||||
- `owner_user_id`
|
||||
- `users`
|
||||
- `tenant_id` FK
|
||||
- `discord_user_id` (unique)
|
||||
- `role`
|
||||
- `bots`
|
||||
- `tenant_id` FK
|
||||
- `owner_user_id` FK
|
||||
- `discord_bot_id` (unique global)
|
||||
- `token_ciphertext`, `token_iv`, `token_tag`
|
||||
- `status`, `last_error`
|
||||
- `bot_runtime_events`
|
||||
- logs runtime par bot + tenant
|
||||
|
||||
Les tables legacy de configuration (`bot_presence_states`, `bot_member_message_configs`, `bot_log_event_configs`) sont enrichies avec `tenant_id` et `owner_user_id` pour respecter l'isolation multi-tenant stricte.
|
||||
|
||||
## API Principale
|
||||
|
||||
### Auth
|
||||
|
||||
- `GET /auth/discord/login`
|
||||
- `GET /auth/discord/callback`
|
||||
- `POST /auth/logout`
|
||||
- `GET /api/me`
|
||||
|
||||
### Bots
|
||||
|
||||
- `GET /api/bots`
|
||||
- `POST /api/bots`
|
||||
- body: `{ token: string, displayName?: string }`
|
||||
- `POST /api/bots/:botId/start`
|
||||
- `POST /api/bots/:botId/stop`
|
||||
- `POST /api/bots/:botId/restart`
|
||||
|
||||
Tous ces endpoints sont tenant-scopés via la session.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Tokens bot jamais stockés en clair
|
||||
- AES-256-GCM avec `TOKEN_ENCRYPTION_KEY` (32 bytes en base64)
|
||||
- Validation token côté Discord avant insertion
|
||||
- Session auth via JWT httpOnly cookie
|
||||
- Filtrage systématique des requêtes par `tenant_id`
|
||||
- Clés Redis namespacées par tenant
|
||||
- Rate limiting par tenant sur les actions de contrôle
|
||||
|
||||
## Docker Compose (fixe)
|
||||
|
||||
Services:
|
||||
|
||||
- `web`
|
||||
- `api`
|
||||
- `bot`
|
||||
- `postgres`
|
||||
- `redis`
|
||||
|
||||
Pas de service par bot. Pas de `.env` par bot.
|
||||
|
||||
## Démarrage local
|
||||
|
||||
1. Copier l'environnement:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
3. Fill required values in `.env`:
|
||||
- `DISCORD_TOKEN`
|
||||
- `DISCORD_CLIENT_ID`
|
||||
- `DATABASE_URL`
|
||||
4. Optional values:
|
||||
- `DATABASE_SSL` (`true` for managed cloud DB, `false` for local Docker)
|
||||
- `DATABASE_SSL_REJECT_UNAUTHORIZED` (`true` by default for secure TLS verification)
|
||||
- `DATABASE_SSL_CA` (optional CA cert chain, supports escaped newlines)
|
||||
- `ALLOW_INSECURE_DB_SSL` (`false` by default; set `true` only for local/dev exceptions)
|
||||
- `PRESENCE_STREAM_URL` (used when activity type is `STREAMING`)
|
||||
- `AUTO_DEPLOY_SLASH` (`true` to sync slash commands on startup)
|
||||
- `DEV_GUILD_ID` (optional guild scope for faster slash sync)
|
||||
- `LOG_LEVEL` (default `info`, JSON logs)
|
||||
- `STATE_BACKEND` (`memory` or `redis`)
|
||||
- `REDIS_URL` (required when `STATE_BACKEND=redis`)
|
||||
- `GLOBAL_RATE_LIMIT_MAX_REQUESTS` (global per-user request budget)
|
||||
- `GLOBAL_RATE_LIMIT_WINDOW_SECONDS` (window size in seconds)
|
||||
- `COMMAND_DISPATCH_MODE` (`local` or `worker`; enable `worker` only if a queue consumer is deployed)
|
||||
- `COMMAND_QUEUE_NAME` (Redis list used in `worker` mode)
|
||||
- `ENABLE_LEADER_ELECTION` (`true` to guard startup jobs in multi-instance)
|
||||
5. Run migrations:
|
||||
npm run migrate
|
||||
6. Start in development:
|
||||
npm run dev
|
||||
7. Validate code quality:
|
||||
- npm run typecheck
|
||||
- npm run test
|
||||
- npm run check
|
||||
```
|
||||
|
||||
## Docker Deployment (2 Bots + PostgreSQL)
|
||||
2. Générer une clé de chiffrement valide (32 bytes base64):
|
||||
|
||||
1. Fill production env files:
|
||||
- `.env.bot-alpha.prod`
|
||||
- `.env.bot-beta.prod`
|
||||
2. Set strong shared DB credentials:
|
||||
- `POSTGRES_DB`
|
||||
- `POSTGRES_USER`
|
||||
- `POSTGRES_PASSWORD`
|
||||
3. Start stack:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
3. Installer les dépendances monorepo:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Lancer la stack complète:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
4. Stop stack:
|
||||
docker compose down
|
||||
```
|
||||
|
||||
By default, `docker-compose.yml` provisions:
|
||||
- `bot_alpha`: Discord bot instance A
|
||||
- `bot_beta`: Discord bot instance B
|
||||
- `postgres`: PostgreSQL 16 with persistent volume `postgres_data`
|
||||
5. Dashboard:
|
||||
|
||||
Both bots use the same PostgreSQL service, and data remains isolated per bot through `bot_id` keys.
|
||||
- `http://localhost:3000`
|
||||
|
||||
Security defaults:
|
||||
- PostgreSQL is exposed only on the internal Compose network (`expose: 5432`, no host bind).
|
||||
- Bot containers enforce `no-new-privileges` and graceful stop.
|
||||
- Log rotation is enabled (`max-size=10m`, `max-file=3`).
|
||||
- Database TLS verification remains strict by default when SSL is enabled.
|
||||
API:
|
||||
|
||||
## Architecture
|
||||
- `http://localhost:4000/health`
|
||||
|
||||
- `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown
|
||||
- `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute)
|
||||
- `src/modules/help/*`: help command module (service + command contract)
|
||||
- `src/modules/presence/*`: module entrypoint for presence runtime and contracts
|
||||
- `src/modules/memberMessages/*`: module entrypoint for member-message runtime and contracts
|
||||
- `src/features/*`: implementation details used behind `src/modules/*`
|
||||
- `src/core/commands/*`: registry, parsing, slash payload, usage helpers
|
||||
- `src/core/execution/CommandExecutor.ts`: execution core (permissions, cooldown, global rate limit)
|
||||
- `src/core/execution/dispatch.ts`: dispatch abstraction (`local` or `worker`)
|
||||
- `src/core/execution/cooldownStore.ts`: cooldown store contracts and memory/Redis implementations
|
||||
- `src/core/execution/globalRateLimitStore.ts`: global limiter contracts and memory/Redis implementations
|
||||
- `src/core/runtime/leaderCoordinator.ts`: leader-only startup coordination for multi-instance deployments
|
||||
- `src/core/logging/logger.ts`: centralized structured logger (`pino`)
|
||||
- `src/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry)
|
||||
- `src/database/stores/*`: PostgreSQL store implementations
|
||||
- `src/database/dbLifecycle.ts`: centralized DB init/shutdown lifecycle
|
||||
- `src/validators/*`: business validation/sanitization
|
||||
- `src/types/*`: pure shared types and contracts
|
||||
- `src/i18n/*.json`: external i18n dictionaries
|
||||
Bot manager health:
|
||||
|
||||
## Included Commands
|
||||
- `http://localhost:4100/health`
|
||||
|
||||
- `kiss` (`fun`) with required `user` arg
|
||||
- `ping` (`utility`)
|
||||
- `welcome` (`utility`) interactive welcome-message panel
|
||||
- `goodbye` (`utility`) interactive goodbye-message panel
|
||||
- `presence` (`utility`) with interactive status/activity/text panel
|
||||
- `help` (`core`) with auto category and usage generation
|
||||
## Notes Scalabilité
|
||||
|
||||
## Adding A Command
|
||||
|
||||
1. Create a command object in `src/commands/...`
|
||||
2. Follow the schema in `src/types/command.ts`
|
||||
3. Add command to `src/commands/index.ts`
|
||||
4. Put business logic in `src/modules/<module>/...` and keep `src/commands/*` as wrappers only
|
||||
5. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically
|
||||
- Une instance bot unique peut gérer des dizaines/centaines de bots selon ressources.
|
||||
- Pour monter en charge horizontalement:
|
||||
- scaler `apps/api`
|
||||
- scaler `apps/bot` avec coordination queue/locks
|
||||
- conserver Redis + PostgreSQL managés
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --from=deps /workspace/node_modules ./node_modules
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/api ./apps/api
|
||||
COPY database ./database
|
||||
|
||||
RUN npm run build -w @saas/shared && npm exec -- tsc -p apps/api/tsconfig.json
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /workspace
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /workspace/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY apps/api/scripts ./apps/api/scripts
|
||||
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=builder /workspace/apps/api/dist ./apps/api/dist
|
||||
COPY database ./database
|
||||
|
||||
CMD ["sh", "-c", "npm run migrate -w @saas/api && npm run start -w @saas/api"]
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@saas/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json",
|
||||
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "node scripts/migrate.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@saas/shared": "*",
|
||||
"bullmq": "^5.21.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"jose": "^5.9.6",
|
||||
"pg": "^8.13.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/pg": "^8.11.11"
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const LOCK_CLASS_ID = 9421;
|
||||
const LOCK_OBJECT_ID = 2601;
|
||||
const LOCK_CLASS_ID = 7813;
|
||||
const LOCK_OBJECT_ID = 4312;
|
||||
|
||||
const parseBoolean = (value, fallback = false) => {
|
||||
if (value === undefined || value === null || String(value).trim().length === 0) {
|
||||
@@ -18,15 +18,6 @@ const parseBoolean = (value, fallback = false) => {
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
};
|
||||
|
||||
const optionalString = (value) => {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
||||
|
||||
const listMigrationFiles = async (migrationsDir) => {
|
||||
@@ -40,11 +31,13 @@ const listMigrationFiles = async (migrationsDir) => {
|
||||
|
||||
const loadMigrations = async (migrationsDir) => {
|
||||
const files = await listMigrationFiles(migrationsDir);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new Error(`[migrate] no SQL migration files found in ${migrationsDir}`);
|
||||
}
|
||||
|
||||
const migrations = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
const filePath = path.join(migrationsDir, fileName);
|
||||
const sql = await readFile(filePath, "utf8");
|
||||
@@ -70,38 +63,19 @@ const ensureMigrationTable = async (client) => {
|
||||
};
|
||||
|
||||
const applyMigrations = async (client, migrations) => {
|
||||
const appliedResult = await client.query(
|
||||
"SELECT version, checksum FROM schema_migrations ORDER BY version ASC",
|
||||
);
|
||||
const applied = await client.query("SELECT version, checksum FROM schema_migrations ORDER BY version ASC");
|
||||
|
||||
const appliedByVersion = new Map(
|
||||
appliedResult.rows.map((row) => [String(row.version), String(row.checksum)]),
|
||||
);
|
||||
|
||||
const migrationFileNames = new Set(migrations.map((entry) => entry.fileName));
|
||||
|
||||
for (const version of appliedByVersion.keys()) {
|
||||
if (!migrationFileNames.has(version)) {
|
||||
throw new Error(
|
||||
`[migrate] migration history drift detected: database has version \"${version}\" but file is missing locally.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let appliedCount = 0;
|
||||
let skippedCount = 0;
|
||||
const appliedByVersion = new Map(applied.rows.map((row) => [String(row.version), String(row.checksum)]));
|
||||
|
||||
for (const migration of migrations) {
|
||||
const existingChecksum = appliedByVersion.get(migration.fileName);
|
||||
|
||||
if (existingChecksum) {
|
||||
if (existingChecksum !== migration.checksum) {
|
||||
throw new Error(
|
||||
`[migrate] checksum mismatch for ${migration.fileName}. Expected ${existingChecksum}, got ${migration.checksum}.`,
|
||||
`[migrate] checksum mismatch for ${migration.fileName}. expected ${existingChecksum}, got ${migration.checksum}`,
|
||||
);
|
||||
}
|
||||
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -114,49 +88,30 @@ const applyMigrations = async (client, migrations) => {
|
||||
[migration.fileName, migration.checksum],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
appliedCount += 1;
|
||||
console.log(`[migrate] applied ${migration.fileName}`);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw new Error(`[migrate] failed while applying ${migration.fileName}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[migrate] completed. applied=${appliedCount}, skipped=${skippedCount}`);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
loadEnv();
|
||||
|
||||
const databaseUrl = optionalString(process.env.DATABASE_URL);
|
||||
if (!databaseUrl) {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("[migrate] DATABASE_URL is required");
|
||||
}
|
||||
|
||||
const databaseSsl = parseBoolean(process.env.DATABASE_SSL, false);
|
||||
const rejectUnauthorized = parseBoolean(process.env.DATABASE_SSL_REJECT_UNAUTHORIZED, true);
|
||||
const allowInsecureDbSsl = parseBoolean(process.env.ALLOW_INSECURE_DB_SSL, false);
|
||||
|
||||
if (databaseSsl && !rejectUnauthorized && !allowInsecureDbSsl) {
|
||||
throw new Error(
|
||||
"[migrate] insecure TLS configuration blocked. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or ALLOW_INSECURE_DB_SSL=true for non-production use.",
|
||||
);
|
||||
}
|
||||
|
||||
const databaseSslCa = optionalString(process.env.DATABASE_SSL_CA)?.replace(/\\n/g, "\n");
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, "..");
|
||||
const rootDir = path.resolve(scriptDir, "../../..");
|
||||
const migrationsDir = path.join(rootDir, "database", "migrations");
|
||||
|
||||
const migrations = await loadMigrations(migrationsDir);
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
ssl: databaseSsl
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: parseBoolean(process.env.DATABASE_SSL, false)
|
||||
? {
|
||||
rejectUnauthorized,
|
||||
...(databaseSslCa ? { ca: databaseSslCa } : {}),
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -164,9 +119,12 @@ const main = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const migrations = await loadMigrations(migrationsDir);
|
||||
|
||||
await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]);
|
||||
await ensureMigrationTable(client);
|
||||
await applyMigrations(client, migrations);
|
||||
console.log("[migrate] completed");
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined);
|
||||
client.release();
|
||||
@@ -0,0 +1,122 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
|
||||
const oauthTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
});
|
||||
|
||||
const discordUserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string().nullable(),
|
||||
global_name: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const discordBotSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
discriminator: z.string(),
|
||||
global_name: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export interface DiscordIdentity {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordBotIdentity {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const createOauthState = (): string => {
|
||||
return randomBytes(24).toString("base64url");
|
||||
};
|
||||
|
||||
export const buildDiscordLoginUrl = (state: string): string => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
redirect_uri: env.DISCORD_REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "identify",
|
||||
state,
|
||||
prompt: "consent",
|
||||
});
|
||||
|
||||
return `https://discord.com/oauth2/authorize?${params.toString()}`;
|
||||
};
|
||||
|
||||
const createAvatarUrl = (discordUserId: string, avatarHash: string | null): string | null => {
|
||||
if (!avatarHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `https://cdn.discordapp.com/avatars/${discordUserId}/${avatarHash}.png`;
|
||||
};
|
||||
|
||||
export const exchangeCodeForDiscordIdentity = async (code: string): Promise<DiscordIdentity> => {
|
||||
const payload = new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: env.DISCORD_REDIRECT_URI,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error("Discord OAuth token exchange failed");
|
||||
}
|
||||
|
||||
const tokenJson = oauthTokenSchema.parse(await tokenResponse.json());
|
||||
|
||||
const userResponse = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `${tokenJson.token_type} ${tokenJson.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
throw new Error("Discord user profile fetch failed");
|
||||
}
|
||||
|
||||
const userJson = discordUserSchema.parse(await userResponse.json());
|
||||
|
||||
return {
|
||||
id: userJson.id,
|
||||
username: userJson.global_name ?? userJson.username,
|
||||
avatarUrl: createAvatarUrl(userJson.id, userJson.avatar),
|
||||
};
|
||||
};
|
||||
|
||||
export const validateDiscordBotToken = async (botToken: string): Promise<DiscordBotIdentity> => {
|
||||
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Invalid Discord bot token");
|
||||
}
|
||||
|
||||
const botJson = discordBotSchema.parse(await response.json());
|
||||
return {
|
||||
id: botJson.id,
|
||||
displayName: botJson.global_name ?? botJson.username,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
import type { AuthSession } from "../types/auth.js";
|
||||
|
||||
interface SessionJwtPayload {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
discordUserId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET);
|
||||
|
||||
export const issueSessionToken = async (session: AuthSession): Promise<string> => {
|
||||
return new SignJWT({
|
||||
tenantId: session.tenantId,
|
||||
discordUserId: session.discordUserId,
|
||||
username: session.username,
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(session.userId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(env.JWT_EXPIRES_IN)
|
||||
.sign(secret);
|
||||
};
|
||||
|
||||
export const verifySessionToken = async (token: string): Promise<AuthSession> => {
|
||||
const verified = await jwtVerify<SessionJwtPayload>(token, secret, {
|
||||
algorithms: ["HS256"],
|
||||
});
|
||||
|
||||
if (!verified.payload.sub) {
|
||||
throw new Error("session token missing subject");
|
||||
}
|
||||
|
||||
return {
|
||||
userId: verified.payload.sub,
|
||||
tenantId: verified.payload.tenantId,
|
||||
discordUserId: verified.payload.discordUserId,
|
||||
username: verified.payload.username,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const parseBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(4000),
|
||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||
REDIS_URL: z.string().url("REDIS_URL must be a valid URL"),
|
||||
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
|
||||
DISCORD_CLIENT_SECRET: z.string().min(1, "DISCORD_CLIENT_SECRET is required"),
|
||||
DISCORD_REDIRECT_URI: z.string().url("DISCORD_REDIRECT_URI must be a valid URL"),
|
||||
WEB_URL: z.string().url("WEB_URL must be a valid URL"),
|
||||
API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"),
|
||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
|
||||
JWT_EXPIRES_IN: z.string().min(2).default("7d"),
|
||||
SESSION_COOKIE_NAME: z.string().min(1).default("saas_session"),
|
||||
COOKIE_SECURE: z.string().default("false").transform(parseBoolean),
|
||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||
TENANT_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
|
||||
TENANT_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
});
|
||||
|
||||
export const env = envSchema.parse(process.env);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
export const createPgPool = (): Pool => {
|
||||
return new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
ssl: env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { EncryptedToken, PublicBot } from "@saas/shared";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
export interface DbUser {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
discordUserId: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
role: "owner" | "member";
|
||||
}
|
||||
|
||||
interface CreateOrUpdateBotInput {
|
||||
tenantId: string;
|
||||
ownerUserId: string;
|
||||
discordBotId: string;
|
||||
displayName: string;
|
||||
encryptedToken: EncryptedToken;
|
||||
}
|
||||
|
||||
const mapUser = (row: Record<string, unknown>): DbUser => {
|
||||
return {
|
||||
id: String(row.id),
|
||||
tenantId: String(row.tenant_id),
|
||||
discordUserId: String(row.discord_user_id),
|
||||
username: String(row.username),
|
||||
avatarUrl: row.avatar_url ? String(row.avatar_url) : null,
|
||||
role: (row.role as "owner" | "member") ?? "member",
|
||||
};
|
||||
};
|
||||
|
||||
const mapPublicBot = (row: Record<string, unknown>): PublicBot => {
|
||||
return {
|
||||
id: String(row.id),
|
||||
tenantId: String(row.tenant_id),
|
||||
discordBotId: String(row.discord_bot_id),
|
||||
displayName: String(row.display_name),
|
||||
status: row.status as PublicBot["status"],
|
||||
lastError: row.last_error ? String(row.last_error) : null,
|
||||
createdAt: new Date(String(row.created_at)).toISOString(),
|
||||
updatedAt: new Date(String(row.updated_at)).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const upsertUserFromDiscord = async (
|
||||
pool: Pool,
|
||||
identity: {
|
||||
discordUserId: string;
|
||||
username: string;
|
||||
avatarUrl: string | null;
|
||||
},
|
||||
): Promise<DbUser> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const existing = await client.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
|
||||
FROM users
|
||||
WHERE discord_user_id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[identity.discordUserId],
|
||||
);
|
||||
|
||||
if (existing.rowCount && existing.rows[0]) {
|
||||
const updated = await client.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET username = $2,
|
||||
avatar_url = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
|
||||
`,
|
||||
[existing.rows[0].id, identity.username, identity.avatarUrl],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return mapUser(updated.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const tenantInsert = await client.query<{ id: string }>(
|
||||
`
|
||||
INSERT INTO tenants DEFAULT VALUES
|
||||
RETURNING id
|
||||
`,
|
||||
);
|
||||
|
||||
const tenantId = tenantInsert.rows[0]?.id;
|
||||
if (!tenantId) {
|
||||
throw new Error("Failed to create tenant");
|
||||
}
|
||||
|
||||
const userInsert = await client.query(
|
||||
`
|
||||
INSERT INTO users (
|
||||
tenant_id,
|
||||
discord_user_id,
|
||||
username,
|
||||
avatar_url,
|
||||
role
|
||||
) VALUES ($1, $2, $3, $4, 'owner')
|
||||
RETURNING id, tenant_id, discord_user_id, username, avatar_url, role
|
||||
`,
|
||||
[tenantId, identity.discordUserId, identity.username, identity.avatarUrl],
|
||||
);
|
||||
|
||||
const user = mapUser(userInsert.rows[0] as Record<string, unknown>);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE tenants
|
||||
SET owner_user_id = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
[user.id, tenantId],
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return user;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserByIdAndTenant = async (
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<DbUser | null> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_user_id, username, avatar_url, role
|
||||
FROM users
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[userId, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapUser(result.rows[0] as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const listBotsForTenant = async (pool: Pool, tenantId: string): Promise<PublicBot[]> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
FROM bots
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => mapPublicBot(row as Record<string, unknown>));
|
||||
};
|
||||
|
||||
export const getBotForTenant = async (
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
botId: string,
|
||||
): Promise<PublicBot | null> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
FROM bots
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[tenantId, botId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapPublicBot(result.rows[0] as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const createOrUpdateBotForTenant = async (
|
||||
pool: Pool,
|
||||
input: CreateOrUpdateBotInput,
|
||||
): Promise<PublicBot> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const existingByDiscordBot = await client.query(
|
||||
`
|
||||
SELECT id, tenant_id
|
||||
FROM bots
|
||||
WHERE discord_bot_id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[input.discordBotId],
|
||||
);
|
||||
|
||||
if (existingByDiscordBot.rows[0] && existingByDiscordBot.rows[0].tenant_id !== input.tenantId) {
|
||||
throw new Error("BOT_ALREADY_CLAIMED");
|
||||
}
|
||||
|
||||
const existingInTenant = await client.query(
|
||||
`
|
||||
SELECT id
|
||||
FROM bots
|
||||
WHERE tenant_id = $1 AND discord_bot_id = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[input.tenantId, input.discordBotId],
|
||||
);
|
||||
|
||||
let upserted;
|
||||
|
||||
if (existingInTenant.rows[0]) {
|
||||
upserted = await client.query(
|
||||
`
|
||||
UPDATE bots
|
||||
SET owner_user_id = $2,
|
||||
display_name = $3,
|
||||
token_ciphertext = $4,
|
||||
token_iv = $5,
|
||||
token_tag = $6,
|
||||
status = 'stopped',
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
existingInTenant.rows[0].id,
|
||||
input.ownerUserId,
|
||||
input.displayName,
|
||||
input.encryptedToken.ciphertext,
|
||||
input.encryptedToken.iv,
|
||||
input.encryptedToken.tag,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
upserted = await client.query(
|
||||
`
|
||||
INSERT INTO bots (
|
||||
tenant_id,
|
||||
owner_user_id,
|
||||
discord_bot_id,
|
||||
display_name,
|
||||
token_ciphertext,
|
||||
token_iv,
|
||||
token_tag,
|
||||
status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'stopped')
|
||||
RETURNING id, tenant_id, discord_bot_id, display_name, status, last_error, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.ownerUserId,
|
||||
input.discordBotId,
|
||||
input.displayName,
|
||||
input.encryptedToken.ciphertext,
|
||||
input.encryptedToken.iv,
|
||||
input.encryptedToken.tag,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return mapPublicBot(upserted.rows[0] as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
export const setBotStatusForTenant = async (
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
botId: string,
|
||||
status: PublicBot["status"],
|
||||
lastError: string | null = null,
|
||||
): Promise<void> => {
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE bots
|
||||
SET status = $3,
|
||||
last_error = $4,
|
||||
updated_at = NOW()
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
`,
|
||||
[tenantId, botId, status, lastError],
|
||||
);
|
||||
};
|
||||
|
||||
export const insertBotRuntimeEvent = async (
|
||||
pool: Pool,
|
||||
input: {
|
||||
tenantId: string;
|
||||
botId: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
},
|
||||
): Promise<void> => {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.botId,
|
||||
input.level,
|
||||
input.message,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
import { parseTokenEncryptionKey } from "@saas/shared";
|
||||
|
||||
import { env } from "./config/env.js";
|
||||
import { createPgPool } from "./db/pool.js";
|
||||
import { getUserByIdAndTenant } from "./db/repositories.js";
|
||||
import { requireAuth } from "./middleware/auth.js";
|
||||
import { createTenantRateLimit } from "./middleware/tenantRateLimit.js";
|
||||
import { createAuthRouter } from "./routes/authRoutes.js";
|
||||
import { createBotRouter } from "./routes/botRoutes.js";
|
||||
import { createBotControlQueue } from "./services/botControlQueue.js";
|
||||
|
||||
const bootstrap = async (): Promise<void> => {
|
||||
const pgPool = createPgPool();
|
||||
|
||||
const redis = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
});
|
||||
|
||||
const tokenEncryptionKey = parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY);
|
||||
const botControlQueue = createBotControlQueue(redis.duplicate());
|
||||
|
||||
await pgPool.query("SELECT 1");
|
||||
await redis.ping();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.WEB_URL,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(express.json({ limit: "200kb" }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.status(200).json({
|
||||
ok: true,
|
||||
service: "api",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/auth", createAuthRouter({ pool: pgPool }));
|
||||
|
||||
app.get("/api/me", requireAuth, async (req, res) => {
|
||||
if (!req.auth) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await getUserByIdAndTenant(pgPool, req.auth.userId, req.auth.tenantId);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Session does not match an existing user" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
tenantId: user.tenantId,
|
||||
discordUserId: user.discordUserId,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const tenantControlRateLimit = createTenantRateLimit({
|
||||
redis,
|
||||
scope: "bot-control",
|
||||
maxRequests: env.TENANT_RATE_LIMIT_MAX,
|
||||
windowSeconds: env.TENANT_RATE_LIMIT_WINDOW_SECONDS,
|
||||
});
|
||||
|
||||
app.use(
|
||||
"/api/bots",
|
||||
requireAuth,
|
||||
createBotRouter({
|
||||
pool: pgPool,
|
||||
queue: botControlQueue,
|
||||
tokenEncryptionKey,
|
||||
tenantControlRateLimit,
|
||||
}),
|
||||
);
|
||||
|
||||
const server = app.listen(env.PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] listening on :${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[api] shutdown requested (${signal})`);
|
||||
|
||||
server.close(async () => {
|
||||
await botControlQueue.close();
|
||||
await redis.quit().catch(() => undefined);
|
||||
await pgPool.end().catch(() => undefined);
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.once("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[api] fatal startup error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
import { verifySessionToken } from "../auth/jwt.js";
|
||||
|
||||
export const requireAuth: RequestHandler = async (req, res, next) => {
|
||||
const sessionToken = req.cookies?.[env.SESSION_COOKIE_NAME];
|
||||
|
||||
if (!sessionToken) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
req.auth = await verifySessionToken(sessionToken);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: "Invalid or expired session" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RequestHandler } from "express";
|
||||
import type { Redis } from "ioredis";
|
||||
|
||||
import { tenantRateLimitKey } from "@saas/shared";
|
||||
|
||||
interface TenantRateLimitOptions {
|
||||
redis: Redis;
|
||||
scope: string;
|
||||
maxRequests: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export const createTenantRateLimit = ({
|
||||
redis,
|
||||
scope,
|
||||
maxRequests,
|
||||
windowSeconds,
|
||||
}: TenantRateLimitOptions): RequestHandler => {
|
||||
return async (req, res, next) => {
|
||||
if (!req.auth) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const key = tenantRateLimitKey(req.auth.tenantId, scope);
|
||||
|
||||
try {
|
||||
const count = await redis.incr(key);
|
||||
if (count === 1) {
|
||||
await redis.expire(key, windowSeconds);
|
||||
}
|
||||
|
||||
if (count > maxRequests) {
|
||||
res.status(429).json({
|
||||
error: "Rate limit reached",
|
||||
limit: maxRequests,
|
||||
windowSeconds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch {
|
||||
res.status(503).json({ error: "Rate limit backend unavailable" });
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Router } from "express";
|
||||
import { Router as expressRouter } from "express";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import { buildDiscordLoginUrl, createOauthState, exchangeCodeForDiscordIdentity } from "../auth/discordOAuth.js";
|
||||
import { issueSessionToken } from "../auth/jwt.js";
|
||||
import { env } from "../config/env.js";
|
||||
import { upsertUserFromDiscord } from "../db/repositories.js";
|
||||
|
||||
const OAUTH_STATE_COOKIE_NAME = "discord_oauth_state";
|
||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface AuthRouterDependencies {
|
||||
pool: Pool;
|
||||
}
|
||||
|
||||
const sessionCookieConfig = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: env.COOKIE_SECURE,
|
||||
maxAge: SESSION_TTL_MS,
|
||||
path: "/",
|
||||
};
|
||||
|
||||
export const createAuthRouter = ({ pool }: AuthRouterDependencies): Router => {
|
||||
const router = expressRouter();
|
||||
|
||||
router.get("/discord/login", (_req, res) => {
|
||||
const state = createOauthState();
|
||||
|
||||
res.cookie(OAUTH_STATE_COOKIE_NAME, state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.COOKIE_SECURE,
|
||||
maxAge: OAUTH_STATE_TTL_MS,
|
||||
path: "/auth/discord/callback",
|
||||
});
|
||||
|
||||
res.redirect(buildDiscordLoginUrl(state));
|
||||
});
|
||||
|
||||
router.get("/discord/callback", async (req, res) => {
|
||||
const code = typeof req.query.code === "string" ? req.query.code : "";
|
||||
const state = typeof req.query.state === "string" ? req.query.state : "";
|
||||
const cookieState = req.cookies?.[OAUTH_STATE_COOKIE_NAME];
|
||||
|
||||
if (!code || !state || !cookieState || state !== cookieState) {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_state_invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const discordIdentity = await exchangeCodeForDiscordIdentity(code);
|
||||
const user = await upsertUserFromDiscord(pool, {
|
||||
discordUserId: discordIdentity.id,
|
||||
username: discordIdentity.username,
|
||||
avatarUrl: discordIdentity.avatarUrl,
|
||||
});
|
||||
|
||||
const sessionToken = await issueSessionToken({
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
discordUserId: user.discordUserId,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.cookie(env.SESSION_COOKIE_NAME, sessionToken, sessionCookieConfig);
|
||||
res.redirect(`${env.WEB_URL}/dashboard`);
|
||||
} catch {
|
||||
res.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/auth/discord/callback" });
|
||||
res.redirect(`${env.WEB_URL}/login?error=oauth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/logout", (_req, res) => {
|
||||
res.clearCookie(env.SESSION_COOKIE_NAME, { path: "/" });
|
||||
res.status(204).send();
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { Router as expressRouter } from "express";
|
||||
import type { Queue } from "bullmq";
|
||||
import { z } from "zod";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import type { BotControlJob } from "@saas/shared";
|
||||
import { encryptToken } from "@saas/shared";
|
||||
|
||||
import { validateDiscordBotToken } from "../auth/discordOAuth.js";
|
||||
import {
|
||||
createOrUpdateBotForTenant,
|
||||
getBotForTenant,
|
||||
insertBotRuntimeEvent,
|
||||
listBotsForTenant,
|
||||
setBotStatusForTenant,
|
||||
} from "../db/repositories.js";
|
||||
import { enqueueBotControlAction } from "../services/botControlQueue.js";
|
||||
|
||||
const addBotSchema = z.object({
|
||||
token: z.string().min(20, "Bot token is required"),
|
||||
displayName: z.string().min(2).max(80).optional(),
|
||||
});
|
||||
|
||||
const botIdParamsSchema = z.object({
|
||||
botId: z.string().uuid("botId must be a valid UUID"),
|
||||
});
|
||||
|
||||
interface BotRouterDependencies {
|
||||
pool: Pool;
|
||||
queue: Queue<BotControlJob>;
|
||||
tokenEncryptionKey: Buffer;
|
||||
tenantControlRateLimit: RequestHandler;
|
||||
}
|
||||
|
||||
const queueBotAction = (
|
||||
action: "start" | "stop" | "restart",
|
||||
statusDuringAction: "starting" | "stopping",
|
||||
deps: BotRouterDependencies,
|
||||
): RequestHandler => {
|
||||
return async (req, res) => {
|
||||
if (!req.auth) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedParams = botIdParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
res.status(400).json({ error: "Invalid botId" });
|
||||
return;
|
||||
}
|
||||
|
||||
const bot = await getBotForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId);
|
||||
if (!bot) {
|
||||
res.status(404).json({ error: "Bot not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await setBotStatusForTenant(deps.pool, req.auth.tenantId, parsedParams.data.botId, statusDuringAction, null);
|
||||
|
||||
await insertBotRuntimeEvent(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
botId: parsedParams.data.botId,
|
||||
level: "info",
|
||||
message: `Bot ${action} requested by dashboard user`,
|
||||
metadata: {
|
||||
userId: req.auth.userId,
|
||||
action,
|
||||
},
|
||||
});
|
||||
|
||||
await enqueueBotControlAction(deps.queue, {
|
||||
tenantId: req.auth.tenantId,
|
||||
userId: req.auth.userId,
|
||||
botId: parsedParams.data.botId,
|
||||
action,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
accepted: true,
|
||||
action,
|
||||
botId: parsedParams.data.botId,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const createBotRouter = (deps: BotRouterDependencies): Router => {
|
||||
const router = expressRouter();
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
if (!req.auth) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const bots = await listBotsForTenant(deps.pool, req.auth.tenantId);
|
||||
res.json({ bots });
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
if (!req.auth) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedBody = addBotSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
res.status(400).json({ error: parsedBody.error.issues[0]?.message ?? "Invalid body" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const botIdentity = await validateDiscordBotToken(parsedBody.data.token);
|
||||
const encryptedToken = encryptToken(parsedBody.data.token, deps.tokenEncryptionKey);
|
||||
|
||||
const bot = await createOrUpdateBotForTenant(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
ownerUserId: req.auth.userId,
|
||||
discordBotId: botIdentity.id,
|
||||
displayName: parsedBody.data.displayName ?? botIdentity.displayName,
|
||||
encryptedToken,
|
||||
});
|
||||
|
||||
await insertBotRuntimeEvent(deps.pool, {
|
||||
tenantId: req.auth.tenantId,
|
||||
botId: bot.id,
|
||||
level: "info",
|
||||
message: "Bot credentials stored and encrypted",
|
||||
metadata: {
|
||||
byUser: req.auth.userId,
|
||||
discordBotId: botIdentity.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ bot });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "BOT_ALREADY_CLAIMED") {
|
||||
res.status(409).json({ error: "This Discord bot is already claimed by another tenant" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message === "Invalid Discord bot token") {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "Unable to store bot" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:botId/start", deps.tenantControlRateLimit, queueBotAction("start", "starting", deps));
|
||||
router.post("/:botId/stop", deps.tenantControlRateLimit, queueBotAction("stop", "stopping", deps));
|
||||
router.post("/:botId/restart", deps.tenantControlRateLimit, queueBotAction("restart", "starting", deps));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Queue } from "bullmq";
|
||||
import type { Redis } from "ioredis";
|
||||
|
||||
import {
|
||||
BOT_CONTROL_QUEUE,
|
||||
BOT_CONTROL_QUEUE_PREFIX,
|
||||
type BotControlAction,
|
||||
type BotControlJob,
|
||||
} from "@saas/shared";
|
||||
|
||||
export const createBotControlQueue = (redis: Redis): Queue<BotControlJob> => {
|
||||
return new Queue<BotControlJob>(BOT_CONTROL_QUEUE, {
|
||||
connection: redis,
|
||||
prefix: BOT_CONTROL_QUEUE_PREFIX,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 750,
|
||||
},
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 3000,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const enqueueBotControlAction = async (
|
||||
queue: Queue<BotControlJob>,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
botId: string;
|
||||
action: BotControlAction;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const payload: BotControlJob = {
|
||||
tenantId: input.tenantId,
|
||||
userId: input.userId,
|
||||
botId: input.botId,
|
||||
action: input.action,
|
||||
requestedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await queue.add(`${input.action}:${input.botId}:${Date.now()}`, payload);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface AuthSession {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
discordUserId: string;
|
||||
username: string;
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
import type { AuthSession } from "./auth.js";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: AuthSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY apps/bot/package.json ./apps/bot/package.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --from=deps /workspace/node_modules ./node_modules
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY apps/bot ./apps/bot
|
||||
|
||||
RUN npm run build -w @saas/bot
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /workspace
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=deps /workspace/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY apps/bot/package.json ./apps/bot/package.json
|
||||
COPY --from=builder /workspace/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=builder /workspace/apps/bot/dist ./apps/bot/dist
|
||||
|
||||
CMD ["npm", "run", "start", "-w", "@saas/bot"]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@saas/bot",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "npm run build -w @saas/shared && tsc -p tsconfig.json && node ./scripts/copyLegacyLocales.mjs",
|
||||
"typecheck": "npm run build -w @saas/shared && tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "^0.1.98",
|
||||
"@saas/shared": "*",
|
||||
"bullmq": "^5.21.2",
|
||||
"discord.js": "^14.17.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"ioredis": "^5.4.1",
|
||||
"pg": "^8.13.3",
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const SOURCE_DIR = path.join(BOT_DIR, "src", "legacy", "i18n");
|
||||
const TARGET_DIR = path.join(BOT_DIR, "dist", "legacy", "i18n");
|
||||
|
||||
if (!existsSync(SOURCE_DIR)) {
|
||||
throw new Error(`[i18n] source locale directory not found: ${SOURCE_DIR}`);
|
||||
}
|
||||
|
||||
mkdirSync(TARGET_DIR, { recursive: true });
|
||||
|
||||
for (const fileName of readdirSync(SOURCE_DIR)) {
|
||||
if (!fileName.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFileSync(path.join(SOURCE_DIR, fileName), path.join(TARGET_DIR, fileName));
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUPPORTED_LANGS } from "../legacy/types/command.js";
|
||||
|
||||
loadEnv();
|
||||
|
||||
const parseBoolean = (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
};
|
||||
|
||||
const optionalString = (value?: string): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const parseOptionalUrl = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = optionalString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(4100),
|
||||
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
|
||||
DATABASE_SSL: z.string().default("false").transform(parseBoolean),
|
||||
DATABASE_SSL_REJECT_UNAUTHORIZED: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("true")
|
||||
.transform(parseBoolean),
|
||||
DATABASE_SSL_CA: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
const normalized = optionalString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized.replace(/\\n/g, "\n");
|
||||
}),
|
||||
ALLOW_INSECURE_DB_SSL: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform(parseBoolean),
|
||||
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
|
||||
TOKEN_ENCRYPTION_KEY: z.string().min(10, "TOKEN_ENCRYPTION_KEY is required"),
|
||||
PRESENCE_STREAM_URL: z
|
||||
.string()
|
||||
.url("PRESENCE_STREAM_URL must be a valid URL")
|
||||
.optional()
|
||||
.default("https://twitch.tv/discord"),
|
||||
PREFIX: z.string().min(1).max(5).default("+"),
|
||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
||||
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
|
||||
AUTO_DEPLOY_SLASH: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform(parseBoolean),
|
||||
LOG_LEVEL: z.string().trim().min(1).default("info"),
|
||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
||||
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
|
||||
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"),
|
||||
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
||||
RATE_LIMIT_FAIL_OPEN: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform(parseBoolean),
|
||||
ENABLE_LEADER_ELECTION: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("true")
|
||||
.transform(parseBoolean),
|
||||
});
|
||||
|
||||
const parsed = envSchema.parse(process.env);
|
||||
|
||||
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
|
||||
throw new Error(
|
||||
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) {
|
||||
throw new Error("REDIS_URL is required when STATE_BACKEND=redis.");
|
||||
}
|
||||
|
||||
export const env = parsed;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
export const createPgPool = (): Pool => {
|
||||
const ssl = env.DATABASE_SSL
|
||||
? {
|
||||
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
|
||||
ca: env.DATABASE_SSL_CA,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
ssl,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { BotStatus } from "@saas/shared";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
export interface StoredBotCredentials {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
ownerUserId: string;
|
||||
discordBotId: string;
|
||||
displayName: string;
|
||||
tokenCiphertext: string;
|
||||
tokenIv: string;
|
||||
tokenTag: string;
|
||||
status: BotStatus;
|
||||
}
|
||||
|
||||
const mapBotCredentials = (row: Record<string, unknown>): StoredBotCredentials => {
|
||||
return {
|
||||
id: String(row.id),
|
||||
tenantId: String(row.tenant_id),
|
||||
ownerUserId: String(row.owner_user_id),
|
||||
discordBotId: String(row.discord_bot_id),
|
||||
displayName: String(row.display_name),
|
||||
tokenCiphertext: String(row.token_ciphertext),
|
||||
tokenIv: String(row.token_iv),
|
||||
tokenTag: String(row.token_tag),
|
||||
status: row.status as BotStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export const listBotsToRestore = async (pool: Pool): Promise<StoredBotCredentials[]> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
|
||||
FROM bots
|
||||
WHERE status IN ('running', 'starting')
|
||||
ORDER BY created_at ASC
|
||||
`,
|
||||
);
|
||||
|
||||
return result.rows.map((row) => mapBotCredentials(row as Record<string, unknown>));
|
||||
};
|
||||
|
||||
export const getBotCredentialsById = async (
|
||||
pool: Pool,
|
||||
botId: string,
|
||||
): Promise<StoredBotCredentials | null> => {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, tenant_id, owner_user_id, discord_bot_id, display_name, token_ciphertext, token_iv, token_tag, status
|
||||
FROM bots
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[botId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapBotCredentials(result.rows[0] as Record<string, unknown>);
|
||||
};
|
||||
|
||||
export const setBotStatusById = async (
|
||||
pool: Pool,
|
||||
botId: string,
|
||||
status: BotStatus,
|
||||
lastError: string | null = null,
|
||||
): Promise<void> => {
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE bots
|
||||
SET status = $2,
|
||||
last_error = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`,
|
||||
[botId, status, lastError],
|
||||
);
|
||||
};
|
||||
|
||||
export const insertRuntimeEvent = async (
|
||||
pool: Pool,
|
||||
input: {
|
||||
tenantId: string;
|
||||
botId: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
metadata?: unknown;
|
||||
},
|
||||
): Promise<void> => {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO bot_runtime_events (tenant_id, bot_id, level, message, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.botId,
|
||||
input.level,
|
||||
input.message,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
import { parseTokenEncryptionKey } from "@saas/shared";
|
||||
|
||||
import { env } from "./config/env.js";
|
||||
import { createPgPool } from "./db/pool.js";
|
||||
import { BotManager } from "./manager/BotManager.js";
|
||||
import { createBotControlWorker } from "./queue/worker.js";
|
||||
|
||||
const bootstrap = async (): Promise<void> => {
|
||||
const pgPool = createPgPool();
|
||||
|
||||
const redis = env.REDIS_URL
|
||||
? new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
await pgPool.query("SELECT 1");
|
||||
if (redis) {
|
||||
await redis.ping();
|
||||
}
|
||||
|
||||
const manager = new BotManager(pgPool, parseTokenEncryptionKey(env.TOKEN_ENCRYPTION_KEY));
|
||||
await manager.loadAndStartPersistedBots();
|
||||
|
||||
const worker = redis ? createBotControlWorker(redis.duplicate(), manager) : null;
|
||||
|
||||
if (worker) {
|
||||
worker.on("completed", (job) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] completed ${job.data.action} for bot ${job.data.botId}`);
|
||||
});
|
||||
|
||||
worker.on("failed", (job, error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[bot] failed ${job?.data.action ?? "unknown"} for bot ${job?.data.botId ?? "unknown"}`, error);
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[bot] REDIS_URL is not configured, bot control queue worker is disabled");
|
||||
}
|
||||
|
||||
const healthServer = createServer((req, res) => {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const payload = JSON.stringify({
|
||||
ok: true,
|
||||
service: "bot",
|
||||
runningBots: manager.getRunningCount(),
|
||||
bots: manager.listRunningBots(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
res.end(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
healthServer.listen(env.PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] health endpoint available on :${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[bot] shutdown requested (${signal})`);
|
||||
|
||||
healthServer.close(async () => {
|
||||
await worker?.close().catch(() => undefined);
|
||||
await manager.shutdown().catch(() => undefined);
|
||||
await redis?.quit().catch(() => undefined);
|
||||
await pgPool.end().catch(() => undefined);
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.once("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
};
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[bot] fatal startup error", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -33,8 +33,8 @@ const parseOptionalUrl = (value: unknown): string | undefined => {
|
||||
};
|
||||
|
||||
const envSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
|
||||
DISCORD_TOKEN: z.string().optional().default(""),
|
||||
DISCORD_CLIENT_ID: z.string().optional().default(""),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -11,6 +11,7 @@ import { registerGuildMemberRemove } from "./guildMemberRemove.js";
|
||||
import { registerInteractionCreate } from "./interactionCreate.js";
|
||||
import { registerLogRuntimeEvents } from "./logsRuntime.js";
|
||||
import { registerMessageCreate } from "./messageCreate.js";
|
||||
import type { RuntimeBotAuth } from "./ready.js";
|
||||
import { registerClientReady } from "./ready.js";
|
||||
|
||||
export const registerEvents = (
|
||||
@@ -23,6 +24,7 @@ export const registerEvents = (
|
||||
registry: CommandRegistry,
|
||||
services: AppFeatureServices,
|
||||
leaderCoordinator: LeaderCoordinator,
|
||||
runtimeAuth?: RuntimeBotAuth,
|
||||
): void => {
|
||||
registerMessageCreate(client, handlers.onPrefixMessage);
|
||||
registerInteractionCreate(client, handlers.onSlashInteraction);
|
||||
@@ -34,5 +36,5 @@ export const registerEvents = (
|
||||
registerGuildCreate(client);
|
||||
registerGuildDelete(client, services.memberMessageService, services.logEventService);
|
||||
|
||||
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator);
|
||||
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator, runtimeAuth);
|
||||
};
|
||||
@@ -10,12 +10,18 @@ import type { PresenceService } from "../modules/presence/index.js";
|
||||
|
||||
const log = createScopedLogger("event:ready");
|
||||
|
||||
export interface RuntimeBotAuth {
|
||||
token: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const registerClientReady = (
|
||||
client: Client,
|
||||
registry: CommandRegistry,
|
||||
i18n: I18nService,
|
||||
presenceService: PresenceService,
|
||||
leaderCoordinator: LeaderCoordinator,
|
||||
runtimeAuth?: RuntimeBotAuth,
|
||||
): void => {
|
||||
client.once(Events.ClientReady, async () => {
|
||||
log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
|
||||
@@ -39,11 +45,16 @@ export const registerClientReady = (
|
||||
}
|
||||
|
||||
if (env.AUTO_DEPLOY_SLASH) {
|
||||
if (!runtimeAuth) {
|
||||
log.warn("slash sync skipped: runtime auth is missing for this bot instance");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
|
||||
const result = await deployApplicationCommands({
|
||||
token: env.DISCORD_TOKEN,
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
token: runtimeAuth.token,
|
||||
clientId: runtimeAuth.clientId,
|
||||
registry,
|
||||
i18n,
|
||||
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user