This commit is contained in:
Puechberty Arthur
2026-04-14 22:32:27 +02:00
parent aa3cec7127
commit e5effd62fa
51 changed files with 1855 additions and 366 deletions
+11
View File
@@ -2,6 +2,9 @@ DISCORD_TOKEN=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots
DATABASE_SSL=false DATABASE_SSL=false
DATABASE_SSL_REJECT_UNAUTHORIZED=true
DATABASE_SSL_CA=
ALLOW_INSECURE_DB_SSL=false
PRESENCE_STREAM_URL=https://twitch.tv/discord PRESENCE_STREAM_URL=https://twitch.tv/discord
POSTGRES_DB=discord_bots POSTGRES_DB=discord_bots
POSTGRES_USER=discord_bot POSTGRES_USER=discord_bot
@@ -11,3 +14,11 @@ PREFIX=+
DEFAULT_LANG=en DEFAULT_LANG=en
DEV_GUILD_ID= DEV_GUILD_ID=
AUTO_DEPLOY_SLASH=false 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
+25 -10
View File
@@ -24,15 +24,21 @@ Organisation des dossiers
- Wrappers de commandes uniquement. - Wrappers de commandes uniquement.
- Chaque fichier retourne une commande via `defineCommand(...)`. - Chaque fichier retourne une commande via `defineCommand(...)`.
- Aucune logique metier lourde dans cette couche. - 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/` - `src/features/`
- `presence/`: orchestration panel, runtime, service, repository contract. - Implementation interne des modules (detail technique derriere `src/modules/*`).
- `memberMessages/`: orchestration panel, dispatch, image rendering, repository contract.
- `src/core/` - `src/core/`
- `commands/`: parser, registry, slash builder, usage. - `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). - `discord/`: helpers partages (resolveReplyMessage, session registry).
- `src/database/` - `src/database/`
- `stores/`: implementations PostgreSQL concretes. - `stores/`: implementations PostgreSQL concretes.
- `migrations/`: fichiers SQL versionnes appliques via `npm run migrate`.
- `dbLifecycle.ts`: init/shutdown centralise. - `dbLifecycle.ts`: init/shutdown centralise.
- `src/validators/` - `src/validators/`
- Validation et sanitation metier (presence, member messages). - Validation et sanitation metier (presence, member messages).
@@ -49,14 +55,21 @@ Principes d'architecture
------------------------ ------------------------
- Commands UI only: - Commands UI only:
- Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`. - Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`.
- Features own business logic: - Modules own business logic:
- Toute logique metier testable va dans `src/features/*`. - Toute logique metier testable va dans `src/modules/*` (les wrappers `src/commands/*` restent minces).
- Core is shared infra: - Core is shared infra:
- Helpers communs Discord, execution pipeline, parser, registry. - 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: - Validators isolate rules:
- Les regles de validation/sanitation ne vont pas dans `src/types`. - Les regles de validation/sanitation ne vont pas dans `src/types`.
- Database via repository contracts: - 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: - Bootstrap owns lifecycle:
- Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`. - 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/`. 1. Creer un wrapper dans `src/commands/`.
2. Declarer la commande avec `defineCommand({ ... })`. 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`). 4. Ajouter la commande dans `createCommandList` (`src/commands/index.ts`).
5. Ajouter les cles i18n dans `src/i18n/*.json`. 5. Ajouter les cles i18n dans `src/i18n/*.json`.
6. Ajouter les tests cibles (`tests/`) selon la logique introduite. 6. Ajouter les tests cibles (`tests/`) selon la logique introduite.
Procedure: ajouter une feature Procedure: ajouter une feature
------------------------------ ------------------------------
1. Creer `src/features/<feature>/`. 1. Creer `src/modules/<feature>/`.
2. Definir les contrats repository dans la feature. 2. Definir les contrats repository dans la feature.
3. Implementer le service metier independant de Discord quand possible. 3. Implementer le service metier independant de Discord quand possible.
4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`. 4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`.
5. Cablage d'injection dans `src/app/bootstrap.ts`. 5. Ajouter une migration SQL versionnee dans `database/migrations/`.
6. Ajouter tests unitaires et, si necessaire, integration. 6. Cablage d'injection dans `src/app/bootstrap.ts`.
7. Ajouter tests unitaires et, si necessaire, integration.
Tests et verification Tests et verification
--------------------- ---------------------
- Verification minimale avant PR: - Verification minimale avant PR:
- `npm run migrate`
- `npm run typecheck` - `npm run typecheck`
- `npm test` - `npm test`
- En cas de refactor de structure: - En cas de refactor de structure:
+8 -1
View File
@@ -20,5 +20,12 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY --from=builder /app/build ./build 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"]
+31 -6
View File
@@ -6,6 +6,9 @@ Professional command framework template for Discord.js `14.26.2` with:
- Minimal command authoring (`name`, `category`, `execute`) - Minimal command authoring (`name`, `category`, `execute`)
- Optional per-user command cooldown (`cooldown` in seconds) - Optional per-user command cooldown (`cooldown` in seconds)
- Shared execution logic for prefix and slash - 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 - External JSON i18n
- Automatic prefix and slash localizations from locale files - Automatic prefix and slash localizations from locale files
- One localized `name` per language drives both prefix and slash triggers - 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` - `DATABASE_URL`
4. Optional values: 4. Optional values:
- `DATABASE_SSL` (`true` for managed cloud DB, `false` for local Docker) - `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`) - `PRESENCE_STREAM_URL` (used when activity type is `STREAMING`)
- `AUTO_DEPLOY_SLASH` (`true` to sync slash commands on startup) - `AUTO_DEPLOY_SLASH` (`true` to sync slash commands on startup)
- `DEV_GUILD_ID` (optional guild scope for faster slash sync) - `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 npm run dev
6. Validate code quality: 7. Validate code quality:
- npm run typecheck - npm run typecheck
- npm run test - npm run test
- npm run check - npm run check
@@ -63,6 +79,7 @@ The bot container uses:
Security defaults: Security defaults:
- PostgreSQL is bound to `127.0.0.1` by default in Compose. - 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. - Keep strong values for `POSTGRES_PASSWORD` and rotate credentials if exposed.
## Multi-Bot With One DB ## 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/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown
- `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute) - `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute)
- `src/features/presence/*`: presence runtime, panel orchestration, repository contract - `src/modules/help/*`: help command module (service + command contract)
- `src/features/memberMessages/*`: member-message dispatch, panel orchestration, image rendering, repository 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/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/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry)
- `src/database/stores/*`: PostgreSQL store implementations - `src/database/stores/*`: PostgreSQL store implementations
- `src/database/dbLifecycle.ts`: centralized DB init/shutdown lifecycle - `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/...` 1. Create a command object in `src/commands/...`
2. Follow the schema in `src/types/command.ts` 2. Follow the schema in `src/types/command.ts`
3. Add command to `src/commands/index.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/<module>/...` and keep `src/commands/*` as wrappers only
5. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically
@@ -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);
+234
View File
@@ -11,7 +11,9 @@
"@napi-rs/canvas": "^0.1.97", "@napi-rs/canvas": "^0.1.97",
"discord.js": "14.26.2", "discord.js": "14.26.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"ioredis": "^5.10.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1",
"zod": "3.23.8" "zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -607,6 +609,12 @@
"node": ">=18" "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": { "node_modules/@napi-rs/canvas": {
"version": "0.1.97", "version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
@@ -856,6 +864,12 @@
"url": "https://github.com/sponsors/Brooooooklyn" "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": { "node_modules/@sapphire/async-queue": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -929,6 +943,50 @@
"npm": ">=7.0.0" "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": { "node_modules/discord-api-types": {
"version": "0.38.45", "version": "0.38.45",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", "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" "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": { "node_modules/lodash": {
"version": "4.18.1", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "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": { "node_modules/lodash.snakecase": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
@@ -1071,6 +1165,21 @@
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT" "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": { "node_modules/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -1161,6 +1270,43 @@
"split2": "^4.1.0" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1200,6 +1346,58 @@
"node": ">=0.10.0" "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": { "node_modules/resolve-pkg-maps": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "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" "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": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -1219,6 +1435,24 @@
"node": ">= 10.x" "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": { "node_modules/ts-mixer": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
+4 -1
View File
@@ -9,16 +9,19 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "npm run typecheck && node scripts/build.mjs", "build": "npm run typecheck && node scripts/build.mjs",
"migrate": "node scripts/migrate.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test tests/**/*.test.ts", "test": "node --import tsx --test tests/**/*.test.ts",
"check": "npm run typecheck && npm run test", "check": "npm run typecheck && npm run test",
"start": "node build/index.js" "start": "npm run migrate && node build/index.js"
}, },
"dependencies": { "dependencies": {
"@napi-rs/canvas": "^0.1.97", "@napi-rs/canvas": "^0.1.97",
"discord.js": "14.26.2", "discord.js": "14.26.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
"ioredis": "^5.10.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1",
"zod": "3.23.8" "zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
+180
View File
@@ -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);
});
+150 -27
View File
@@ -1,4 +1,5 @@
import { Client, GatewayIntentBits } from "discord.js"; import { Client, GatewayIntentBits } from "discord.js";
import { Redis } from "ioredis";
import { Pool } from "pg"; import { Pool } from "pg";
import type { AppFeatureServices } from "./container.js"; import type { AppFeatureServices } from "./container.js";
@@ -6,6 +7,24 @@ import { createCommandList } from "../commands/index.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { CommandRegistry } from "../core/commands/registry.js"; import { CommandRegistry } from "../core/commands/registry.js";
import { CommandExecutor } from "../core/execution/CommandExecutor.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 { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
import { PostgresPresenceStore } from "../database/stores/presenceStore.js"; import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
import { DatabaseLifecycle } from "../database/dbLifecycle.js"; import { DatabaseLifecycle } from "../database/dbLifecycle.js";
@@ -13,8 +32,15 @@ import { registerEvents } from "../events/index.js";
import { createPrefixHandler } from "../handlers/prefixHandler.js"; import { createPrefixHandler } from "../handlers/prefixHandler.js";
import { createSlashHandler } from "../handlers/slashHandler.js"; import { createSlashHandler } from "../handlers/slashHandler.js";
import { I18nService } from "../i18n/index.js"; import { I18nService } from "../i18n/index.js";
import { MemberMessageService } from "../features/memberMessages/service.js"; import {
import { PresenceService } from "../features/presence/service.js"; 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>): void => { const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
process.once("SIGINT", () => { process.once("SIGINT", () => {
@@ -26,12 +52,48 @@ const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void
}); });
}; };
const bindFatalProcessHandlers = (
shutdown: (signal: string, exitCode?: number, error?: unknown) => Promise<void>,
): 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<void> => { export const bootstrap = async (): Promise<void> => {
const pool = new Pool({ const pool = new Pool({
connectionString: env.DATABASE_URL, 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 presenceStore = new PostgresPresenceStore(pool);
const memberMessageStore = new PostgresMemberMessageStore(pool); const memberMessageStore = new PostgresMemberMessageStore(pool);
@@ -50,44 +112,94 @@ export const bootstrap = async (): Promise<void> => {
memberMessageService: new MemberMessageService(memberMessageStore), 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 shuttingDown = false;
let client: Client | null = null; let client: Client | null = null;
const shutdown = async (signal: string, exitCode = 0): Promise<void> => { const shutdown = async (signal: string, exitCode = 0, error?: unknown): Promise<void> => {
if (shuttingDown) { if (shuttingDown) {
return; return;
} }
shuttingDown = true; 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) { if (client) {
client.destroy(); client.destroy();
} }
await services.presenceService.shutdown().catch((error) => { 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) => { 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); process.exit(exitCode);
}; };
bindFatalProcessHandlers(shutdown);
try { try {
await dbLifecycle.init(); await dbLifecycle.init();
bindGracefulShutdown((signal) => shutdown(signal)); 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({ client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
@@ -99,12 +211,11 @@ export const bootstrap = async (): Promise<void> => {
const i18n = new I18nService(env.DEFAULT_LANG); const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(createCommandList(services, i18n), i18n); const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
const executor = new CommandExecutor();
const onPrefixMessage = createPrefixHandler({ const onPrefixMessage = createPrefixHandler({
registry, registry,
i18n, i18n,
executor, dispatcher,
prefix: env.PREFIX, prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG, defaultLang: env.DEFAULT_LANG,
}); });
@@ -112,23 +223,35 @@ export const bootstrap = async (): Promise<void> => {
const onSlashInteraction = createSlashHandler({ const onSlashInteraction = createSlashHandler({
registry, registry,
i18n, i18n,
executor, dispatcher,
prefix: env.PREFIX, prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG, 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); await client.login(env.DISCORD_TOKEN);
} catch (error) { } catch (error) {
await services.presenceService.shutdown().catch((closeError) => { await shutdown("BOOTSTRAP_FAILURE", 1, error);
console.error("[boot] failed to close presence service", closeError);
});
await dbLifecycle.shutdown().catch((closeError) => {
console.error("[boot] failed to shutdown database", closeError);
});
throw error; throw error;
} }
}; };
+6 -2
View File
@@ -1,5 +1,9 @@
import type { MemberMessageService } from "../features/memberMessages/service.js"; import type {
import type { PresenceService } from "../features/presence/service.js"; MemberMessageService,
} from "../modules/memberMessages/index.js";
import type {
PresenceService,
} from "../modules/presence/index.js";
export interface AppFeatureServices { export interface AppFeatureServices {
presenceService: PresenceService; presenceService: PresenceService;
+5 -2
View File
@@ -7,9 +7,11 @@
import { PermissionFlagsBits } from "discord.js"; import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.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 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'. */ /** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
@@ -18,6 +20,7 @@ export const createGoodbyeCommand = (memberMessageService: MemberMessageService,
category: "utility", category: "utility",
}, },
permissions: [PermissionFlagsBits.ManageGuild], permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [ examples: [
{ {
source: "slash", source: "slash",
+1 -166
View File
@@ -1,166 +1 @@
/** export { helpCommand } from "../modules/help/command.js";
* 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<string, BotCommand[]>();
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} <command>` }));
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: "<command>",
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)] });
},
});
+8 -2
View File
@@ -1,12 +1,18 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js"; import { defineCommand } from "../core/commands/defineCommand.js";
import { createPresenceCommandExecute } from "../features/presence/commandPanel.js"; import {
import type { PresenceService } from "../features/presence/service.js"; createPresenceCommandExecute,
type PresenceService,
} from "../modules/presence/index.js";
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({ export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
meta: { meta: {
name: "presence", name: "presence",
category: "utility", category: "utility",
}, },
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [ examples: [
{ {
source: "slash", source: "slash",
+5 -2
View File
@@ -7,9 +7,11 @@
import { PermissionFlagsBits } from "discord.js"; import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.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 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'. */ /** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
@@ -18,6 +20,7 @@ export const createWelcomeCommand = (memberMessageService: MemberMessageService,
category: "utility", category: "utility",
}, },
permissions: [PermissionFlagsBits.ManageGuild], permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [ examples: [
{ {
source: "slash", source: "slash",
+77 -3
View File
@@ -5,6 +5,33 @@ import { SUPPORTED_LANGS } from "../types/command.js";
loadEnv(); 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({ const envSchema = z.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"), DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
@@ -17,7 +44,28 @@ const envSchema = z.object({
.string() .string()
.optional() .optional()
.default("false") .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 PRESENCE_STREAM_URL: z
.string() .string()
.url("PRESENCE_STREAM_URL must be a valid URL") .url("PRESENCE_STREAM_URL must be a valid URL")
@@ -30,7 +78,33 @@ const envSchema = z.object({
.string() .string()
.optional() .optional()
.default("false") .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;
+18 -1
View File
@@ -32,14 +32,31 @@ const normalizeCooldown = (input: BotCommandInput): number | undefined => {
return input.cooldown; 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 => { export const defineCommand = (input: BotCommandInput): BotCommand => {
assertRequiredArgsBeforeOptional(input); assertRequiredArgsBeforeOptional(input);
const cooldown = normalizeCooldown(input); const cooldown = normalizeCooldown(input);
const permissions = [...(input.permissions ?? [])];
const sensitive = resolveSensitive(input, permissions);
return { return {
meta: input.meta, meta: input.meta,
args: [...(input.args ?? [])], args: [...(input.args ?? [])],
permissions: input.permissions ?? [], permissions,
sensitive,
examples: input.examples ?? [], examples: input.examples ?? [],
...(cooldown !== undefined ? { cooldown } : {}), ...(cooldown !== undefined ? { cooldown } : {}),
execute: input.execute, execute: input.execute,
+94 -48
View File
@@ -1,27 +1,50 @@
import { import {
PermissionsBitField, PermissionsBitField,
type ChatInputCommandInteraction,
type Message,
type PermissionResolvable, type PermissionResolvable,
} from "discord.js"; } from "discord.js";
import type { BotCommand, CommandExecutionContext } from "../../types/command.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; export interface CommandExecutorDeps {
const COOLDOWN_SWEEP_MIN_ENTRIES = 512; cooldownStore: CooldownStore;
globalRateLimitStore: GlobalRateLimitStore;
globalRateLimitPolicy: GlobalRateLimitPolicy;
logger: AppLogger;
}
export class CommandExecutor { export class CommandExecutor {
private readonly cooldowns = new Map<string, number>(); public constructor(private readonly deps: CommandExecutorDeps) {}
private lastCooldownSweepAt = 0;
public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> { public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
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) { if (missingUserPermissions.length > 0) {
await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") })); await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") }));
return; return;
} }
const remainingCooldownSeconds = this.consumeCooldown(command, ctx.user.id); const remainingCooldownSeconds = await this.consumeCooldown(command, ctx.execution.actor.userId);
if (remainingCooldownSeconds > 0) { if (remainingCooldownSeconds > 0) {
await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds })); await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }));
return; return;
@@ -30,18 +53,37 @@ export class CommandExecutor {
try { try {
await command.execute(ctx); await command.execute(ctx);
} catch (error) { } 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")); await ctx.reply(ctx.t("errors.execution"));
} }
} }
private memberPermissions(ctx: CommandExecutionContext): Readonly<PermissionsBitField> | null { private async resolveMemberPermissions(
if (ctx.source === "slash") { ctx: CommandExecutionContext,
return (ctx.raw as ChatInputCommandInteraction).memberPermissions ?? null; ): Promise<Readonly<PermissionsBitField> | 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( private getMissingPermissions(
@@ -86,50 +128,54 @@ export class CommandExecutor {
.trim(); .trim();
} }
private consumeCooldown(command: BotCommand, userId: string): number { private async consumeCooldown(command: BotCommand, userId: string): Promise<number> {
if (command.cooldown === undefined || command.cooldown <= 0) { if (command.cooldown === undefined || command.cooldown <= 0) {
return 0; return 0;
} }
const key = this.cooldownKey(command.meta.name, userId); try {
const now = Date.now(); const result = await this.deps.cooldownStore.consume(command.meta.name, userId, command.cooldown);
this.sweepExpiredCooldowns(now); return result.allowed ? 0 : result.retryAfterSeconds;
} catch (error) {
const expiresAt = this.cooldowns.get(key); this.deps.logger.warn(
{
if (expiresAt !== undefined && expiresAt > now) { command: command.meta.name,
return Math.ceil((expiresAt - now) / 1000); 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 { private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise<number> {
if (this.cooldowns.size === 0) { try {
return; const result = await this.deps.globalRateLimitStore.consume(
} ctx.execution.actor.userId,
this.deps.globalRateLimitPolicy,
);
const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES; if (result.allowed) {
const shouldSweepByTime = now - this.lastCooldownSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS; return 0;
if (!shouldSweepBySize && !shouldSweepByTime) {
return;
}
for (const [key, expiresAt] of this.cooldowns.entries()) {
if (expiresAt <= now) {
this.cooldowns.delete(key);
} }
}
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 { private isSensitiveCommand(command: BotCommand): boolean {
return `${commandName}:${userId}`; return command.sensitive || command.permissions.length > 0;
} }
} }
+95
View File
@@ -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<CooldownConsumeResult>;
}
export class MemoryCooldownStore implements CooldownStore {
private readonly cooldowns = new Map<string, number>();
private lastSweepAt = 0;
public async consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise<CooldownConsumeResult> {
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<CooldownConsumeResult> {
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}`;
}
}
+103
View File
@@ -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<void>;
}
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<string, unknown>;
queuedAt: number;
}
export interface CommandJobPublisher {
publish(job: CommandExecutionJob): Promise<void>;
}
export class LocalCommandDispatchPort implements CommandDispatchPort {
public constructor(private readonly executor: CommandExecutor) {}
public async dispatch(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
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<void> {
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);
};
+108
View File
@@ -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<GlobalRateLimitDecision>;
}
interface WindowCounterEntry {
count: number;
resetAt: number;
}
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
private readonly counters = new Map<string, WindowCounterEntry>();
public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
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<GlobalRateLimitDecision> {
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,
};
};
@@ -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<void> {
await this.redis.lpush(this.queueName, JSON.stringify(job));
}
}
+27
View File
@@ -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;
+59
View File
@@ -0,0 +1,59 @@
import type { Pool } from "pg";
export interface LeaderCoordinator {
runIfLeader(lockName: string, task: () => Promise<void>): Promise<boolean>;
}
export class LocalLeaderCoordinator implements LeaderCoordinator {
public async runIfLeader(_lockName: string, task: () => Promise<void>): Promise<boolean> {
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<void>): Promise<boolean> {
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;
};
+20 -25
View File
@@ -9,30 +9,19 @@ import {
createDefaultMemberMessageConfig, createDefaultMemberMessageConfig,
isMemberMessageRenderTypeValue, isMemberMessageRenderTypeValue,
} from "../../validators/memberMessages.js"; } from "../../validators/memberMessages.js";
import type { MemberMessageRepository } from "../../features/memberMessages/repository.js"; import type { MemberMessageRepository } from "../../modules/memberMessages/index.js";
const tableSql = ` const memberMessageSchemaProbeSql = `
CREATE TABLE IF NOT EXISTS bot_member_message_configs ( SELECT
bot_id TEXT NOT NULL, bot_id,
guild_id TEXT NOT NULL, guild_id,
kind TEXT NOT NULL, kind,
enabled BOOLEAN NOT NULL DEFAULT FALSE, enabled,
channel_id TEXT, channel_id,
message_type TEXT NOT NULL DEFAULT 'simple', message_type,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at
PRIMARY KEY (bot_id, guild_id, kind) FROM bot_member_message_configs
); LIMIT 0;
`;
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 toConfig = (row: MemberMessageRow): MemberMessageConfig => { const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
@@ -49,8 +38,14 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
public constructor(private readonly pool: Pool) {} public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> { public async init(): Promise<void> {
await this.pool.query(tableSql); try {
await this.pool.query(migrationSql); 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<MemberMessageConfig> { public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
+20 -30
View File
@@ -15,34 +15,19 @@ import {
sanitizeActivityTexts, sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds, sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js"; } from "../../validators/presence.js";
import type { PresenceRepository } from "../../features/presence/repository.js"; import type { PresenceRepository } from "../../modules/presence/index.js";
const tableSql = ` const presenceSchemaProbeSql = `
CREATE TABLE IF NOT EXISTS bot_presence_states ( SELECT
bot_id TEXT PRIMARY KEY, bot_id,
status TEXT NOT NULL, status,
activity_type TEXT NOT NULL, activity_type,
activity_text TEXT NOT NULL, activity_text,
activity_texts TEXT, activity_texts,
rotation_interval_seconds INTEGER, rotation_interval_seconds,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at
); FROM bot_presence_states
`; LIMIT 0;
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 parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => { const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => {
@@ -92,9 +77,14 @@ export class PostgresPresenceStore implements PresenceRepository {
public constructor(private readonly pool: Pool) {} public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> { public async init(): Promise<void> {
await this.pool.query(tableSql); try {
await this.pool.query(migrationSql); await this.pool.query(presenceSchemaProbeSql);
await this.pool.query(backfillSql); } 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<PresenceState> { public async getByBotId(botId: string): Promise<PresenceState> {
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client } from "discord.js"; 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). * 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 => { export const registerGuildCreate = (client: Client): void => {
client.on(Events.GuildCreate, (guild) => { 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");
}); });
}; };
+6 -3
View File
@@ -1,10 +1,13 @@
import { Events, type Client } from "discord.js"; 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 => { export const registerGuildDelete = (client: Client, memberMessageService: MemberMessageService): void => {
client.on(Events.GuildDelete, (guild) => { 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); const botId = memberMessageService.resolveBotId(client);
if (!botId) { if (!botId) {
@@ -12,7 +15,7 @@ export const registerGuildDelete = (client: Client, memberMessageService: Member
} }
void memberMessageService.cleanupGuild(botId, guild.id).catch((error) => { 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");
}); });
}); });
}; };
+12 -2
View File
@@ -1,7 +1,10 @@
import { Events, type Client } from "discord.js"; 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 { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberAdd");
export const registerGuildMemberAdd = ( export const registerGuildMemberAdd = (
client: Client, client: Client,
@@ -16,7 +19,14 @@ export const registerGuildMemberAdd = (
user: member.user, user: member.user,
kind: "welcome", kind: "welcome",
}).catch((error) => { }).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",
);
}); });
}); });
}; };
+12 -2
View File
@@ -1,7 +1,10 @@
import { Events, type Client } from "discord.js"; 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 { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberRemove");
export const registerGuildMemberRemove = ( export const registerGuildMemberRemove = (
client: Client, client: Client,
@@ -16,7 +19,14 @@ export const registerGuildMemberRemove = (
user: member.user, user: member.user,
kind: "goodbye", kind: "goodbye",
}).catch((error) => { }).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",
);
}); });
}); });
}; };
+3 -1
View File
@@ -2,6 +2,7 @@ import type { ChatInputCommandInteraction, Client, Message } from "discord.js";
import type { AppFeatureServices } from "../app/container.js"; import type { AppFeatureServices } from "../app/container.js";
import type { CommandRegistry } from "../core/commands/registry.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 type { I18nService } from "../i18n/index.js";
import { registerGuildCreate } from "./guildCreate.js"; import { registerGuildCreate } from "./guildCreate.js";
import { registerGuildDelete } from "./guildDelete.js"; import { registerGuildDelete } from "./guildDelete.js";
@@ -20,6 +21,7 @@ export const registerEvents = (
}, },
registry: CommandRegistry, registry: CommandRegistry,
services: AppFeatureServices, services: AppFeatureServices,
leaderCoordinator: LeaderCoordinator,
): void => { ): void => {
registerMessageCreate(client, handlers.onPrefixMessage); registerMessageCreate(client, handlers.onPrefixMessage);
registerInteractionCreate(client, handlers.onSlashInteraction); registerInteractionCreate(client, handlers.onSlashInteraction);
@@ -30,5 +32,5 @@ export const registerEvents = (
registerGuildCreate(client); registerGuildCreate(client);
registerGuildDelete(client, services.memberMessageService); registerGuildDelete(client, services.memberMessageService);
registerClientReady(client, registry, i18n, services.presenceService); registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator);
}; };
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client, type ChatInputCommandInteraction } from "discord.js"; 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). * Enregistre le listener `interactionCreate` pour les commandes slash (chat input).
@@ -16,7 +19,7 @@ export const registerInteractionCreate = (
} }
void onSlashInteraction(interaction).catch((error) => { void onSlashInteraction(interaction).catch((error) => {
console.error("[event:interactionCreate] handler failed", error); log.error({ err: error }, "handler failed");
}); });
}); });
}; };
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client, type Message } from "discord.js"; 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. * 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>): void => { export const registerMessageCreate = (client: Client, onPrefixMessage: (message: Message) => Promise<void>): void => {
client.on(Events.MessageCreate, (message: Message) => { client.on(Events.MessageCreate, (message: Message) => {
void onPrefixMessage(message).catch((error) => { void onPrefixMessage(message).catch((error) => {
console.error("[event:messageCreate] handler failed", error); log.error({ err: error }, "handler failed");
}); });
}); });
}; };
+36 -12
View File
@@ -2,36 +2,60 @@ import { Events, type Client } from "discord.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { deployApplicationCommands } from "../core/commands/deploy.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 { CommandRegistry } from "../core/commands/registry.js";
import type { PresenceService } from "../features/presence/service.js";
import type { I18nService } from "../i18n/index.js"; import type { I18nService } from "../i18n/index.js";
import type { PresenceService } from "../modules/presence/index.js";
const log = createScopedLogger("event:ready");
export const registerClientReady = ( export const registerClientReady = (
client: Client, client: Client,
registry: CommandRegistry, registry: CommandRegistry,
i18n: I18nService, i18n: I18nService,
presenceService: PresenceService, presenceService: PresenceService,
leaderCoordinator: LeaderCoordinator,
): void => { ): void => {
client.once(Events.ClientReady, async () => { client.once(Events.ClientReady, async () => {
console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
try { 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) { } 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) { if (env.AUTO_DEPLOY_SLASH) {
try { try {
const result = await deployApplicationCommands({ const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", async () => {
token: env.DISCORD_TOKEN, const result = await deployApplicationCommands({
clientId: env.DISCORD_CLIENT_ID, token: env.DISCORD_TOKEN,
registry, clientId: env.DISCORD_CLIENT_ID,
i18n, registry,
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}), 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) { } catch (error) {
console.error("[ready] slash sync failed", error); log.error({ err: error }, "slash sync failed");
} }
} }
}); });
+4 -1
View File
@@ -12,6 +12,7 @@ import {
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js"; import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js"; import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import { createScopedLogger } from "../../core/logging/logger.js";
import type { I18nService } from "../../i18n/index.js"; import type { I18nService } from "../../i18n/index.js";
import type { CommandExecutionContext } from "../../types/command.js"; import type { CommandExecutionContext } from "../../types/command.js";
import type { import type {
@@ -28,6 +29,8 @@ import {
} from "../../validators/memberMessages.js"; } from "../../validators/memberMessages.js";
import type { MemberMessageService } from "./service.js"; import type { MemberMessageService } from "./service.js";
const log = createScopedLogger("command:memberMessagesPanel");
const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>(); const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => { const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => {
@@ -334,7 +337,7 @@ export const createMemberMessagePanelExecute = (
flags: [MessageFlags.Ephemeral], flags: [MessageFlags.Ephemeral],
}); });
} catch (error) { } catch (error) {
console.error(`[command:${kind}] interaction failed`, error); log.error({ kind, err: error }, "interaction failed");
if (!interaction.replied && !interaction.deferred) { if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ await interaction.reply({
+4 -1
View File
@@ -26,6 +26,9 @@ import {
import { getPresenceTemplateHelpText } from "./templateVariables.js"; import { getPresenceTemplateHelpText } from "./templateVariables.js";
import type { PresenceService } from "./service.js"; import type { PresenceService } from "./service.js";
import type { PresenceCustomIds } from "./types.js"; import type { PresenceCustomIds } from "./types.js";
import { createScopedLogger } from "../../core/logging/logger.js";
const log = createScopedLogger("command:presence");
const createCustomIds = (): PresenceCustomIds => { const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; 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] }); await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) { } catch (error) {
console.error("[command:presence] interaction failed", error); log.error({ err: error }, "interaction failed");
const fallback = ctx.t("errors.execution"); const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) { if (!interaction.replied && !interaction.deferred) {
+51 -6
View File
@@ -1,10 +1,14 @@
import type { import type {
CommandExecutionContext, CommandExecutionContext,
ExecutionContext,
I18nContext,
SupportedLang, SupportedLang,
TransportContext,
TranslationVars, TranslationVars,
} from "../types/command.js"; } from "../types/command.js";
import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js"; import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js";
import type { I18nService } from "../i18n/index.js"; import type { I18nService } from "../i18n/index.js";
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
export const createTranslator = ( export const createTranslator = (
i18n: I18nService, i18n: I18nService,
@@ -21,24 +25,65 @@ export const buildCommandExecutionContext = (
const ct = (relativeKey: string, vars?: TranslationVars): string => const ct = (relativeKey: string, vars?: TranslationVars): string =>
deps.i18n.commandT(input.lang, input.command.meta.name, relativeKey, vars); 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, client: input.client,
user: input.user, user: input.user,
guild: input.guild, guild: input.guild,
channel: input.channel, channel: input.channel,
raw: input.raw,
reply: input.reply,
resolveMemberPermissions: createDiscordMemberPermissionsResolver(input),
};
const i18nContext: I18nContext = {
lang: input.lang, lang: input.lang,
args: input.args,
command: input.command,
source: input.source,
t, t,
ct, ct,
commandText: deps.i18n.commandObject(input.lang, input.command.meta.name), commandText: deps.i18n.commandObject(input.lang, input.command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars), format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n, i18n: deps.i18n,
raw: input.raw,
reply: input.reply,
registry: deps.registry, registry: deps.registry,
prefix: deps.prefix, prefix: deps.prefix,
defaultLang: deps.defaultLang, 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,
};
}; };
+82
View File
@@ -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<Readonly<PermissionsBitField> | 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<PermissionsBitField> | 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<Readonly<PermissionsBitField> | null>) => {
if (input.source === "slash") {
const interaction = input.raw as ChatInputCommandInteraction;
return async (): Promise<Readonly<PermissionsBitField> | 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<Readonly<PermissionsBitField> | 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);
};
};
+4 -1
View File
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import type { Message } from "discord.js"; import type { Message } from "discord.js";
import type { BotCommand, SupportedLang } from "../types/command.js"; import type { BotCommand, SupportedLang } from "../types/command.js";
import type { PrefixHandlerDeps } from "../types/handlers.js"; import type { PrefixHandlerDeps } from "../types/handlers.js";
@@ -66,9 +68,10 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
return; return;
} }
await deps.executor.run( await deps.dispatcher.dispatch(
command, command,
buildCommandExecutionContext(deps, { buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command, command,
source: "prefix", source: "prefix",
lang, lang,
+4 -1
View File
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js"; import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import type { SlashHandlerDeps } from "../types/handlers.js"; import type { SlashHandlerDeps } from "../types/handlers.js";
@@ -35,9 +37,10 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
return; return;
} }
await deps.executor.run( await deps.dispatcher.dispatch(
command, command,
buildCommandExecutionContext(deps, { buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command, command,
source: "slash", source: "slash",
lang, lang,
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "You are missing permissions: {{permissions}}" "user": "You are missing permissions: {{permissions}}"
}, },
"execution": "An unexpected error happened while running this command.", "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": { "categories": {
"fun": "Fun", "fun": "Fun",
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "Te faltan permisos: {{permissions}}" "user": "Te faltan permisos: {{permissions}}"
}, },
"execution": "Ocurrio un error inesperado al ejecutar este comando.", "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": { "categories": {
"fun": "Diversion", "fun": "Diversion",
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "Tu n as pas les permissions: {{permissions}}" "user": "Tu n as pas les permissions: {{permissions}}"
}, },
"execution": "Une erreur inattendue est survenue pendant l execution.", "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": { "categories": {
"fun": "Fun", "fun": "Fun",
+4 -1
View File
@@ -1,6 +1,9 @@
import { bootstrap } from "./app/bootstrap.js"; import { bootstrap } from "./app/bootstrap.js";
import { createScopedLogger } from "./core/logging/logger.js";
const log = createScopedLogger("boot");
bootstrap().catch((error) => { bootstrap().catch((error) => {
console.error("[boot] fatal error", error); log.fatal({ err: error }, "fatal startup error");
process.exit(1); process.exit(1);
}); });
+46
View File
@@ -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: "<command>",
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)] });
},
});
+127
View File
@@ -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<string, BotCommand[]>();
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} <command>` }));
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 }) });
};
+3
View File
@@ -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";
+3
View File
@@ -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";
+45
View File
@@ -8,6 +8,7 @@ import type {
Message, Message,
MessageCreateOptions, MessageCreateOptions,
MessageReplyOptions, MessageReplyOptions,
PermissionsBitField,
PermissionResolvable, PermissionResolvable,
Role, Role,
TextBasedChannel, TextBasedChannel,
@@ -72,7 +73,49 @@ export interface CommandI18nTools {
format: (template: string, vars?: TranslationVars) => string; format: (template: string, vars?: TranslationVars) => string;
} }
export interface ExecutionContext {
requestId: string;
receivedAt: number;
source: CommandSource;
commandName: string;
commandCategory: string;
args: Record<string, CommandArgValue>;
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<unknown>;
resolveMemberPermissions: () => Promise<Readonly<PermissionsBitField> | null>;
}
export interface I18nContext {
lang: SupportedLang;
t: (key: string, vars?: TranslationVars) => string;
ct: (relativeKey: string, vars?: TranslationVars) => string;
commandText: Record<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
i18n: CommandI18nTools;
registry: CommandRegistryReader;
prefix: string;
defaultLang: SupportedLang;
}
export interface CommandExecutionContext { export interface CommandExecutionContext {
execution: ExecutionContext;
transport: TransportContext;
i18nContext: I18nContext;
// Backward-compatible aliases kept for existing command implementations.
client: Client; client: Client;
user: User; user: User;
guild: Guild | null; guild: Guild | null;
@@ -97,6 +140,7 @@ export interface BotCommandInput {
meta: CommandMeta; meta: CommandMeta;
args?: CommandArgument[]; args?: CommandArgument[];
permissions?: PermissionResolvable[]; permissions?: PermissionResolvable[];
sensitive?: boolean;
examples?: CommandExample[]; examples?: CommandExample[];
cooldown?: number; cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>; execute: (ctx: CommandExecutionContext) => Promise<void>;
@@ -106,6 +150,7 @@ export interface BotCommand {
meta: CommandMeta; meta: CommandMeta;
args: CommandArgument[]; args: CommandArgument[];
permissions: PermissionResolvable[]; permissions: PermissionResolvable[];
sensitive: boolean;
examples: CommandExample[]; examples: CommandExample[];
cooldown?: number; cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>; execute: (ctx: CommandExecutionContext) => Promise<void>;
+4 -3
View File
@@ -1,5 +1,5 @@
import type { CommandRegistry } from "../core/commands/registry.js"; 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 { I18nService } from "../i18n/index.js";
import type { import type {
BotCommand, BotCommand,
@@ -16,6 +16,7 @@ export interface HandlerExecutionDeps {
} }
export interface BuildExecutionContextInput { export interface BuildExecutionContextInput {
requestId: string;
command: BotCommand; command: BotCommand;
source: CommandSource; source: CommandSource;
lang: SupportedLang; lang: SupportedLang;
@@ -29,9 +30,9 @@ export interface BuildExecutionContextInput {
} }
export interface PrefixHandlerDeps extends HandlerExecutionDeps { export interface PrefixHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor; dispatcher: CommandDispatchPort;
} }
export interface SlashHandlerDeps extends HandlerExecutionDeps { export interface SlashHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor; dispatcher: CommandDispatchPort;
} }
+21
View File
@@ -35,5 +35,26 @@ test("defineCommand applique les valeurs par defaut", () => {
assert.deepEqual(command.args, []); assert.deepEqual(command.args, []);
assert.deepEqual(command.permissions, []); assert.deepEqual(command.permissions, []);
assert.deepEqual(command.examples, []); assert.deepEqual(command.examples, []);
assert.equal(command.sensitive, false);
assert.equal(command.cooldown, undefined); 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);
});
+37
View File
@@ -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);
});