From e5effd62fa4a1015ad9f02f33b099e71cf537966 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Tue, 14 Apr 2026 22:32:27 +0200 Subject: [PATCH] refonte --- .env.example | 11 + AGENT.md | 35 ++- Dockerfile | 9 +- README.md | 37 ++- database/migrations/0001_initial_schema.sql | 23 ++ package-lock.json | 234 ++++++++++++++++++ package.json | 5 +- scripts/migrate.mjs | 180 ++++++++++++++ src/app/bootstrap.ts | 177 +++++++++++-- src/app/container.ts | 8 +- src/commands/goodbye.ts | 7 +- src/commands/help.ts | 167 +------------ src/commands/presence.ts | 10 +- src/commands/welcome.ts | 7 +- src/config/env.ts | 80 +++++- src/core/commands/defineCommand.ts | 19 +- src/core/execution/CommandExecutor.ts | 142 +++++++---- src/core/execution/cooldownStore.ts | 95 +++++++ src/core/execution/dispatch.ts | 103 ++++++++ src/core/execution/globalRateLimitStore.ts | 108 ++++++++ .../execution/redisCommandJobPublisher.ts | 14 ++ src/core/logging/logger.ts | 27 ++ src/core/runtime/leaderCoordinator.ts | 59 +++++ src/database/stores/memberMessageStore.ts | 45 ++-- src/database/stores/presenceStore.ts | 50 ++-- src/events/guildCreate.ts | 5 +- src/events/guildDelete.ts | 9 +- src/events/guildMemberAdd.ts | 14 +- src/events/guildMemberRemove.ts | 14 +- src/events/index.ts | 4 +- src/events/interactionCreate.ts | 5 +- src/events/messageCreate.ts | 5 +- src/events/ready.ts | 48 +++- src/features/memberMessages/commandPanel.ts | 5 +- src/features/presence/commandPanel.ts | 5 +- src/handlers/commandExecutionContext.ts | 57 ++++- src/handlers/discordPermissionResolver.ts | 82 ++++++ src/handlers/prefixHandler.ts | 5 +- src/handlers/slashHandler.ts | 5 +- src/i18n/en.json | 4 +- src/i18n/es.json | 4 +- src/i18n/fr.json | 4 +- src/index.ts | 5 +- src/modules/help/command.ts | 46 ++++ src/modules/help/service.ts | 127 ++++++++++ src/modules/memberMessages/index.ts | 3 + src/modules/presence/index.ts | 3 + src/types/command.ts | 45 ++++ src/types/handlers.ts | 7 +- tests/defineCommand.test.ts | 21 ++ tests/executionStores.test.ts | 37 +++ 51 files changed, 1855 insertions(+), 366 deletions(-) create mode 100644 database/migrations/0001_initial_schema.sql create mode 100644 scripts/migrate.mjs create mode 100644 src/core/execution/cooldownStore.ts create mode 100644 src/core/execution/dispatch.ts create mode 100644 src/core/execution/globalRateLimitStore.ts create mode 100644 src/core/execution/redisCommandJobPublisher.ts create mode 100644 src/core/logging/logger.ts create mode 100644 src/core/runtime/leaderCoordinator.ts create mode 100644 src/handlers/discordPermissionResolver.ts create mode 100644 src/modules/help/command.ts create mode 100644 src/modules/help/service.ts create mode 100644 src/modules/memberMessages/index.ts create mode 100644 src/modules/presence/index.ts create mode 100644 tests/executionStores.test.ts diff --git a/.env.example b/.env.example index 33c91ce..652bfe2 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ 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 @@ -11,3 +14,11 @@ 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:command-jobs +GLOBAL_RATE_LIMIT_MAX_REQUESTS=20 +GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10 +ENABLE_LEADER_ELECTION=true diff --git a/AGENT.md b/AGENT.md index 903a4be..d732355 100644 --- a/AGENT.md +++ b/AGENT.md @@ -24,15 +24,21 @@ Organisation des dossiers - 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/` - - `presence/`: orchestration panel, runtime, service, repository contract. - - `memberMessages/`: orchestration panel, dispatch, image rendering, repository contract. + - Implementation interne des modules (detail technique derriere `src/modules/*`). - `src/core/` - `commands/`: parser, registry, slash builder, usage. - - `execution/`: pipeline d'execution des commandes. + - `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). @@ -49,14 +55,21 @@ Principes d'architecture ------------------------ - Commands UI only: - Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`. -- Features own business logic: - - Toute logique metier testable va dans `src/features/*`. +- 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 features dependent d'interfaces, pas de singletons globaux. + - Les modules dependent d'interfaces, pas de singletons globaux. - Bootstrap owns lifecycle: - Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`. @@ -72,23 +85,25 @@ Procedure: ajouter une commande -------------------------------- 1. Creer un wrapper dans `src/commands/`. 2. Declarer la commande avec `defineCommand({ ... })`. -3. Deleguer `execute` vers un module `src/features/...`. +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/features//`. +1. Creer `src/modules//`. 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. Cablage d'injection dans `src/app/bootstrap.ts`. -6. Ajouter tests unitaires et, si necessaire, integration. +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: diff --git a/Dockerfile b/Dockerfile index cdd83b1..1f380d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,5 +20,12 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY --from=builder /app/build ./build +COPY scripts ./scripts +COPY database ./database -CMD ["node", "build/index.js"] +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"] diff --git a/README.md b/README.md index 15373c4..1851429 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ Professional command framework template for Discord.js `14.26.2` with: - 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 @@ -31,12 +34,25 @@ Professional command framework template for Discord.js `14.26.2` with: - `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) -5. Start in development: + - `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 -6. Validate code quality: +7. Validate code quality: - npm run typecheck - npm run test - npm run check @@ -63,6 +79,7 @@ The bot container uses: Security defaults: - PostgreSQL is bound to `127.0.0.1` by default in Compose. +- Database TLS verification is strict by default when SSL is enabled. - Keep strong values for `POSTGRES_PASSWORD` and rotate credentials if exposed. ## Multi-Bot With One DB @@ -81,10 +98,17 @@ An example with two bot services is available in `docker-compose.multi-bot.examp - `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown - `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute) -- `src/features/presence/*`: presence runtime, panel orchestration, repository contract -- `src/features/memberMessages/*`: member-message dispatch, panel orchestration, image rendering, repository contract +- `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`: unified execution pipeline (permissions/cooldown/error handling) +- `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 @@ -106,4 +130,5 @@ An example with two bot services is available in `docker-compose.multi-bot.examp 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. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically +4. Put business logic in `src/modules//...` and keep `src/commands/*` as wrappers only +5. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically diff --git a/database/migrations/0001_initial_schema.sql b/database/migrations/0001_initial_schema.sql new file mode 100644 index 0000000..14bc8a5 --- /dev/null +++ b/database/migrations/0001_initial_schema.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS bot_presence_states ( + bot_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('online', 'idle', 'dnd', 'invisible', 'streaming')), + activity_type TEXT NOT NULL CHECK (activity_type IN ('PLAYING', 'STREAMING', 'WATCHING', 'LISTENING', 'COMPETING', 'CUSTOM')), + activity_text TEXT NOT NULL, + activity_texts TEXT NOT NULL DEFAULT '[]', + rotation_interval_seconds INTEGER NOT NULL DEFAULT 30 CHECK (rotation_interval_seconds BETWEEN 5 AND 3600), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS bot_member_message_configs ( + bot_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('welcome', 'goodbye')), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + channel_id TEXT, + message_type TEXT NOT NULL DEFAULT 'simple' CHECK (message_type IN ('simple', 'embed', 'container', 'image')), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, kind) +); + +CREATE INDEX IF NOT EXISTS idx_bot_member_message_configs_bot_guild + ON bot_member_message_configs (bot_id, guild_id); diff --git a/package-lock.json b/package-lock.json index 76b6d8a..86d6c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "@napi-rs/canvas": "^0.1.97", "discord.js": "14.26.2", "dotenv": "16.4.5", + "ioredis": "^5.10.1", "pg": "^8.20.0", + "pino": "^10.3.1", "zod": "3.23.8" }, "devDependencies": { @@ -607,6 +609,12 @@ "node": ">=18" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.97", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", @@ -856,6 +864,12 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", @@ -929,6 +943,50 @@ "npm": ">=7.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/discord-api-types": { "version": "0.38.45", "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", @@ -1053,12 +1111,48 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -1071,6 +1165,21 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -1161,6 +1270,43 @@ "split2": "^4.1.0" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1200,6 +1346,58 @@ "node": ">=0.10.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1210,6 +1408,24 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1219,6 +1435,24 @@ "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", diff --git a/package.json b/package.json index 395a986..343aab4 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,19 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "npm run typecheck && node scripts/build.mjs", + "migrate": "node scripts/migrate.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "node --import tsx --test tests/**/*.test.ts", "check": "npm run typecheck && npm run test", - "start": "node build/index.js" + "start": "npm run migrate && node build/index.js" }, "dependencies": { "@napi-rs/canvas": "^0.1.97", "discord.js": "14.26.2", "dotenv": "16.4.5", + "ioredis": "^5.10.1", "pg": "^8.20.0", + "pino": "^10.3.1", "zod": "3.23.8" }, "devDependencies": { diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..b8b2434 --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,180 @@ +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +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 parseBoolean = (value, fallback = false) => { + if (value === undefined || value === null || String(value).trim().length === 0) { + return fallback; + } + + const normalized = String(value).trim().toLowerCase(); + 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) => { + const entries = await readdir(migrationsDir, { withFileTypes: true }); + + return entries + .filter((entry) => entry.isFile() && /^\d+.*\.sql$/i.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b)); +}; + +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"); + + migrations.push({ + fileName, + sql, + checksum: sha256(sql), + }); + } + + return migrations; +}; + +const ensureMigrationTable = async (client) => { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +}; + +const applyMigrations = async (client, migrations) => { + const appliedResult = 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; + + 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}.`, + ); + } + + skippedCount += 1; + continue; + } + + await client.query("BEGIN"); + + try { + await client.query(migration.sql); + await client.query( + "INSERT INTO schema_migrations (version, checksum) VALUES ($1, $2)", + [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) { + 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 migrationsDir = path.join(rootDir, "database", "migrations"); + + const migrations = await loadMigrations(migrationsDir); + + const pool = new Pool({ + connectionString: databaseUrl, + ssl: databaseSsl + ? { + rejectUnauthorized, + ...(databaseSslCa ? { ca: databaseSslCa } : {}), + } + : undefined, + }); + + const client = await pool.connect(); + + try { + await client.query("SELECT pg_advisory_lock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]); + await ensureMigrationTable(client); + await applyMigrations(client, migrations); + } finally { + await client.query("SELECT pg_advisory_unlock($1, $2)", [LOCK_CLASS_ID, LOCK_OBJECT_ID]).catch(() => undefined); + client.release(); + await pool.end(); + } +}; + +main().catch((error) => { + console.error("[migrate] fatal", error); + process.exit(1); +}); diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 50ada40..5a325ee 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -1,4 +1,5 @@ import { Client, GatewayIntentBits } from "discord.js"; +import { Redis } from "ioredis"; import { Pool } from "pg"; import type { AppFeatureServices } from "./container.js"; @@ -6,6 +7,24 @@ import { createCommandList } from "../commands/index.js"; import { env } from "../config/env.js"; import { CommandRegistry } from "../core/commands/registry.js"; import { CommandExecutor } from "../core/execution/CommandExecutor.js"; +import { + LocalCommandDispatchPort, + WorkerCommandDispatchPort, +} from "../core/execution/dispatch.js"; +import { + MemoryCooldownStore, + RedisCooldownStore, +} from "../core/execution/cooldownStore.js"; +import { + MemoryGlobalRateLimitStore, + RedisGlobalRateLimitStore, +} from "../core/execution/globalRateLimitStore.js"; +import { RedisCommandJobPublisher } from "../core/execution/redisCommandJobPublisher.js"; +import { createScopedLogger } from "../core/logging/logger.js"; +import { + LocalLeaderCoordinator, + PostgresLeaderCoordinator, +} from "../core/runtime/leaderCoordinator.js"; import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js"; import { PostgresPresenceStore } from "../database/stores/presenceStore.js"; import { DatabaseLifecycle } from "../database/dbLifecycle.js"; @@ -13,8 +32,15 @@ import { registerEvents } from "../events/index.js"; import { createPrefixHandler } from "../handlers/prefixHandler.js"; import { createSlashHandler } from "../handlers/slashHandler.js"; import { I18nService } from "../i18n/index.js"; -import { MemberMessageService } from "../features/memberMessages/service.js"; -import { PresenceService } from "../features/presence/service.js"; +import { + MemberMessageService, +} from "../modules/memberMessages/index.js"; +import { + PresenceService, +} from "../modules/presence/index.js"; + +const SHUTDOWN_TIMEOUT_MS = 10_000; +const log = createScopedLogger("bootstrap"); const bindGracefulShutdown = (shutdown: (signal: string) => Promise): void => { process.once("SIGINT", () => { @@ -26,12 +52,48 @@ const bindGracefulShutdown = (shutdown: (signal: string) => Promise): void }); }; +const bindFatalProcessHandlers = ( + shutdown: (signal: string, exitCode?: number, error?: unknown) => Promise, +): void => { + process.once("uncaughtException", (error) => { + log.error({ err: error }, "process uncaught exception"); + void shutdown("UNCAUGHT_EXCEPTION", 1, error); + }); + + process.once("unhandledRejection", (reason) => { + log.error({ reason }, "process unhandled rejection"); + void shutdown("UNHANDLED_REJECTION", 1, reason); + }); +}; + export const bootstrap = async (): Promise => { const pool = new Pool({ connectionString: env.DATABASE_URL, - ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined, + ssl: env.DATABASE_SSL + ? { + rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED, + ...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}), + } + : undefined, }); + const requiresRedis = env.STATE_BACKEND === "redis" || env.COMMAND_DISPATCH_MODE === "worker"; + let redis: Redis | null = null; + + if (requiresRedis) { + const redisUrl = env.REDIS_URL; + if (!redisUrl) { + throw new Error("REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker"); + } + + redis = new Redis(redisUrl, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); + + await redis.ping(); + } + const presenceStore = new PostgresPresenceStore(pool); const memberMessageStore = new PostgresMemberMessageStore(pool); @@ -50,44 +112,94 @@ export const bootstrap = async (): Promise => { memberMessageService: new MemberMessageService(memberMessageStore), }; + const cooldownStore = env.STATE_BACKEND === "redis" && redis + ? new RedisCooldownStore(redis) + : new MemoryCooldownStore(); + + const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis + ? new RedisGlobalRateLimitStore(redis) + : new MemoryGlobalRateLimitStore(); + + const executor = new CommandExecutor({ + cooldownStore, + globalRateLimitStore, + globalRateLimitPolicy: { + limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS, + windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS, + }, + logger: createScopedLogger("command-executor"), + }); + + const dispatcher = (() => { + if (env.COMMAND_DISPATCH_MODE === "worker") { + if (!redis) { + throw new Error("Worker dispatch mode requires an initialized Redis client"); + } + + return new WorkerCommandDispatchPort( + new RedisCommandJobPublisher(redis, env.COMMAND_QUEUE_NAME), + createScopedLogger("command-dispatcher"), + ); + } + + return new LocalCommandDispatchPort(executor); + })(); + + const leaderCoordinator = env.ENABLE_LEADER_ELECTION + ? new PostgresLeaderCoordinator(pool, "discordjs-framework-template") + : new LocalLeaderCoordinator(); + let shuttingDown = false; let client: Client | null = null; - const shutdown = async (signal: string, exitCode = 0): Promise => { + const shutdown = async (signal: string, exitCode = 0, error?: unknown): Promise => { if (shuttingDown) { return; } shuttingDown = true; - console.log(`[shutdown] ${signal}`); + log.info({ signal }, "shutdown signal received"); + + if (error !== undefined) { + log.error({ signal, err: error }, "shutdown reason"); + } + + const forcedExitCode = exitCode === 0 ? 1 : exitCode; + const forceExitTimer = setTimeout(() => { + log.error({ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS }, "forcing process exit"); + process.exit(forcedExitCode); + }, SHUTDOWN_TIMEOUT_MS); + + forceExitTimer.unref?.(); if (client) { client.destroy(); } await services.presenceService.shutdown().catch((error) => { - console.error("[shutdown] presence service close failed", error); + log.error({ err: error }, "presence service shutdown failed"); }); await dbLifecycle.shutdown().catch((error) => { - console.error("[shutdown] database shutdown failed", error); + log.error({ err: error }, "database shutdown failed"); }); + if (redis) { + await redis.quit().catch((error: unknown) => { + log.error({ err: error }, "redis shutdown failed"); + }); + } + + clearTimeout(forceExitTimer); process.exit(exitCode); }; + bindFatalProcessHandlers(shutdown); + try { await dbLifecycle.init(); bindGracefulShutdown((signal) => shutdown(signal)); - process.on("unhandledRejection", (reason) => { - console.error("[process] unhandled rejection", reason); - }); - - process.on("uncaughtException", (error) => { - console.error("[process] uncaught exception", error); - }); - client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -99,12 +211,11 @@ export const bootstrap = async (): Promise => { const i18n = new I18nService(env.DEFAULT_LANG); const registry = new CommandRegistry(createCommandList(services, i18n), i18n); - const executor = new CommandExecutor(); const onPrefixMessage = createPrefixHandler({ registry, i18n, - executor, + dispatcher, prefix: env.PREFIX, defaultLang: env.DEFAULT_LANG, }); @@ -112,23 +223,35 @@ export const bootstrap = async (): Promise => { const onSlashInteraction = createSlashHandler({ registry, i18n, - executor, + dispatcher, prefix: env.PREFIX, defaultLang: env.DEFAULT_LANG, }); - registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry, services); + registerEvents( + client, + i18n, + { onPrefixMessage, onSlashInteraction }, + registry, + services, + leaderCoordinator, + ); + + log.info( + { + stateBackend: env.STATE_BACKEND, + commandDispatchMode: env.COMMAND_DISPATCH_MODE, + rateLimit: { + maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS, + windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS, + }, + }, + "runtime initialized", + ); await client.login(env.DISCORD_TOKEN); } catch (error) { - await services.presenceService.shutdown().catch((closeError) => { - console.error("[boot] failed to close presence service", closeError); - }); - - await dbLifecycle.shutdown().catch((closeError) => { - console.error("[boot] failed to shutdown database", closeError); - }); - + await shutdown("BOOTSTRAP_FAILURE", 1, error); throw error; } }; diff --git a/src/app/container.ts b/src/app/container.ts index f22cd8f..1b39fd5 100644 --- a/src/app/container.ts +++ b/src/app/container.ts @@ -1,5 +1,9 @@ -import type { MemberMessageService } from "../features/memberMessages/service.js"; -import type { PresenceService } from "../features/presence/service.js"; +import type { + MemberMessageService, +} from "../modules/memberMessages/index.js"; +import type { + PresenceService, +} from "../modules/presence/index.js"; export interface AppFeatureServices { presenceService: PresenceService; diff --git a/src/commands/goodbye.ts b/src/commands/goodbye.ts index dcf9ba9..097dfbb 100644 --- a/src/commands/goodbye.ts +++ b/src/commands/goodbye.ts @@ -7,9 +7,11 @@ import { PermissionFlagsBits } from "discord.js"; import { defineCommand } from "../core/commands/defineCommand.js"; -import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js"; -import type { MemberMessageService } from "../features/memberMessages/service.js"; import type { I18nService } from "../i18n/index.js"; +import { + createMemberMessagePanelExecute, + type MemberMessageService, +} from "../modules/memberMessages/index.js"; /** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */ export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ @@ -18,6 +20,7 @@ export const createGoodbyeCommand = (memberMessageService: MemberMessageService, category: "utility", }, permissions: [PermissionFlagsBits.ManageGuild], + sensitive: true, examples: [ { source: "slash", diff --git a/src/commands/help.ts b/src/commands/help.ts index d3066fb..323a73b 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,166 +1 @@ -/** - * Commande `help` - * - * Construit un embed d'aide global listant les commandes par catégorie, ou - * affiche les détails pour une commande spécifique si un argument est fourni. - * - * Export: - * - `helpCommand`: BotCommand (défini via `defineCommand`) - */ -import { EmbedBuilder } from "discord.js"; - -import { buildPrefixUsage, buildSlashUsage, resolvePrefixTrigger, resolveSlashName } from "../core/commands/usage.js"; -import { defineCommand } from "../core/commands/defineCommand.js"; -import type { BotCommand, CommandExecutionContext } from "../types/command.js"; - -const categoryName = (command: BotCommand): string => command.meta.category; - -const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string => - ctx.i18n.commandT(ctx.lang, command.meta.name, "description"); - -const resolveCommandFromQuery = (ctx: CommandExecutionContext, query: string): BotCommand | undefined => { - const normalized = query.toLowerCase(); - const byName = ctx.registry.findByName(normalized); - if (byName) { - return byName; - } - - return ctx.registry.findByAnyPrefixTrigger(normalized)?.command; -}; - -const buildGlobalHelpEmbed = (ctx: CommandExecutionContext): EmbedBuilder => { - const commands = [...ctx.registry.getAll()]; - const grouped = new Map(); - - for (const command of commands) { - const key = categoryName(command); - if (!grouped.has(key)) { - grouped.set(key, []); - } - grouped.get(key)?.push(command); - } - - const helpUsage = buildPrefixUsage(helpCommand, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); - - const embed = new EmbedBuilder() - .setColor(0x2b6cb0) - .setTitle(ctx.ct("embed.title")) - .setDescription(ctx.ct("embed.description", { prefix: ctx.prefix, usage: `${helpUsage} ` })); - - for (const [category, categoryCommands] of grouped.entries()) { - const lines = categoryCommands - .map((command) => { - const slashLabel = `/${resolveSlashName(command, ctx.lang, ctx.i18n)}`; - const prefixLabel = `${ctx.prefix}${resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n)}`; - return `${slashLabel} | ${prefixLabel} - ${commandDescription(ctx, command)}`; - }) - .join("\n"); - - embed.addFields({ - name: ctx.t(`categories.${category}`), - value: lines.length > 0 ? lines : ctx.ct("embed.categoryEmpty"), - }); - } - - return embed; -}; - -const buildCommandDetailsEmbed = (ctx: CommandExecutionContext, command: BotCommand): EmbedBuilder => { - const usagePrefix = buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); - const usageSlash = buildSlashUsage(command, ctx.lang, ctx.i18n); - const prefixTrigger = resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n); - const slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n); - const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name); - - const args = command.args.length === 0 - ? ctx.ct("labels.noArgs") - : command.args - .map((arg) => { - const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey); - const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional"); - return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`; - }) - .join("\n"); - - const examples = command.examples.length === 0 - ? ctx.ct("labels.noExamples") - : command.examples - .map((example) => { - const source = example.source ?? "prefix"; - const baseUsage = source === "slash" - ? buildSlashUsage(command, ctx.lang, ctx.i18n) - : buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); - - const baseCommand = source === "slash" - ? `/${slashTrigger}` - : `${ctx.prefix}${prefixTrigger}`; - - const input = example.args ? `${baseCommand} ${example.args}` : baseUsage; - const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey); - return `- ${input}: ${description}`; - }) - .join("\n"); - - return new EmbedBuilder() - .setColor(0x2f855a) - .setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName })) - .setDescription(ctx.ct("embed.detailsDescription", { description: commandDescription(ctx, command) })) - .addFields( - { - name: ctx.ct("embed.fields.usage"), - value: `${ctx.ct("labels.prefix")}: ${usagePrefix}\n${ctx.ct("labels.slash")}: ${usageSlash}`, - }, - { - name: ctx.ct("embed.fields.arguments"), - value: args, - }, - { - name: ctx.ct("embed.fields.examples"), - value: examples, - }, - ) - .setFooter({ text: ctx.ct("embed.footer", { source: command.meta.category }) }); -}; - -/** - * Commande `help` — renvoie un embed d'aide global ou les détails d'une commande. - */ -export const helpCommand = defineCommand({ - meta: { - name: "help", - category: "core", - }, - args: [ - { - name: "command", - type: "string", - required: false, - descriptionKey: "args.command", - }, - ], - examples: [ - { - descriptionKey: "examples.basic", - }, - { - args: "", - descriptionKey: "examples.single", - }, - ], - execute: async (ctx) => { - const queryArg = ctx.args.command; - - if (typeof queryArg === "string" && queryArg.trim().length > 0) { - const command = resolveCommandFromQuery(ctx, queryArg.trim()); - if (!command) { - await ctx.reply(ctx.ct("errors.notFound", { query: queryArg })); - return; - } - - await ctx.reply({ embeds: [buildCommandDetailsEmbed(ctx, command)] }); - return; - } - - await ctx.reply({ embeds: [buildGlobalHelpEmbed(ctx)] }); - }, -}); +export { helpCommand } from "../modules/help/command.js"; diff --git a/src/commands/presence.ts b/src/commands/presence.ts index 6c2ad21..753397d 100644 --- a/src/commands/presence.ts +++ b/src/commands/presence.ts @@ -1,12 +1,18 @@ +import { PermissionFlagsBits } from "discord.js"; + import { defineCommand } from "../core/commands/defineCommand.js"; -import { createPresenceCommandExecute } from "../features/presence/commandPanel.js"; -import type { PresenceService } from "../features/presence/service.js"; +import { + createPresenceCommandExecute, + type PresenceService, +} from "../modules/presence/index.js"; export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({ meta: { name: "presence", category: "utility", }, + permissions: [PermissionFlagsBits.ManageGuild], + sensitive: true, examples: [ { source: "slash", diff --git a/src/commands/welcome.ts b/src/commands/welcome.ts index beed0de..a28451e 100644 --- a/src/commands/welcome.ts +++ b/src/commands/welcome.ts @@ -7,9 +7,11 @@ import { PermissionFlagsBits } from "discord.js"; import { defineCommand } from "../core/commands/defineCommand.js"; -import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js"; -import type { MemberMessageService } from "../features/memberMessages/service.js"; import type { I18nService } from "../i18n/index.js"; +import { + createMemberMessagePanelExecute, + type MemberMessageService, +} from "../modules/memberMessages/index.js"; /** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */ export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ @@ -18,6 +20,7 @@ export const createWelcomeCommand = (memberMessageService: MemberMessageService, category: "utility", }, permissions: [PermissionFlagsBits.ManageGuild], + sensitive: true, examples: [ { source: "slash", diff --git a/src/config/env.ts b/src/config/env.ts index ad5f6ee..e0bb23d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,6 +5,33 @@ import { SUPPORTED_LANGS } from "../types/command.js"; loadEnv(); +const toBoolean = (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({ DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"), @@ -17,7 +44,28 @@ const envSchema = z.object({ .string() .optional() .default("false") - .transform((value) => value.toLowerCase() === "true"), + .transform(toBoolean), + DATABASE_SSL_REJECT_UNAUTHORIZED: z + .string() + .optional() + .default("true") + .transform(toBoolean), + 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(toBoolean), PRESENCE_STREAM_URL: z .string() .url("PRESENCE_STREAM_URL must be a valid URL") @@ -30,7 +78,33 @@ const envSchema = z.object({ .string() .optional() .default("false") - .transform((value) => value.toLowerCase() === "true"), + .transform(toBoolean), + LOG_LEVEL: z.string().trim().min(1).default("info"), + STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"), + REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()), + COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"), + COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot: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), + ENABLE_LEADER_ELECTION: z + .string() + .optional() + .default("true") + .transform(toBoolean), }); -export const env = envSchema.parse(process.env); +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.COMMAND_DISPATCH_MODE === "worker") && !parsed.REDIS_URL) { + throw new Error( + "REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker.", + ); +} + +export const env = parsed; diff --git a/src/core/commands/defineCommand.ts b/src/core/commands/defineCommand.ts index 5fe8edc..546806d 100644 --- a/src/core/commands/defineCommand.ts +++ b/src/core/commands/defineCommand.ts @@ -32,14 +32,31 @@ const normalizeCooldown = (input: BotCommandInput): number | undefined => { return input.cooldown; }; +const resolveSensitive = (input: BotCommandInput, permissions: readonly unknown[]): boolean => { + if (input.sensitive === true && permissions.length === 0) { + throw new Error( + `Invalid security config for command "${input.meta.name}": sensitive commands must declare at least one required permission.`, + ); + } + + if (input.sensitive !== undefined) { + return input.sensitive; + } + + return permissions.length > 0; +}; + export const defineCommand = (input: BotCommandInput): BotCommand => { assertRequiredArgsBeforeOptional(input); const cooldown = normalizeCooldown(input); + const permissions = [...(input.permissions ?? [])]; + const sensitive = resolveSensitive(input, permissions); return { meta: input.meta, args: [...(input.args ?? [])], - permissions: input.permissions ?? [], + permissions, + sensitive, examples: input.examples ?? [], ...(cooldown !== undefined ? { cooldown } : {}), execute: input.execute, diff --git a/src/core/execution/CommandExecutor.ts b/src/core/execution/CommandExecutor.ts index 6c9fdb9..4529c54 100644 --- a/src/core/execution/CommandExecutor.ts +++ b/src/core/execution/CommandExecutor.ts @@ -1,27 +1,50 @@ import { PermissionsBitField, - type ChatInputCommandInteraction, - type Message, type PermissionResolvable, } from "discord.js"; import type { BotCommand, CommandExecutionContext } from "../../types/command.js"; +import type { AppLogger } from "../logging/logger.js"; +import type { CooldownStore } from "./cooldownStore.js"; +import type { + GlobalRateLimitPolicy, + GlobalRateLimitStore, +} from "./globalRateLimitStore.js"; -const COOLDOWN_SWEEP_INTERVAL_MS = 60_000; -const COOLDOWN_SWEEP_MIN_ENTRIES = 512; +export interface CommandExecutorDeps { + cooldownStore: CooldownStore; + globalRateLimitStore: GlobalRateLimitStore; + globalRateLimitPolicy: GlobalRateLimitPolicy; + logger: AppLogger; +} export class CommandExecutor { - private readonly cooldowns = new Map(); - private lastCooldownSweepAt = 0; + public constructor(private readonly deps: CommandExecutorDeps) {} public async run(command: BotCommand, ctx: CommandExecutionContext): Promise { - const missingUserPermissions = this.getMissingPermissions(command.permissions, this.memberPermissions(ctx)); + if (this.isSensitiveCommand(command) && !ctx.transport.guild) { + await ctx.reply( + ctx.t("errors.permissions.user", { + permissions: this.formatPermissionLabel("ManageGuild"), + }), + ); + return; + } + + const rateLimitRetryAfterSeconds = await this.consumeGlobalRateLimit(ctx); + if (rateLimitRetryAfterSeconds > 0) { + await ctx.reply(ctx.t("errors.rateLimit", { seconds: rateLimitRetryAfterSeconds })); + return; + } + + const availablePermissions = await this.resolveMemberPermissions(ctx); + const missingUserPermissions = this.getMissingPermissions(command.permissions, availablePermissions); if (missingUserPermissions.length > 0) { await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") })); return; } - const remainingCooldownSeconds = this.consumeCooldown(command, ctx.user.id); + const remainingCooldownSeconds = await this.consumeCooldown(command, ctx.execution.actor.userId); if (remainingCooldownSeconds > 0) { await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds })); return; @@ -30,18 +53,37 @@ export class CommandExecutor { try { await command.execute(ctx); } catch (error) { - console.error(`[command:${command.meta.name}] execution failed`, error); + this.deps.logger.error( + { + requestId: ctx.execution.requestId, + command: command.meta.name, + source: ctx.execution.source, + userId: ctx.execution.actor.userId, + err: error, + }, + "command execution failed", + ); await ctx.reply(ctx.t("errors.execution")); } } - private memberPermissions(ctx: CommandExecutionContext): Readonly | null { - if (ctx.source === "slash") { - return (ctx.raw as ChatInputCommandInteraction).memberPermissions ?? null; + private async resolveMemberPermissions( + ctx: CommandExecutionContext, + ): Promise | null> { + try { + return await ctx.transport.resolveMemberPermissions(); + } catch (error) { + this.deps.logger.warn( + { + requestId: ctx.execution.requestId, + source: ctx.execution.source, + userId: ctx.execution.actor.userId, + err: error, + }, + "permission resolution failed", + ); + return null; } - - const message = ctx.raw as Message; - return message.member?.permissions ?? null; } private getMissingPermissions( @@ -86,50 +128,54 @@ export class CommandExecutor { .trim(); } - private consumeCooldown(command: BotCommand, userId: string): number { + private async consumeCooldown(command: BotCommand, userId: string): Promise { if (command.cooldown === undefined || command.cooldown <= 0) { return 0; } - const key = this.cooldownKey(command.meta.name, userId); - const now = Date.now(); - this.sweepExpiredCooldowns(now); - - const expiresAt = this.cooldowns.get(key); - - if (expiresAt !== undefined && expiresAt > now) { - return Math.ceil((expiresAt - now) / 1000); + try { + const result = await this.deps.cooldownStore.consume(command.meta.name, userId, command.cooldown); + return result.allowed ? 0 : result.retryAfterSeconds; + } catch (error) { + this.deps.logger.warn( + { + command: command.meta.name, + userId, + err: error, + }, + "cooldown store unavailable, continuing without cooldown enforcement", + ); + return 0; } - - if (expiresAt !== undefined) { - this.cooldowns.delete(key); - } - - this.cooldowns.set(key, now + command.cooldown * 1000); - return 0; } - private sweepExpiredCooldowns(now: number): void { - if (this.cooldowns.size === 0) { - return; - } + private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise { + try { + const result = await this.deps.globalRateLimitStore.consume( + ctx.execution.actor.userId, + this.deps.globalRateLimitPolicy, + ); - const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES; - const shouldSweepByTime = now - this.lastCooldownSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS; - if (!shouldSweepBySize && !shouldSweepByTime) { - return; - } - - for (const [key, expiresAt] of this.cooldowns.entries()) { - if (expiresAt <= now) { - this.cooldowns.delete(key); + if (result.allowed) { + return 0; } - } - this.lastCooldownSweepAt = now; + return result.retryAfterSeconds; + } catch (error) { + this.deps.logger.warn( + { + requestId: ctx.execution.requestId, + userId: ctx.execution.actor.userId, + err: error, + }, + "global rate limit store unavailable, continuing without rate limiting", + ); + + return 0; + } } - private cooldownKey(commandName: string, userId: string): string { - return `${commandName}:${userId}`; + private isSensitiveCommand(command: BotCommand): boolean { + return command.sensitive || command.permissions.length > 0; } } diff --git a/src/core/execution/cooldownStore.ts b/src/core/execution/cooldownStore.ts new file mode 100644 index 0000000..444c005 --- /dev/null +++ b/src/core/execution/cooldownStore.ts @@ -0,0 +1,95 @@ +import type { Redis } from "ioredis"; + +const COOLDOWN_SWEEP_INTERVAL_MS = 60_000; +const COOLDOWN_SWEEP_MIN_ENTRIES = 512; + +export interface CooldownConsumeResult { + allowed: boolean; + retryAfterSeconds: number; +} + +export interface CooldownStore { + consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise; +} + +export class MemoryCooldownStore implements CooldownStore { + private readonly cooldowns = new Map(); + private lastSweepAt = 0; + + public async consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise { + if (cooldownSeconds <= 0) { + return { allowed: true, retryAfterSeconds: 0 }; + } + + const key = this.key(commandName, subjectId); + const now = Date.now(); + this.sweepExpired(now); + + const expiresAt = this.cooldowns.get(key); + if (expiresAt !== undefined && expiresAt > now) { + return { + allowed: false, + retryAfterSeconds: Math.ceil((expiresAt - now) / 1000), + }; + } + + this.cooldowns.set(key, now + cooldownSeconds * 1000); + return { allowed: true, retryAfterSeconds: 0 }; + } + + private key(commandName: string, subjectId: string): string { + return `${commandName}:${subjectId}`; + } + + private sweepExpired(now: number): void { + if (this.cooldowns.size === 0) { + return; + } + + const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES; + const shouldSweepByTime = now - this.lastSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS; + if (!shouldSweepBySize && !shouldSweepByTime) { + return; + } + + for (const [key, expiresAt] of this.cooldowns.entries()) { + if (expiresAt <= now) { + this.cooldowns.delete(key); + } + } + + this.lastSweepAt = now; + } +} + +export class RedisCooldownStore implements CooldownStore { + public constructor( + private readonly redis: Redis, + private readonly keyPrefix = "bot:cooldown", + ) {} + + public async consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise { + if (cooldownSeconds <= 0) { + return { allowed: true, retryAfterSeconds: 0 }; + } + + const key = this.key(commandName, subjectId); + const result = await this.redis.set(key, "1", "EX", cooldownSeconds, "NX"); + if (result === "OK") { + return { + allowed: true, + retryAfterSeconds: 0, + }; + } + + const ttl = await this.redis.ttl(key); + return { + allowed: false, + retryAfterSeconds: ttl > 0 ? ttl : cooldownSeconds, + }; + } + + private key(commandName: string, subjectId: string): string { + return `${this.keyPrefix}:${commandName}:${subjectId}`; + } +} diff --git a/src/core/execution/dispatch.ts b/src/core/execution/dispatch.ts new file mode 100644 index 0000000..0416369 --- /dev/null +++ b/src/core/execution/dispatch.ts @@ -0,0 +1,103 @@ +import type { + BotCommand, + CommandArgValue, + CommandExecutionContext, + CommandSource, + SupportedLang, +} from "../../types/command.js"; +import type { AppLogger } from "../logging/logger.js"; +import type { CommandExecutor } from "./CommandExecutor.js"; + +export interface CommandDispatchPort { + dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise; +} + +export type CommandDispatchMode = "local" | "worker"; + +export interface CommandExecutionJob { + requestId: string; + commandName: string; + source: CommandSource; + lang: SupportedLang; + actor: { + userId: string; + guildId: string | null; + channelId: string | null; + }; + args: Record; + queuedAt: number; +} + +export interface CommandJobPublisher { + publish(job: CommandExecutionJob): Promise; +} + +export class LocalCommandDispatchPort implements CommandDispatchPort { + public constructor(private readonly executor: CommandExecutor) {} + + public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise { + await this.executor.run(command, ctx); + } +} + +export class WorkerCommandDispatchPort implements CommandDispatchPort { + public constructor( + private readonly publisher: CommandJobPublisher, + private readonly logger: AppLogger, + ) {} + + public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise { + const job = toExecutionJob(command, ctx); + + await this.publisher.publish(job); + + this.logger.info( + { + requestId: ctx.execution.requestId, + command: command.meta.name, + source: ctx.execution.source, + }, + "command queued for worker execution", + ); + + await ctx.reply(ctx.t("errors.executionQueued")); + } +} + +const toExecutionJob = (command: BotCommand, ctx: CommandExecutionContext): CommandExecutionJob => { + const serializedArgs = Object.fromEntries( + Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]), + ); + + return { + requestId: ctx.execution.requestId, + commandName: command.meta.name, + source: ctx.execution.source, + lang: ctx.i18nContext.lang, + actor: { + userId: ctx.execution.actor.userId, + guildId: ctx.execution.actor.guildId, + channelId: ctx.execution.actor.channelId, + }, + args: serializedArgs, + queuedAt: Date.now(), + }; +}; + +const serializeArgValue = (value: CommandArgValue): unknown => { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value === "object" && "id" in value && typeof value.id === "string") { + return { + id: value.id, + }; + } + + return String(value); +}; diff --git a/src/core/execution/globalRateLimitStore.ts b/src/core/execution/globalRateLimitStore.ts new file mode 100644 index 0000000..b2ecbb8 --- /dev/null +++ b/src/core/execution/globalRateLimitStore.ts @@ -0,0 +1,108 @@ +import type { Redis } from "ioredis"; + +export interface GlobalRateLimitPolicy { + limit: number; + windowSeconds: number; +} + +export interface GlobalRateLimitDecision { + allowed: boolean; + retryAfterSeconds: number; + remaining: number; + limit: number; +} + +export interface GlobalRateLimitStore { + consume(userId: string, policy: GlobalRateLimitPolicy): Promise; +} + +interface WindowCounterEntry { + count: number; + resetAt: number; +} + +export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore { + private readonly counters = new Map(); + + public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise { + const sanitized = sanitizePolicy(policy); + const now = Date.now(); + const key = this.key(userId, sanitized.windowSeconds); + const existing = this.counters.get(key); + + if (!existing || existing.resetAt <= now) { + const resetAt = now + sanitized.windowSeconds * 1000; + this.counters.set(key, { + count: 1, + resetAt, + }); + + return { + allowed: true, + retryAfterSeconds: 0, + remaining: Math.max(0, sanitized.limit - 1), + limit: sanitized.limit, + }; + } + + existing.count += 1; + + const retryAfterSeconds = Math.max(1, Math.ceil((existing.resetAt - now) / 1000)); + const allowed = existing.count <= sanitized.limit; + + return { + allowed, + retryAfterSeconds: allowed ? 0 : retryAfterSeconds, + remaining: Math.max(0, sanitized.limit - existing.count), + limit: sanitized.limit, + }; + } + + private key(userId: string, windowSeconds: number): string { + return `${userId}:${windowSeconds}`; + } +} + +export class RedisGlobalRateLimitStore implements GlobalRateLimitStore { + public constructor( + private readonly redis: Redis, + private readonly keyPrefix = "bot:ratelimit:user", + ) {} + + public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise { + const sanitized = sanitizePolicy(policy); + const key = this.key(userId); + + const count = await this.redis.incr(key); + if (count === 1) { + await this.redis.expire(key, sanitized.windowSeconds); + } + + const ttl = await this.redis.ttl(key); + const retryAfterSeconds = ttl > 0 ? ttl : sanitized.windowSeconds; + const allowed = count <= sanitized.limit; + + return { + allowed, + retryAfterSeconds: allowed ? 0 : retryAfterSeconds, + remaining: Math.max(0, sanitized.limit - count), + limit: sanitized.limit, + }; + } + + private key(userId: string): string { + return `${this.keyPrefix}:${userId}`; + } +} + +const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy => { + const limit = Number.isFinite(policy.limit) && policy.limit > 0 ? Math.floor(policy.limit) : 1; + const windowSeconds = Number.isFinite(policy.windowSeconds) && policy.windowSeconds > 0 + ? Math.floor(policy.windowSeconds) + : 1; + + return { + limit, + windowSeconds, + }; +}; diff --git a/src/core/execution/redisCommandJobPublisher.ts b/src/core/execution/redisCommandJobPublisher.ts new file mode 100644 index 0000000..cd71dd3 --- /dev/null +++ b/src/core/execution/redisCommandJobPublisher.ts @@ -0,0 +1,14 @@ +import type { Redis } from "ioredis"; + +import type { CommandExecutionJob, CommandJobPublisher } from "./dispatch.js"; + +export class RedisCommandJobPublisher implements CommandJobPublisher { + public constructor( + private readonly redis: Redis, + private readonly queueName = "bot:command-jobs", + ) {} + + public async publish(job: CommandExecutionJob): Promise { + await this.redis.lpush(this.queueName, JSON.stringify(job)); + } +} diff --git a/src/core/logging/logger.ts b/src/core/logging/logger.ts new file mode 100644 index 0000000..24df378 --- /dev/null +++ b/src/core/logging/logger.ts @@ -0,0 +1,27 @@ +import { config as loadEnv } from "dotenv"; +import pino, { type Logger, type LoggerOptions } from "pino"; + +const LOG_LEVEL_DEFAULT = "info"; + +loadEnv(); + +const resolveLogLevel = (): string => { + const value = process.env.LOG_LEVEL?.trim(); + return value && value.length > 0 ? value : LOG_LEVEL_DEFAULT; +}; + +const options: LoggerOptions = { + level: resolveLogLevel(), + base: { + service: "discordjs-framework-template", + }, + timestamp: pino.stdTimeFunctions.isoTime, +}; + +export const logger = pino(options); + +export const createScopedLogger = (scope: string): Logger => { + return logger.child({ scope }); +}; + +export type AppLogger = Logger; diff --git a/src/core/runtime/leaderCoordinator.ts b/src/core/runtime/leaderCoordinator.ts new file mode 100644 index 0000000..07a40af --- /dev/null +++ b/src/core/runtime/leaderCoordinator.ts @@ -0,0 +1,59 @@ +import type { Pool } from "pg"; + +export interface LeaderCoordinator { + runIfLeader(lockName: string, task: () => Promise): Promise; +} + +export class LocalLeaderCoordinator implements LeaderCoordinator { + public async runIfLeader(_lockName: string, task: () => Promise): Promise { + await task(); + return true; + } +} + +export class PostgresLeaderCoordinator implements LeaderCoordinator { + public constructor( + private readonly pool: Pool, + private readonly namespace = "discord-bot", + ) {} + + public async runIfLeader(lockName: string, task: () => Promise): Promise { + const advisoryLockKey = hashToInt32(`${this.namespace}:${lockName}`); + const client = await this.pool.connect(); + + try { + const acquiredResult = await client.query<{ locked: boolean }>( + "SELECT pg_try_advisory_lock($1) AS locked", + [advisoryLockKey], + ); + + const acquired = acquiredResult.rows[0]?.locked ?? false; + if (!acquired) { + return false; + } + + try { + await task(); + return true; + } finally { + await client.query("SELECT pg_advisory_unlock($1)", [advisoryLockKey]); + } + } finally { + client.release(); + } + } +} + +const hashToInt32 = (value: string): number => { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + + if (hash === 0) { + return 1; + } + + return hash; +}; diff --git a/src/database/stores/memberMessageStore.ts b/src/database/stores/memberMessageStore.ts index 1f1c99d..5d07998 100644 --- a/src/database/stores/memberMessageStore.ts +++ b/src/database/stores/memberMessageStore.ts @@ -9,30 +9,19 @@ import { createDefaultMemberMessageConfig, isMemberMessageRenderTypeValue, } from "../../validators/memberMessages.js"; -import type { MemberMessageRepository } from "../../features/memberMessages/repository.js"; +import type { MemberMessageRepository } from "../../modules/memberMessages/index.js"; -const tableSql = ` -CREATE TABLE IF NOT EXISTS bot_member_message_configs ( - bot_id TEXT NOT NULL, - guild_id TEXT NOT NULL, - kind TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT FALSE, - channel_id TEXT, - message_type TEXT NOT NULL DEFAULT 'simple', - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (bot_id, guild_id, kind) -); -`; - -const migrationSql = ` -ALTER TABLE bot_member_message_configs - ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT FALSE; - -ALTER TABLE bot_member_message_configs - ADD COLUMN IF NOT EXISTS channel_id TEXT; - -ALTER TABLE bot_member_message_configs - ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'simple'; +const memberMessageSchemaProbeSql = ` +SELECT + bot_id, + guild_id, + kind, + enabled, + channel_id, + message_type, + updated_at +FROM bot_member_message_configs +LIMIT 0; `; const toConfig = (row: MemberMessageRow): MemberMessageConfig => { @@ -49,8 +38,14 @@ export class PostgresMemberMessageStore implements MemberMessageRepository { public constructor(private readonly pool: Pool) {} public async init(): Promise { - await this.pool.query(tableSql); - await this.pool.query(migrationSql); + try { + await this.pool.query(memberMessageSchemaProbeSql); + } catch (error) { + throw new Error( + "[db:init] missing or incompatible table \"bot_member_message_configs\". Run migrations with \"npm run migrate\".", + { cause: error }, + ); + } } public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise { diff --git a/src/database/stores/presenceStore.ts b/src/database/stores/presenceStore.ts index 73d26a3..abbe08b 100644 --- a/src/database/stores/presenceStore.ts +++ b/src/database/stores/presenceStore.ts @@ -15,34 +15,19 @@ import { sanitizeActivityTexts, sanitizePresenceRotationIntervalSeconds, } from "../../validators/presence.js"; -import type { PresenceRepository } from "../../features/presence/repository.js"; +import type { PresenceRepository } from "../../modules/presence/index.js"; -const tableSql = ` -CREATE TABLE IF NOT EXISTS bot_presence_states ( - bot_id TEXT PRIMARY KEY, - status TEXT NOT NULL, - activity_type TEXT NOT NULL, - activity_text TEXT NOT NULL, - activity_texts TEXT, - rotation_interval_seconds INTEGER, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -`; - -const migrationSql = ` -ALTER TABLE bot_presence_states - ADD COLUMN IF NOT EXISTS activity_texts TEXT; - -ALTER TABLE bot_presence_states - ADD COLUMN IF NOT EXISTS rotation_interval_seconds INTEGER; -`; - -const backfillSql = ` -UPDATE bot_presence_states -SET - activity_texts = COALESCE(activity_texts, '[]'), - rotation_interval_seconds = COALESCE(rotation_interval_seconds, ${DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS}) -WHERE activity_texts IS NULL OR rotation_interval_seconds IS NULL; +const presenceSchemaProbeSql = ` +SELECT + bot_id, + status, + activity_type, + activity_text, + activity_texts, + rotation_interval_seconds, + updated_at +FROM bot_presence_states +LIMIT 0; `; const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => { @@ -92,9 +77,14 @@ export class PostgresPresenceStore implements PresenceRepository { public constructor(private readonly pool: Pool) {} public async init(): Promise { - await this.pool.query(tableSql); - await this.pool.query(migrationSql); - await this.pool.query(backfillSql); + try { + await this.pool.query(presenceSchemaProbeSql); + } catch (error) { + throw new Error( + "[db:init] missing or incompatible table \"bot_presence_states\". Run migrations with \"npm run migrate\".", + { cause: error }, + ); + } } public async getByBotId(botId: string): Promise { diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 57ca936..acbf666 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,4 +1,7 @@ import { Events, type Client } from "discord.js"; +import { createScopedLogger } from "../core/logging/logger.js"; + +const log = createScopedLogger("event:guildCreate"); /** * Enregistre le listener `guildCreate` (bot ajouté à un serveur). @@ -7,6 +10,6 @@ import { Events, type Client } from "discord.js"; */ export const registerGuildCreate = (client: Client): void => { client.on(Events.GuildCreate, (guild) => { - console.log(`[event:guildCreate] joined guild ${guild.id} (${guild.name})`); + log.info({ guildId: guild.id, guildName: guild.name }, "joined guild"); }); }; diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 481d78a..4c1293c 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,10 +1,13 @@ import { Events, type Client } from "discord.js"; -import type { MemberMessageService } from "../features/memberMessages/service.js"; +import { createScopedLogger } from "../core/logging/logger.js"; +import type { MemberMessageService } from "../modules/memberMessages/index.js"; + +const log = createScopedLogger("event:guildDelete"); export const registerGuildDelete = (client: Client, memberMessageService: MemberMessageService): void => { client.on(Events.GuildDelete, (guild) => { - console.log(`[event:guildDelete] left guild ${guild.id} (${guild.name})`); + log.info({ guildId: guild.id, guildName: guild.name }, "left guild"); const botId = memberMessageService.resolveBotId(client); if (!botId) { @@ -12,7 +15,7 @@ export const registerGuildDelete = (client: Client, memberMessageService: Member } void memberMessageService.cleanupGuild(botId, guild.id).catch((error) => { - console.error("[event:guildDelete] failed to cleanup guild config", error); + log.error({ guildId: guild.id, botId, err: error }, "failed to cleanup guild config"); }); }); }; diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 4c2dbfc..cce0761 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,7 +1,10 @@ import { Events, type Client } from "discord.js"; -import type { MemberMessageService } from "../features/memberMessages/service.js"; +import { createScopedLogger } from "../core/logging/logger.js"; import type { I18nService } from "../i18n/index.js"; +import type { MemberMessageService } from "../modules/memberMessages/index.js"; + +const log = createScopedLogger("event:guildMemberAdd"); export const registerGuildMemberAdd = ( client: Client, @@ -16,7 +19,14 @@ export const registerGuildMemberAdd = ( user: member.user, kind: "welcome", }).catch((error) => { - console.error("[event:guildMemberAdd] failed to send welcome message", error); + log.error( + { + guildId: member.guild.id, + userId: member.user.id, + err: error, + }, + "failed to send welcome message", + ); }); }); }; diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index dfb3ae3..41c76fb 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,7 +1,10 @@ import { Events, type Client } from "discord.js"; -import type { MemberMessageService } from "../features/memberMessages/service.js"; +import { createScopedLogger } from "../core/logging/logger.js"; import type { I18nService } from "../i18n/index.js"; +import type { MemberMessageService } from "../modules/memberMessages/index.js"; + +const log = createScopedLogger("event:guildMemberRemove"); export const registerGuildMemberRemove = ( client: Client, @@ -16,7 +19,14 @@ export const registerGuildMemberRemove = ( user: member.user, kind: "goodbye", }).catch((error) => { - console.error("[event:guildMemberRemove] failed to send goodbye message", error); + log.error( + { + guildId: member.guild.id, + userId: member.user.id, + err: error, + }, + "failed to send goodbye message", + ); }); }); }; diff --git a/src/events/index.ts b/src/events/index.ts index 92b530c..6126d19 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -2,6 +2,7 @@ import type { ChatInputCommandInteraction, Client, Message } from "discord.js"; import type { AppFeatureServices } from "../app/container.js"; import type { CommandRegistry } from "../core/commands/registry.js"; +import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js"; import type { I18nService } from "../i18n/index.js"; import { registerGuildCreate } from "./guildCreate.js"; import { registerGuildDelete } from "./guildDelete.js"; @@ -20,6 +21,7 @@ export const registerEvents = ( }, registry: CommandRegistry, services: AppFeatureServices, + leaderCoordinator: LeaderCoordinator, ): void => { registerMessageCreate(client, handlers.onPrefixMessage); registerInteractionCreate(client, handlers.onSlashInteraction); @@ -30,5 +32,5 @@ export const registerEvents = ( registerGuildCreate(client); registerGuildDelete(client, services.memberMessageService); - registerClientReady(client, registry, i18n, services.presenceService); + registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator); }; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 1c395a9..168022e 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,4 +1,7 @@ import { Events, type Client, type ChatInputCommandInteraction } from "discord.js"; +import { createScopedLogger } from "../core/logging/logger.js"; + +const log = createScopedLogger("event:interactionCreate"); /** * Enregistre le listener `interactionCreate` pour les commandes slash (chat input). @@ -16,7 +19,7 @@ export const registerInteractionCreate = ( } void onSlashInteraction(interaction).catch((error) => { - console.error("[event:interactionCreate] handler failed", error); + log.error({ err: error }, "handler failed"); }); }); }; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index e6feac9..ebccf6c 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,4 +1,7 @@ import { Events, type Client, type Message } from "discord.js"; +import { createScopedLogger } from "../core/logging/logger.js"; + +const log = createScopedLogger("event:messageCreate"); /** * Enregistre le listener `messageCreate` en déléguant au handler fourni. @@ -9,7 +12,7 @@ import { Events, type Client, type Message } from "discord.js"; export const registerMessageCreate = (client: Client, onPrefixMessage: (message: Message) => Promise): void => { client.on(Events.MessageCreate, (message: Message) => { void onPrefixMessage(message).catch((error) => { - console.error("[event:messageCreate] handler failed", error); + log.error({ err: error }, "handler failed"); }); }); }; diff --git a/src/events/ready.ts b/src/events/ready.ts index 537e994..a734b31 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -2,36 +2,60 @@ import { Events, type Client } from "discord.js"; import { env } from "../config/env.js"; import { deployApplicationCommands } from "../core/commands/deploy.js"; +import { createScopedLogger } from "../core/logging/logger.js"; +import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js"; import type { CommandRegistry } from "../core/commands/registry.js"; -import type { PresenceService } from "../features/presence/service.js"; import type { I18nService } from "../i18n/index.js"; +import type { PresenceService } from "../modules/presence/index.js"; + +const log = createScopedLogger("event:ready"); export const registerClientReady = ( client: Client, registry: CommandRegistry, i18n: I18nService, presenceService: PresenceService, + leaderCoordinator: LeaderCoordinator, ): void => { client.once(Events.ClientReady, async () => { - console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); + log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready"); try { - await presenceService.restoreFromStorage(client); + const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", async () => { + await presenceService.restoreFromStorage(client); + }); + + if (!restoredByLeader) { + log.info("presence restore skipped: leader lock already held by another instance"); + } } catch (error) { - console.error("[ready] failed to restore bot presence", error); + log.error({ err: error }, "failed to restore bot presence"); } if (env.AUTO_DEPLOY_SLASH) { try { - const result = await deployApplicationCommands({ - token: env.DISCORD_TOKEN, - clientId: env.DISCORD_CLIENT_ID, - registry, - i18n, - ...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}), + const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", async () => { + const result = await deployApplicationCommands({ + token: env.DISCORD_TOKEN, + clientId: env.DISCORD_CLIENT_ID, + registry, + i18n, + ...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}), + }); + + log.info( + { + scope: result.scope, + count: result.count, + }, + "slash sync completed", + ); }); - console.log(`[ready] slash sync done (${result.scope}, ${result.count} commands)`); + + if (!deployedByLeader) { + log.info("slash sync skipped: leader lock already held by another instance"); + } } catch (error) { - console.error("[ready] slash sync failed", error); + log.error({ err: error }, "slash sync failed"); } } }); diff --git a/src/features/memberMessages/commandPanel.ts b/src/features/memberMessages/commandPanel.ts index e221b7b..2b7ac5d 100644 --- a/src/features/memberMessages/commandPanel.ts +++ b/src/features/memberMessages/commandPanel.ts @@ -12,6 +12,7 @@ import { import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js"; import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js"; +import { createScopedLogger } from "../../core/logging/logger.js"; import type { I18nService } from "../../i18n/index.js"; import type { CommandExecutionContext } from "../../types/command.js"; import type { @@ -28,6 +29,8 @@ import { } from "../../validators/memberMessages.js"; import type { MemberMessageService } from "./service.js"; +const log = createScopedLogger("command:memberMessagesPanel"); + const panelSessions = new ComponentSessionRegistry(); const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => { @@ -334,7 +337,7 @@ export const createMemberMessagePanelExecute = ( flags: [MessageFlags.Ephemeral], }); } catch (error) { - console.error(`[command:${kind}] interaction failed`, error); + log.error({ kind, err: error }, "interaction failed"); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ diff --git a/src/features/presence/commandPanel.ts b/src/features/presence/commandPanel.ts index 6d84540..98b41fa 100644 --- a/src/features/presence/commandPanel.ts +++ b/src/features/presence/commandPanel.ts @@ -26,6 +26,9 @@ import { import { getPresenceTemplateHelpText } from "./templateVariables.js"; import type { PresenceService } from "./service.js"; import type { PresenceCustomIds } from "./types.js"; +import { createScopedLogger } from "../../core/logging/logger.js"; + +const log = createScopedLogger("command:presence"); const createCustomIds = (): PresenceCustomIds => { const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; @@ -354,7 +357,7 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) = await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); } catch (error) { - console.error("[command:presence] interaction failed", error); + log.error({ err: error }, "interaction failed"); const fallback = ctx.t("errors.execution"); if (!interaction.replied && !interaction.deferred) { diff --git a/src/handlers/commandExecutionContext.ts b/src/handlers/commandExecutionContext.ts index 3cc7cf3..e57719f 100644 --- a/src/handlers/commandExecutionContext.ts +++ b/src/handlers/commandExecutionContext.ts @@ -1,10 +1,14 @@ import type { CommandExecutionContext, + ExecutionContext, + I18nContext, SupportedLang, + TransportContext, TranslationVars, } from "../types/command.js"; import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js"; import type { I18nService } from "../i18n/index.js"; +import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js"; export const createTranslator = ( i18n: I18nService, @@ -21,24 +25,65 @@ export const buildCommandExecutionContext = ( const ct = (relativeKey: string, vars?: TranslationVars): string => deps.i18n.commandT(input.lang, input.command.meta.name, relativeKey, vars); - return { + const execution: ExecutionContext = { + requestId: input.requestId, + receivedAt: Date.now(), + source: input.source, + commandName: input.command.meta.name, + commandCategory: input.command.meta.category, + args: input.args, + actor: { + userId: input.user.id, + guildId: input.guild?.id ?? null, + channelId: input.channel?.id ?? null, + }, + }; + + const transport: TransportContext = { + kind: "discord", client: input.client, user: input.user, guild: input.guild, channel: input.channel, + raw: input.raw, + reply: input.reply, + resolveMemberPermissions: createDiscordMemberPermissionsResolver(input), + }; + + const i18nContext: I18nContext = { lang: input.lang, - args: input.args, - command: input.command, - source: input.source, t, ct, commandText: deps.i18n.commandObject(input.lang, input.command.meta.name), format: (template, vars) => deps.i18n.format(template, vars), i18n: deps.i18n, - raw: input.raw, - reply: input.reply, registry: deps.registry, prefix: deps.prefix, defaultLang: deps.defaultLang, }; + + return { + execution, + transport, + i18nContext, + + client: transport.client, + user: transport.user, + guild: transport.guild, + channel: transport.channel, + lang: i18nContext.lang, + args: execution.args, + command: input.command, + source: execution.source, + t: i18nContext.t, + ct: i18nContext.ct, + commandText: i18nContext.commandText, + format: i18nContext.format, + i18n: i18nContext.i18n, + raw: transport.raw, + reply: transport.reply, + registry: i18nContext.registry, + prefix: i18nContext.prefix, + defaultLang: i18nContext.defaultLang, + }; }; \ No newline at end of file diff --git a/src/handlers/discordPermissionResolver.ts b/src/handlers/discordPermissionResolver.ts new file mode 100644 index 0000000..927442c --- /dev/null +++ b/src/handlers/discordPermissionResolver.ts @@ -0,0 +1,82 @@ +import { + PermissionsBitField, + type ChatInputCommandInteraction, + type Guild, + type Message, +} from "discord.js"; + +import type { BuildExecutionContextInput } from "../types/handlers.js"; + +const resolveFromGuild = async (guild: Guild | null, userId: string): Promise | null> => { + if (!guild) { + return null; + } + + const cached = guild.members.cache.get(userId); + if (cached?.permissions) { + return cached.permissions; + } + + const fetched = await guild.members.fetch(userId).catch(() => null); + return fetched?.permissions ?? null; +}; + +const resolveFromInteractionMember = ( + interaction: ChatInputCommandInteraction, +): Readonly | null => { + if (interaction.memberPermissions) { + return interaction.memberPermissions; + } + + const member = interaction.member; + if (member && typeof member === "object" && "permissions" in member) { + const rawPermissions = (member as { permissions?: string | number | bigint }).permissions; + + if (rawPermissions !== undefined) { + try { + const normalized = typeof rawPermissions === "string" || typeof rawPermissions === "number" + ? BigInt(rawPermissions) + : rawPermissions; + + return new PermissionsBitField(normalized); + } catch { + return null; + } + } + } + + return null; +}; + +export const createDiscordMemberPermissionsResolver = ( + input: BuildExecutionContextInput, +): (() => Promise | null>) => { + if (input.source === "slash") { + const interaction = input.raw as ChatInputCommandInteraction; + + return async (): Promise | null> => { + const direct = resolveFromInteractionMember(interaction); + if (direct) { + return direct; + } + + return resolveFromGuild(interaction.guild, interaction.user.id); + }; + } + + const message = input.raw as Message; + + return async (): Promise | null> => { + const directFromMessage = message.member?.permissions; + if (directFromMessage) { + return directFromMessage; + } + + const fallbackFromGuildMember = message.guild?.members.resolve(message.author.id)?.permissions; + if (fallbackFromGuildMember) { + return fallbackFromGuildMember; + } + + return resolveFromGuild(message.guild, message.author.id); + }; +}; diff --git a/src/handlers/prefixHandler.ts b/src/handlers/prefixHandler.ts index eaadff3..3e895f9 100644 --- a/src/handlers/prefixHandler.ts +++ b/src/handlers/prefixHandler.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import type { Message } from "discord.js"; import type { BotCommand, SupportedLang } from "../types/command.js"; import type { PrefixHandlerDeps } from "../types/handlers.js"; @@ -66,9 +68,10 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => { return; } - await deps.executor.run( + await deps.dispatcher.dispatch( command, buildCommandExecutionContext(deps, { + requestId: randomUUID(), command, source: "prefix", lang, diff --git a/src/handlers/slashHandler.ts b/src/handlers/slashHandler.ts index 52b96cf..89c3852 100644 --- a/src/handlers/slashHandler.ts +++ b/src/handlers/slashHandler.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import type { SlashHandlerDeps } from "../types/handlers.js"; @@ -35,9 +37,10 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => { return; } - await deps.executor.run( + await deps.dispatcher.dispatch( command, buildCommandExecutionContext(deps, { + requestId: randomUUID(), command, source: "slash", lang, diff --git a/src/i18n/en.json b/src/i18n/en.json index 4a98804..2a2d7da 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -14,7 +14,9 @@ "user": "You are missing permissions: {{permissions}}" }, "execution": "An unexpected error happened while running this command.", - "cooldown": "Please wait {{seconds}}s before using this command again." + "cooldown": "Please wait {{seconds}}s before using this command again.", + "rateLimit": "Too many requests. Please wait {{seconds}}s before retrying.", + "executionQueued": "Command accepted and queued for processing." }, "categories": { "fun": "Fun", diff --git a/src/i18n/es.json b/src/i18n/es.json index 2b7ff25..645fee2 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -14,7 +14,9 @@ "user": "Te faltan permisos: {{permissions}}" }, "execution": "Ocurrio un error inesperado al ejecutar este comando.", - "cooldown": "Espera {{seconds}}s antes de volver a usar este comando." + "cooldown": "Espera {{seconds}}s antes de volver a usar este comando.", + "rateLimit": "Demasiadas solicitudes. Espera {{seconds}}s antes de reintentar.", + "executionQueued": "Comando aceptado y encolado para procesamiento." }, "categories": { "fun": "Diversion", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index bb4ea56..dfa1586 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -14,7 +14,9 @@ "user": "Tu n as pas les permissions: {{permissions}}" }, "execution": "Une erreur inattendue est survenue pendant l execution.", - "cooldown": "Patiente {{seconds}}s avant de reutiliser cette commande." + "cooldown": "Patiente {{seconds}}s avant de reutiliser cette commande.", + "rateLimit": "Trop de requetes. Patiente {{seconds}}s avant de reessayer.", + "executionQueued": "Commande acceptee et mise en file de traitement." }, "categories": { "fun": "Fun", diff --git a/src/index.ts b/src/index.ts index 1ca935c..c2fe342 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,9 @@ import { bootstrap } from "./app/bootstrap.js"; +import { createScopedLogger } from "./core/logging/logger.js"; + +const log = createScopedLogger("boot"); bootstrap().catch((error) => { - console.error("[boot] fatal error", error); + log.fatal({ err: error }, "fatal startup error"); process.exit(1); }); diff --git a/src/modules/help/command.ts b/src/modules/help/command.ts new file mode 100644 index 0000000..a3b3035 --- /dev/null +++ b/src/modules/help/command.ts @@ -0,0 +1,46 @@ +import { defineCommand } from "../../core/commands/defineCommand.js"; +import { + createCommandDetailsEmbed, + createGlobalHelpEmbed, + resolveCommandFromQuery, +} from "./service.js"; + +export const helpCommand = defineCommand({ + meta: { + name: "help", + category: "core", + }, + args: [ + { + name: "command", + type: "string", + required: false, + descriptionKey: "args.command", + }, + ], + examples: [ + { + descriptionKey: "examples.basic", + }, + { + args: "", + descriptionKey: "examples.single", + }, + ], + execute: async (ctx) => { + const queryArg = ctx.args.command; + + if (typeof queryArg === "string" && queryArg.trim().length > 0) { + const command = resolveCommandFromQuery(ctx, queryArg.trim()); + if (!command) { + await ctx.reply(ctx.ct("errors.notFound", { query: queryArg })); + return; + } + + await ctx.reply({ embeds: [createCommandDetailsEmbed(ctx, command)] }); + return; + } + + await ctx.reply({ embeds: [createGlobalHelpEmbed(ctx, helpCommand)] }); + }, +}); diff --git a/src/modules/help/service.ts b/src/modules/help/service.ts new file mode 100644 index 0000000..cfbf4bb --- /dev/null +++ b/src/modules/help/service.ts @@ -0,0 +1,127 @@ +import { EmbedBuilder } from "discord.js"; + +import { + buildPrefixUsage, + buildSlashUsage, + resolvePrefixTrigger, + resolveSlashName, +} from "../../core/commands/usage.js"; +import type { BotCommand, CommandExecutionContext } from "../../types/command.js"; + +const categoryName = (command: BotCommand): string => command.meta.category; + +const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string => + ctx.i18n.commandT(ctx.lang, command.meta.name, "description"); + +export const resolveCommandFromQuery = ( + ctx: CommandExecutionContext, + query: string, +): BotCommand | undefined => { + const normalized = query.toLowerCase(); + const byName = ctx.registry.findByName(normalized); + if (byName) { + return byName; + } + + return ctx.registry.findByAnyPrefixTrigger(normalized)?.command; +}; + +export const createGlobalHelpEmbed = ( + ctx: CommandExecutionContext, + helpCommand: BotCommand, +): EmbedBuilder => { + const commands = [...ctx.registry.getAll()]; + const grouped = new Map(); + + for (const command of commands) { + const key = categoryName(command); + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)?.push(command); + } + + const helpUsage = buildPrefixUsage(helpCommand, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + + const embed = new EmbedBuilder() + .setColor(0x2b6cb0) + .setTitle(ctx.ct("embed.title")) + .setDescription(ctx.ct("embed.description", { prefix: ctx.prefix, usage: `${helpUsage} ` })); + + for (const [category, categoryCommands] of grouped.entries()) { + const lines = categoryCommands + .map((command) => { + const slashLabel = `/${resolveSlashName(command, ctx.lang, ctx.i18n)}`; + const prefixLabel = `${ctx.prefix}${resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n)}`; + return `${slashLabel} | ${prefixLabel} - ${commandDescription(ctx, command)}`; + }) + .join("\n"); + + embed.addFields({ + name: ctx.t(`categories.${category}`), + value: lines.length > 0 ? lines : ctx.ct("embed.categoryEmpty"), + }); + } + + return embed; +}; + +export const createCommandDetailsEmbed = ( + ctx: CommandExecutionContext, + command: BotCommand, +): EmbedBuilder => { + const usagePrefix = buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + const usageSlash = buildSlashUsage(command, ctx.lang, ctx.i18n); + const prefixTrigger = resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n); + const slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n); + const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name); + + const args = command.args.length === 0 + ? ctx.ct("labels.noArgs") + : command.args + .map((arg) => { + const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey); + const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional"); + return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`; + }) + .join("\n"); + + const examples = command.examples.length === 0 + ? ctx.ct("labels.noExamples") + : command.examples + .map((example) => { + const source = example.source ?? "prefix"; + const baseUsage = source === "slash" + ? buildSlashUsage(command, ctx.lang, ctx.i18n) + : buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + + const baseCommand = source === "slash" + ? `/${slashTrigger}` + : `${ctx.prefix}${prefixTrigger}`; + + const input = example.args ? `${baseCommand} ${example.args}` : baseUsage; + const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey); + return `- ${input}: ${description}`; + }) + .join("\n"); + + return new EmbedBuilder() + .setColor(0x2f855a) + .setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName })) + .setDescription(ctx.ct("embed.detailsDescription", { description: commandDescription(ctx, command) })) + .addFields( + { + name: ctx.ct("embed.fields.usage"), + value: `${ctx.ct("labels.prefix")}: ${usagePrefix}\n${ctx.ct("labels.slash")}: ${usageSlash}`, + }, + { + name: ctx.ct("embed.fields.arguments"), + value: args, + }, + { + name: ctx.ct("embed.fields.examples"), + value: examples, + }, + ) + .setFooter({ text: ctx.ct("embed.footer", { source: command.meta.category }) }); +}; diff --git a/src/modules/memberMessages/index.ts b/src/modules/memberMessages/index.ts new file mode 100644 index 0000000..581d6ff --- /dev/null +++ b/src/modules/memberMessages/index.ts @@ -0,0 +1,3 @@ +export { createMemberMessagePanelExecute } from "../../features/memberMessages/commandPanel.js"; +export type { MemberMessageRepository } from "../../features/memberMessages/repository.js"; +export { MemberMessageService } from "../../features/memberMessages/service.js"; diff --git a/src/modules/presence/index.ts b/src/modules/presence/index.ts new file mode 100644 index 0000000..fdb72e4 --- /dev/null +++ b/src/modules/presence/index.ts @@ -0,0 +1,3 @@ +export { createPresenceCommandExecute } from "../../features/presence/commandPanel.js"; +export type { PresenceRepository } from "../../features/presence/repository.js"; +export { PresenceService } from "../../features/presence/service.js"; diff --git a/src/types/command.ts b/src/types/command.ts index fe9c265..a439949 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -8,6 +8,7 @@ import type { Message, MessageCreateOptions, MessageReplyOptions, + PermissionsBitField, PermissionResolvable, Role, TextBasedChannel, @@ -72,7 +73,49 @@ export interface CommandI18nTools { format: (template: string, vars?: TranslationVars) => string; } +export interface ExecutionContext { + requestId: string; + receivedAt: number; + source: CommandSource; + commandName: string; + commandCategory: string; + args: Record; + actor: { + userId: string; + guildId: string | null; + channelId: string | null; + }; +} + +export interface TransportContext { + kind: "discord"; + client: Client; + user: User; + guild: Guild | null; + channel: TextBasedChannel | null; + raw: Message | ChatInputCommandInteraction; + reply: (payload: ReplyPayload) => Promise; + resolveMemberPermissions: () => Promise | null>; +} + +export interface I18nContext { + lang: SupportedLang; + t: (key: string, vars?: TranslationVars) => string; + ct: (relativeKey: string, vars?: TranslationVars) => string; + commandText: Record; + format: (template: string, vars?: TranslationVars) => string; + i18n: CommandI18nTools; + registry: CommandRegistryReader; + prefix: string; + defaultLang: SupportedLang; +} + export interface CommandExecutionContext { + execution: ExecutionContext; + transport: TransportContext; + i18nContext: I18nContext; + + // Backward-compatible aliases kept for existing command implementations. client: Client; user: User; guild: Guild | null; @@ -97,6 +140,7 @@ export interface BotCommandInput { meta: CommandMeta; args?: CommandArgument[]; permissions?: PermissionResolvable[]; + sensitive?: boolean; examples?: CommandExample[]; cooldown?: number; execute: (ctx: CommandExecutionContext) => Promise; @@ -106,6 +150,7 @@ export interface BotCommand { meta: CommandMeta; args: CommandArgument[]; permissions: PermissionResolvable[]; + sensitive: boolean; examples: CommandExample[]; cooldown?: number; execute: (ctx: CommandExecutionContext) => Promise; diff --git a/src/types/handlers.ts b/src/types/handlers.ts index a1f48df..2c73f17 100644 --- a/src/types/handlers.ts +++ b/src/types/handlers.ts @@ -1,5 +1,5 @@ import type { CommandRegistry } from "../core/commands/registry.js"; -import type { CommandExecutor } from "../core/execution/CommandExecutor.js"; +import type { CommandDispatchPort } from "../core/execution/dispatch.js"; import type { I18nService } from "../i18n/index.js"; import type { BotCommand, @@ -16,6 +16,7 @@ export interface HandlerExecutionDeps { } export interface BuildExecutionContextInput { + requestId: string; command: BotCommand; source: CommandSource; lang: SupportedLang; @@ -29,9 +30,9 @@ export interface BuildExecutionContextInput { } export interface PrefixHandlerDeps extends HandlerExecutionDeps { - executor: CommandExecutor; + dispatcher: CommandDispatchPort; } export interface SlashHandlerDeps extends HandlerExecutionDeps { - executor: CommandExecutor; + dispatcher: CommandDispatchPort; } \ No newline at end of file diff --git a/tests/defineCommand.test.ts b/tests/defineCommand.test.ts index 26d571b..61fb16c 100644 --- a/tests/defineCommand.test.ts +++ b/tests/defineCommand.test.ts @@ -35,5 +35,26 @@ test("defineCommand applique les valeurs par defaut", () => { assert.deepEqual(command.args, []); assert.deepEqual(command.permissions, []); assert.deepEqual(command.examples, []); + assert.equal(command.sensitive, false); assert.equal(command.cooldown, undefined); }); + +test("defineCommand refuse une commande sensible sans permission", () => { + assert.throws(() => { + defineCommand({ + meta: { name: "sensitiveNoPerm", category: "test" }, + sensitive: true, + execute: async () => undefined, + }); + }); +}); + +test("defineCommand marque sensible automatiquement si permissions presentes", () => { + const command = defineCommand({ + meta: { name: "secured", category: "test" }, + permissions: ["ManageGuild"], + execute: async () => undefined, + }); + + assert.equal(command.sensitive, true); +}); diff --git a/tests/executionStores.test.ts b/tests/executionStores.test.ts new file mode 100644 index 0000000..0362979 --- /dev/null +++ b/tests/executionStores.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MemoryCooldownStore } from "../src/core/execution/cooldownStore.js"; +import { MemoryGlobalRateLimitStore } from "../src/core/execution/globalRateLimitStore.js"; + +test("MemoryCooldownStore bloque les executions pendant la fenetre", async () => { + const store = new MemoryCooldownStore(); + + const first = await store.consume("ping", "user-1", 10); + assert.equal(first.allowed, true); + assert.equal(first.retryAfterSeconds, 0); + + const second = await store.consume("ping", "user-1", 10); + assert.equal(second.allowed, false); + assert.ok(second.retryAfterSeconds > 0); +}); + +test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async () => { + const store = new MemoryGlobalRateLimitStore(); + const policy = { + limit: 2, + windowSeconds: 30, + }; + + const first = await store.consume("user-rl", policy); + assert.equal(first.allowed, true); + assert.equal(first.remaining, 1); + + const second = await store.consume("user-rl", policy); + assert.equal(second.allowed, true); + assert.equal(second.remaining, 0); + + const third = await store.consume("user-rl", policy); + assert.equal(third.allowed, false); + assert.ok(third.retryAfterSeconds > 0); +});