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=
DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots
DATABASE_SSL=false
DATABASE_SSL_REJECT_UNAUTHORIZED=true
DATABASE_SSL_CA=
ALLOW_INSECURE_DB_SSL=false
PRESENCE_STREAM_URL=https://twitch.tv/discord
POSTGRES_DB=discord_bots
POSTGRES_USER=discord_bot
@@ -11,3 +14,11 @@ PREFIX=+
DEFAULT_LANG=en
DEV_GUILD_ID=
AUTO_DEPLOY_SLASH=false
LOG_LEVEL=info
STATE_BACKEND=memory
REDIS_URL=
COMMAND_DISPATCH_MODE=local
COMMAND_QUEUE_NAME=bot:command-jobs
GLOBAL_RATE_LIMIT_MAX_REQUESTS=20
GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10
ENABLE_LEADER_ELECTION=true
+25 -10
View File
@@ -24,15 +24,21 @@ Organisation des dossiers
- Wrappers de commandes uniquement.
- Chaque fichier retourne une commande via `defineCommand(...)`.
- Aucune logique metier lourde dans cette couche.
- `src/modules/`
- `help/`: module help (service + commande).
- `presence/`: point d'entree module presence (services et contrats).
- `memberMessages/`: point d'entree module member messages (services et contrats).
- `src/features/`
- `presence/`: orchestration panel, runtime, service, repository contract.
- `memberMessages/`: orchestration panel, dispatch, image rendering, repository contract.
- Implementation interne des modules (detail technique derriere `src/modules/*`).
- `src/core/`
- `commands/`: parser, registry, slash builder, usage.
- `execution/`: pipeline d'execution des commandes.
- `execution/`: pipeline d'execution des commandes (dispatch local/worker, cooldown/rate-limit stores).
- `runtime/`: coordination multi-instance (leader election / startup locks).
- `logging/`: logger structure JSON (`pino`).
- `discord/`: helpers partages (resolveReplyMessage, session registry).
- `src/database/`
- `stores/`: implementations PostgreSQL concretes.
- `migrations/`: fichiers SQL versionnes appliques via `npm run migrate`.
- `dbLifecycle.ts`: init/shutdown centralise.
- `src/validators/`
- Validation et sanitation metier (presence, member messages).
@@ -49,14 +55,21 @@ Principes d'architecture
------------------------
- Commands UI only:
- Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`.
- Features own business logic:
- Toute logique metier testable va dans `src/features/*`.
- Modules own business logic:
- Toute logique metier testable va dans `src/modules/*` (les wrappers `src/commands/*` restent minces).
- Core is shared infra:
- Helpers communs Discord, execution pipeline, parser, registry.
- Execution pipeline is decoupled:
- Parsing reste dans `src/handlers/*`.
- Dispatch est route par `src/core/execution/dispatch.ts` (`local` ou `worker`).
- Execution metier passe par `CommandExecutor` avec stores abstraits (memory/Redis).
- Contexts are split for low coupling:
- `ExecutionContext` (core), `TransportContext` (Discord), `I18nContext` (localization).
- `CommandExecutionContext` conserve des alias legacy pour ne pas casser les commandes existantes.
- Validators isolate rules:
- Les regles de validation/sanitation ne vont pas dans `src/types`.
- Database via repository contracts:
- Les features dependent d'interfaces, pas de singletons globaux.
- Les modules dependent d'interfaces, pas de singletons globaux.
- Bootstrap owns lifecycle:
- Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`.
@@ -72,23 +85,25 @@ Procedure: ajouter une commande
--------------------------------
1. Creer un wrapper dans `src/commands/`.
2. Declarer la commande avec `defineCommand({ ... })`.
3. Deleguer `execute` vers un module `src/features/...`.
3. Deleguer `execute` vers un module `src/modules/...`.
4. Ajouter la commande dans `createCommandList` (`src/commands/index.ts`).
5. Ajouter les cles i18n dans `src/i18n/*.json`.
6. Ajouter les tests cibles (`tests/`) selon la logique introduite.
Procedure: ajouter une feature
------------------------------
1. Creer `src/features/<feature>/`.
1. Creer `src/modules/<feature>/`.
2. Definir les contrats repository dans la feature.
3. Implementer le service metier independant de Discord quand possible.
4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`.
5. Cablage d'injection dans `src/app/bootstrap.ts`.
6. Ajouter tests unitaires et, si necessaire, integration.
5. Ajouter une migration SQL versionnee dans `database/migrations/`.
6. Cablage d'injection dans `src/app/bootstrap.ts`.
7. Ajouter tests unitaires et, si necessaire, integration.
Tests et verification
---------------------
- Verification minimale avant PR:
- `npm run migrate`
- `npm run typecheck`
- `npm test`
- En cas de refactor de structure:
+8 -1
View File
@@ -20,5 +20,12 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/build ./build
COPY scripts ./scripts
COPY database ./database
CMD ["node", "build/index.js"]
RUN addgroup -S app && adduser -S app -G app
RUN chown -R app:app /app
USER app
CMD ["sh", "-c", "node scripts/migrate.mjs && node build/index.js"]
+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`)
- Optional per-user command cooldown (`cooldown` in seconds)
- Shared execution logic for prefix and slash
- Dispatch pipeline ready for local execution or worker queue mode
- Global user rate limiting (memory or Redis backend)
- Structured JSON logging with `pino`
- External JSON i18n
- Automatic prefix and slash localizations from locale files
- One localized `name` per language drives both prefix and slash triggers
@@ -31,12 +34,25 @@ Professional command framework template for Discord.js `14.26.2` with:
- `DATABASE_URL`
4. Optional values:
- `DATABASE_SSL` (`true` for managed cloud DB, `false` for local Docker)
- `DATABASE_SSL_REJECT_UNAUTHORIZED` (`true` by default for secure TLS verification)
- `DATABASE_SSL_CA` (optional CA cert chain, supports escaped newlines)
- `ALLOW_INSECURE_DB_SSL` (`false` by default; set `true` only for local/dev exceptions)
- `PRESENCE_STREAM_URL` (used when activity type is `STREAMING`)
- `AUTO_DEPLOY_SLASH` (`true` to sync slash commands on startup)
- `DEV_GUILD_ID` (optional guild scope for faster slash sync)
5. Start in development:
- `LOG_LEVEL` (default `info`, JSON logs)
- `STATE_BACKEND` (`memory` or `redis`)
- `REDIS_URL` (required when `STATE_BACKEND=redis`)
- `GLOBAL_RATE_LIMIT_MAX_REQUESTS` (global per-user request budget)
- `GLOBAL_RATE_LIMIT_WINDOW_SECONDS` (window size in seconds)
- `COMMAND_DISPATCH_MODE` (`local` or `worker`; enable `worker` only if a queue consumer is deployed)
- `COMMAND_QUEUE_NAME` (Redis list used in `worker` mode)
- `ENABLE_LEADER_ELECTION` (`true` to guard startup jobs in multi-instance)
5. Run migrations:
npm run migrate
6. Start in development:
npm run dev
6. Validate code quality:
7. Validate code quality:
- npm run typecheck
- npm run test
- npm run check
@@ -63,6 +79,7 @@ The bot container uses:
Security defaults:
- PostgreSQL is bound to `127.0.0.1` by default in Compose.
- Database TLS verification is strict by default when SSL is enabled.
- Keep strong values for `POSTGRES_PASSWORD` and rotate credentials if exposed.
## Multi-Bot With One DB
@@ -81,10 +98,17 @@ An example with two bot services is available in `docker-compose.multi-bot.examp
- `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown
- `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute)
- `src/features/presence/*`: presence runtime, panel orchestration, repository contract
- `src/features/memberMessages/*`: member-message dispatch, panel orchestration, image rendering, repository contract
- `src/modules/help/*`: help command module (service + command contract)
- `src/modules/presence/*`: module entrypoint for presence runtime and contracts
- `src/modules/memberMessages/*`: module entrypoint for member-message runtime and contracts
- `src/features/*`: implementation details used behind `src/modules/*`
- `src/core/commands/*`: registry, parsing, slash payload, usage helpers
- `src/core/execution/CommandExecutor.ts`: unified execution pipeline (permissions/cooldown/error handling)
- `src/core/execution/CommandExecutor.ts`: execution core (permissions, cooldown, global rate limit)
- `src/core/execution/dispatch.ts`: dispatch abstraction (`local` or `worker`)
- `src/core/execution/cooldownStore.ts`: cooldown store contracts and memory/Redis implementations
- `src/core/execution/globalRateLimitStore.ts`: global limiter contracts and memory/Redis implementations
- `src/core/runtime/leaderCoordinator.ts`: leader-only startup coordination for multi-instance deployments
- `src/core/logging/logger.ts`: centralized structured logger (`pino`)
- `src/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry)
- `src/database/stores/*`: PostgreSQL store implementations
- `src/database/dbLifecycle.ts`: centralized DB init/shutdown lifecycle
@@ -106,4 +130,5 @@ An example with two bot services is available in `docker-compose.multi-bot.examp
1. Create a command object in `src/commands/...`
2. Follow the schema in `src/types/command.ts`
3. Add command to `src/commands/index.ts`
4. If `AUTO_DEPLOY_SLASH=true`, restart the bot to sync slash commands automatically
4. Put business logic in `src/modules/<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",
"discord.js": "14.26.2",
"dotenv": "16.4.5",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"pino": "^10.3.1",
"zod": "3.23.8"
},
"devDependencies": {
@@ -607,6 +609,12 @@
"node": ">=18"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
@@ -856,6 +864,12 @@
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -929,6 +943,50 @@
"npm": ">=7.0.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/discord-api-types": {
"version": "0.38.45",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz",
@@ -1053,12 +1111,48 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
@@ -1071,6 +1165,21 @@
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -1161,6 +1270,43 @@
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1200,6 +1346,58 @@
"node": ">=0.10.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1210,6 +1408,24 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -1219,6 +1435,24 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
+4 -1
View File
@@ -9,16 +9,19 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run typecheck && node scripts/build.mjs",
"migrate": "node scripts/migrate.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test tests/**/*.test.ts",
"check": "npm run typecheck && npm run test",
"start": "node build/index.js"
"start": "npm run migrate && node build/index.js"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.97",
"discord.js": "14.26.2",
"dotenv": "16.4.5",
"ioredis": "^5.10.1",
"pg": "^8.20.0",
"pino": "^10.3.1",
"zod": "3.23.8"
},
"devDependencies": {
+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 { Redis } from "ioredis";
import { Pool } from "pg";
import type { AppFeatureServices } from "./container.js";
@@ -6,6 +7,24 @@ import { createCommandList } from "../commands/index.js";
import { env } from "../config/env.js";
import { CommandRegistry } from "../core/commands/registry.js";
import { CommandExecutor } from "../core/execution/CommandExecutor.js";
import {
LocalCommandDispatchPort,
WorkerCommandDispatchPort,
} from "../core/execution/dispatch.js";
import {
MemoryCooldownStore,
RedisCooldownStore,
} from "../core/execution/cooldownStore.js";
import {
MemoryGlobalRateLimitStore,
RedisGlobalRateLimitStore,
} from "../core/execution/globalRateLimitStore.js";
import { RedisCommandJobPublisher } from "../core/execution/redisCommandJobPublisher.js";
import { createScopedLogger } from "../core/logging/logger.js";
import {
LocalLeaderCoordinator,
PostgresLeaderCoordinator,
} from "../core/runtime/leaderCoordinator.js";
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
@@ -13,8 +32,15 @@ import { registerEvents } from "../events/index.js";
import { createPrefixHandler } from "../handlers/prefixHandler.js";
import { createSlashHandler } from "../handlers/slashHandler.js";
import { I18nService } from "../i18n/index.js";
import { MemberMessageService } from "../features/memberMessages/service.js";
import { PresenceService } from "../features/presence/service.js";
import {
MemberMessageService,
} from "../modules/memberMessages/index.js";
import {
PresenceService,
} from "../modules/presence/index.js";
const SHUTDOWN_TIMEOUT_MS = 10_000;
const log = createScopedLogger("bootstrap");
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
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> => {
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
ssl: env.DATABASE_SSL
? {
rejectUnauthorized: env.DATABASE_SSL_REJECT_UNAUTHORIZED,
...(env.DATABASE_SSL_CA ? { ca: env.DATABASE_SSL_CA } : {}),
}
: undefined,
});
const requiresRedis = env.STATE_BACKEND === "redis" || env.COMMAND_DISPATCH_MODE === "worker";
let redis: Redis | null = null;
if (requiresRedis) {
const redisUrl = env.REDIS_URL;
if (!redisUrl) {
throw new Error("REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker");
}
redis = new Redis(redisUrl, {
maxRetriesPerRequest: null,
enableReadyCheck: true,
});
await redis.ping();
}
const presenceStore = new PostgresPresenceStore(pool);
const memberMessageStore = new PostgresMemberMessageStore(pool);
@@ -50,44 +112,94 @@ export const bootstrap = async (): Promise<void> => {
memberMessageService: new MemberMessageService(memberMessageStore),
};
const cooldownStore = env.STATE_BACKEND === "redis" && redis
? new RedisCooldownStore(redis)
: new MemoryCooldownStore();
const globalRateLimitStore = env.STATE_BACKEND === "redis" && redis
? new RedisGlobalRateLimitStore(redis)
: new MemoryGlobalRateLimitStore();
const executor = new CommandExecutor({
cooldownStore,
globalRateLimitStore,
globalRateLimitPolicy: {
limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
},
logger: createScopedLogger("command-executor"),
});
const dispatcher = (() => {
if (env.COMMAND_DISPATCH_MODE === "worker") {
if (!redis) {
throw new Error("Worker dispatch mode requires an initialized Redis client");
}
return new WorkerCommandDispatchPort(
new RedisCommandJobPublisher(redis, env.COMMAND_QUEUE_NAME),
createScopedLogger("command-dispatcher"),
);
}
return new LocalCommandDispatchPort(executor);
})();
const leaderCoordinator = env.ENABLE_LEADER_ELECTION
? new PostgresLeaderCoordinator(pool, "discordjs-framework-template")
: new LocalLeaderCoordinator();
let shuttingDown = false;
let client: Client | null = null;
const shutdown = async (signal: string, exitCode = 0): Promise<void> => {
const shutdown = async (signal: string, exitCode = 0, error?: unknown): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`[shutdown] ${signal}`);
log.info({ signal }, "shutdown signal received");
if (error !== undefined) {
log.error({ signal, err: error }, "shutdown reason");
}
const forcedExitCode = exitCode === 0 ? 1 : exitCode;
const forceExitTimer = setTimeout(() => {
log.error({ signal, forcedExitCode, timeoutMs: SHUTDOWN_TIMEOUT_MS }, "forcing process exit");
process.exit(forcedExitCode);
}, SHUTDOWN_TIMEOUT_MS);
forceExitTimer.unref?.();
if (client) {
client.destroy();
}
await services.presenceService.shutdown().catch((error) => {
console.error("[shutdown] presence service close failed", error);
log.error({ err: error }, "presence service shutdown failed");
});
await dbLifecycle.shutdown().catch((error) => {
console.error("[shutdown] database shutdown failed", error);
log.error({ err: error }, "database shutdown failed");
});
if (redis) {
await redis.quit().catch((error: unknown) => {
log.error({ err: error }, "redis shutdown failed");
});
}
clearTimeout(forceExitTimer);
process.exit(exitCode);
};
bindFatalProcessHandlers(shutdown);
try {
await dbLifecycle.init();
bindGracefulShutdown((signal) => shutdown(signal));
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
console.error("[process] uncaught exception", error);
});
client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -99,12 +211,11 @@ export const bootstrap = async (): Promise<void> => {
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
const executor = new CommandExecutor();
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
executor,
dispatcher,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
@@ -112,23 +223,35 @@ export const bootstrap = async (): Promise<void> => {
const onSlashInteraction = createSlashHandler({
registry,
i18n,
executor,
dispatcher,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry, services);
registerEvents(
client,
i18n,
{ onPrefixMessage, onSlashInteraction },
registry,
services,
leaderCoordinator,
);
log.info(
{
stateBackend: env.STATE_BACKEND,
commandDispatchMode: env.COMMAND_DISPATCH_MODE,
rateLimit: {
maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
},
},
"runtime initialized",
);
await client.login(env.DISCORD_TOKEN);
} catch (error) {
await services.presenceService.shutdown().catch((closeError) => {
console.error("[boot] failed to close presence service", closeError);
});
await dbLifecycle.shutdown().catch((closeError) => {
console.error("[boot] failed to shutdown database", closeError);
});
await shutdown("BOOTSTRAP_FAILURE", 1, error);
throw error;
}
};
+6 -2
View File
@@ -1,5 +1,9 @@
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { PresenceService } from "../features/presence/service.js";
import type {
MemberMessageService,
} from "../modules/memberMessages/index.js";
import type {
PresenceService,
} from "../modules/presence/index.js";
export interface AppFeatureServices {
presenceService: PresenceService;
+5 -2
View File
@@ -7,9 +7,11 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { I18nService } from "../i18n/index.js";
import {
createMemberMessagePanelExecute,
type MemberMessageService,
} from "../modules/memberMessages/index.js";
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
@@ -18,6 +20,7 @@ export const createGoodbyeCommand = (memberMessageService: MemberMessageService,
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
+1 -166
View File
@@ -1,166 +1 @@
/**
* Commande `help`
*
* Construit un embed d'aide global listant les commandes par catégorie, ou
* affiche les détails pour une commande spécifique si un argument est fourni.
*
* Export:
* - `helpCommand`: BotCommand (défini via `defineCommand`)
*/
import { EmbedBuilder } from "discord.js";
import { buildPrefixUsage, buildSlashUsage, resolvePrefixTrigger, resolveSlashName } from "../core/commands/usage.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import type { BotCommand, CommandExecutionContext } from "../types/command.js";
const categoryName = (command: BotCommand): string => command.meta.category;
const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string =>
ctx.i18n.commandT(ctx.lang, command.meta.name, "description");
const resolveCommandFromQuery = (ctx: CommandExecutionContext, query: string): BotCommand | undefined => {
const normalized = query.toLowerCase();
const byName = ctx.registry.findByName(normalized);
if (byName) {
return byName;
}
return ctx.registry.findByAnyPrefixTrigger(normalized)?.command;
};
const buildGlobalHelpEmbed = (ctx: CommandExecutionContext): EmbedBuilder => {
const commands = [...ctx.registry.getAll()];
const grouped = new Map<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)] });
},
});
export { helpCommand } from "../modules/help/command.js";
+8 -2
View File
@@ -1,12 +1,18 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import { createPresenceCommandExecute } from "../features/presence/commandPanel.js";
import type { PresenceService } from "../features/presence/service.js";
import {
createPresenceCommandExecute,
type PresenceService,
} from "../modules/presence/index.js";
export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
meta: {
name: "presence",
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
+5 -2
View File
@@ -7,9 +7,11 @@
import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { I18nService } from "../i18n/index.js";
import {
createMemberMessagePanelExecute,
type MemberMessageService,
} from "../modules/memberMessages/index.js";
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
@@ -18,6 +20,7 @@ export const createWelcomeCommand = (memberMessageService: MemberMessageService,
category: "utility",
},
permissions: [PermissionFlagsBits.ManageGuild],
sensitive: true,
examples: [
{
source: "slash",
+77 -3
View File
@@ -5,6 +5,33 @@ import { SUPPORTED_LANGS } from "../types/command.js";
loadEnv();
const toBoolean = (value: string): boolean => {
const normalized = value.trim().toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
};
const optionalString = (value?: string): string | undefined => {
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
};
const parseOptionalUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized;
};
const envSchema = z.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"),
@@ -17,7 +44,28 @@ const envSchema = z.object({
.string()
.optional()
.default("false")
.transform((value) => value.toLowerCase() === "true"),
.transform(toBoolean),
DATABASE_SSL_REJECT_UNAUTHORIZED: z
.string()
.optional()
.default("true")
.transform(toBoolean),
DATABASE_SSL_CA: z
.string()
.optional()
.transform((value) => {
const normalized = optionalString(value);
if (!normalized) {
return undefined;
}
return normalized.replace(/\\n/g, "\n");
}),
ALLOW_INSECURE_DB_SSL: z
.string()
.optional()
.default("false")
.transform(toBoolean),
PRESENCE_STREAM_URL: z
.string()
.url("PRESENCE_STREAM_URL must be a valid URL")
@@ -30,7 +78,33 @@ const envSchema = z.object({
.string()
.optional()
.default("false")
.transform((value) => value.toLowerCase() === "true"),
.transform(toBoolean),
LOG_LEVEL: z.string().trim().min(1).default("info"),
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
COMMAND_DISPATCH_MODE: z.enum(["local", "worker"]).default("local"),
COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:command-jobs"),
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
ENABLE_LEADER_ELECTION: z
.string()
.optional()
.default("true")
.transform(toBoolean),
});
export const env = envSchema.parse(process.env);
const parsed = envSchema.parse(process.env);
if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.ALLOW_INSECURE_DB_SSL) {
throw new Error(
"Insecure DATABASE_SSL configuration detected: rejectUnauthorized=false is blocked by default. Set DATABASE_SSL_REJECT_UNAUTHORIZED=true or explicitly set ALLOW_INSECURE_DB_SSL=true for non-production environments.",
);
}
if ((parsed.STATE_BACKEND === "redis" || parsed.COMMAND_DISPATCH_MODE === "worker") && !parsed.REDIS_URL) {
throw new Error(
"REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker.",
);
}
export const env = parsed;
+18 -1
View File
@@ -32,14 +32,31 @@ const normalizeCooldown = (input: BotCommandInput): number | undefined => {
return input.cooldown;
};
const resolveSensitive = (input: BotCommandInput, permissions: readonly unknown[]): boolean => {
if (input.sensitive === true && permissions.length === 0) {
throw new Error(
`Invalid security config for command "${input.meta.name}": sensitive commands must declare at least one required permission.`,
);
}
if (input.sensitive !== undefined) {
return input.sensitive;
}
return permissions.length > 0;
};
export const defineCommand = (input: BotCommandInput): BotCommand => {
assertRequiredArgsBeforeOptional(input);
const cooldown = normalizeCooldown(input);
const permissions = [...(input.permissions ?? [])];
const sensitive = resolveSensitive(input, permissions);
return {
meta: input.meta,
args: [...(input.args ?? [])],
permissions: input.permissions ?? [],
permissions,
sensitive,
examples: input.examples ?? [],
...(cooldown !== undefined ? { cooldown } : {}),
execute: input.execute,
+94 -48
View File
@@ -1,27 +1,50 @@
import {
PermissionsBitField,
type ChatInputCommandInteraction,
type Message,
type PermissionResolvable,
} from "discord.js";
import type { BotCommand, CommandExecutionContext } from "../../types/command.js";
import type { AppLogger } from "../logging/logger.js";
import type { CooldownStore } from "./cooldownStore.js";
import type {
GlobalRateLimitPolicy,
GlobalRateLimitStore,
} from "./globalRateLimitStore.js";
const COOLDOWN_SWEEP_INTERVAL_MS = 60_000;
const COOLDOWN_SWEEP_MIN_ENTRIES = 512;
export interface CommandExecutorDeps {
cooldownStore: CooldownStore;
globalRateLimitStore: GlobalRateLimitStore;
globalRateLimitPolicy: GlobalRateLimitPolicy;
logger: AppLogger;
}
export class CommandExecutor {
private readonly cooldowns = new Map<string, number>();
private lastCooldownSweepAt = 0;
public constructor(private readonly deps: CommandExecutorDeps) {}
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) {
await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") }));
return;
}
const remainingCooldownSeconds = this.consumeCooldown(command, ctx.user.id);
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx.execution.actor.userId);
if (remainingCooldownSeconds > 0) {
await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds }));
return;
@@ -30,18 +53,37 @@ export class CommandExecutor {
try {
await command.execute(ctx);
} catch (error) {
console.error(`[command:${command.meta.name}] execution failed`, error);
this.deps.logger.error(
{
requestId: ctx.execution.requestId,
command: command.meta.name,
source: ctx.execution.source,
userId: ctx.execution.actor.userId,
err: error,
},
"command execution failed",
);
await ctx.reply(ctx.t("errors.execution"));
}
}
private memberPermissions(ctx: CommandExecutionContext): Readonly<PermissionsBitField> | null {
if (ctx.source === "slash") {
return (ctx.raw as ChatInputCommandInteraction).memberPermissions ?? null;
private async resolveMemberPermissions(
ctx: CommandExecutionContext,
): 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(
@@ -86,50 +128,54 @@ export class CommandExecutor {
.trim();
}
private consumeCooldown(command: BotCommand, userId: string): number {
private async consumeCooldown(command: BotCommand, userId: string): Promise<number> {
if (command.cooldown === undefined || command.cooldown <= 0) {
return 0;
}
const key = this.cooldownKey(command.meta.name, userId);
const now = Date.now();
this.sweepExpiredCooldowns(now);
const expiresAt = this.cooldowns.get(key);
if (expiresAt !== undefined && expiresAt > now) {
return Math.ceil((expiresAt - now) / 1000);
try {
const result = await this.deps.cooldownStore.consume(command.meta.name, userId, command.cooldown);
return result.allowed ? 0 : result.retryAfterSeconds;
} catch (error) {
this.deps.logger.warn(
{
command: command.meta.name,
userId,
err: error,
},
"cooldown store unavailable, continuing without cooldown enforcement",
);
return 0;
}
if (expiresAt !== undefined) {
this.cooldowns.delete(key);
}
this.cooldowns.set(key, now + command.cooldown * 1000);
return 0;
}
private sweepExpiredCooldowns(now: number): void {
if (this.cooldowns.size === 0) {
return;
}
private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise<number> {
try {
const result = await this.deps.globalRateLimitStore.consume(
ctx.execution.actor.userId,
this.deps.globalRateLimitPolicy,
);
const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES;
const shouldSweepByTime = now - this.lastCooldownSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS;
if (!shouldSweepBySize && !shouldSweepByTime) {
return;
}
for (const [key, expiresAt] of this.cooldowns.entries()) {
if (expiresAt <= now) {
this.cooldowns.delete(key);
if (result.allowed) {
return 0;
}
}
this.lastCooldownSweepAt = now;
return result.retryAfterSeconds;
} catch (error) {
this.deps.logger.warn(
{
requestId: ctx.execution.requestId,
userId: ctx.execution.actor.userId,
err: error,
},
"global rate limit store unavailable, continuing without rate limiting",
);
return 0;
}
}
private cooldownKey(commandName: string, userId: string): string {
return `${commandName}:${userId}`;
private isSensitiveCommand(command: BotCommand): boolean {
return command.sensitive || command.permissions.length > 0;
}
}
+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,
isMemberMessageRenderTypeValue,
} from "../../validators/memberMessages.js";
import type { MemberMessageRepository } from "../../features/memberMessages/repository.js";
import type { MemberMessageRepository } from "../../modules/memberMessages/index.js";
const tableSql = `
CREATE TABLE IF NOT EXISTS bot_member_message_configs (
bot_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
kind TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
channel_id TEXT,
message_type TEXT NOT NULL DEFAULT 'simple',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bot_id, guild_id, kind)
);
`;
const migrationSql = `
ALTER TABLE bot_member_message_configs
ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE bot_member_message_configs
ADD COLUMN IF NOT EXISTS channel_id TEXT;
ALTER TABLE bot_member_message_configs
ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'simple';
const memberMessageSchemaProbeSql = `
SELECT
bot_id,
guild_id,
kind,
enabled,
channel_id,
message_type,
updated_at
FROM bot_member_message_configs
LIMIT 0;
`;
const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
@@ -49,8 +38,14 @@ export class PostgresMemberMessageStore implements MemberMessageRepository {
public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> {
await this.pool.query(tableSql);
await this.pool.query(migrationSql);
try {
await this.pool.query(memberMessageSchemaProbeSql);
} catch (error) {
throw new Error(
"[db:init] missing or incompatible table \"bot_member_message_configs\". Run migrations with \"npm run migrate\".",
{ cause: error },
);
}
}
public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
+20 -30
View File
@@ -15,34 +15,19 @@ import {
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js";
import type { PresenceRepository } from "../../features/presence/repository.js";
import type { PresenceRepository } from "../../modules/presence/index.js";
const tableSql = `
CREATE TABLE IF NOT EXISTS bot_presence_states (
bot_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
activity_type TEXT NOT NULL,
activity_text TEXT NOT NULL,
activity_texts TEXT,
rotation_interval_seconds INTEGER,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`;
const migrationSql = `
ALTER TABLE bot_presence_states
ADD COLUMN IF NOT EXISTS activity_texts TEXT;
ALTER TABLE bot_presence_states
ADD COLUMN IF NOT EXISTS rotation_interval_seconds INTEGER;
`;
const backfillSql = `
UPDATE bot_presence_states
SET
activity_texts = COALESCE(activity_texts, '[]'),
rotation_interval_seconds = COALESCE(rotation_interval_seconds, ${DEFAULT_ACTIVITY_ROTATION_INTERVAL_SECONDS})
WHERE activity_texts IS NULL OR rotation_interval_seconds IS NULL;
const presenceSchemaProbeSql = `
SELECT
bot_id,
status,
activity_type,
activity_text,
activity_texts,
rotation_interval_seconds,
updated_at
FROM bot_presence_states
LIMIT 0;
`;
const parseStoredTexts = (rawTexts: string | null, fallbackText: string): string[] => {
@@ -92,9 +77,14 @@ export class PostgresPresenceStore implements PresenceRepository {
public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> {
await this.pool.query(tableSql);
await this.pool.query(migrationSql);
await this.pool.query(backfillSql);
try {
await this.pool.query(presenceSchemaProbeSql);
} catch (error) {
throw new Error(
"[db:init] missing or incompatible table \"bot_presence_states\". Run migrations with \"npm run migrate\".",
{ cause: error },
);
}
}
public async getByBotId(botId: string): Promise<PresenceState> {
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:guildCreate");
/**
* Enregistre le listener `guildCreate` (bot ajouté à un serveur).
@@ -7,6 +10,6 @@ import { Events, type Client } from "discord.js";
*/
export const registerGuildCreate = (client: Client): void => {
client.on(Events.GuildCreate, (guild) => {
console.log(`[event:guildCreate] joined guild ${guild.id} (${guild.name})`);
log.info({ guildId: guild.id, guildName: guild.name }, "joined guild");
});
};
+6 -3
View File
@@ -1,10 +1,13 @@
import { Events, type Client } from "discord.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildDelete");
export const registerGuildDelete = (client: Client, memberMessageService: MemberMessageService): void => {
client.on(Events.GuildDelete, (guild) => {
console.log(`[event:guildDelete] left guild ${guild.id} (${guild.name})`);
log.info({ guildId: guild.id, guildName: guild.name }, "left guild");
const botId = memberMessageService.resolveBotId(client);
if (!botId) {
@@ -12,7 +15,7 @@ export const registerGuildDelete = (client: Client, memberMessageService: Member
}
void memberMessageService.cleanupGuild(botId, guild.id).catch((error) => {
console.error("[event:guildDelete] failed to cleanup guild config", error);
log.error({ guildId: guild.id, botId, err: error }, "failed to cleanup guild config");
});
});
};
+12 -2
View File
@@ -1,7 +1,10 @@
import { Events, type Client } from "discord.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberAdd");
export const registerGuildMemberAdd = (
client: Client,
@@ -16,7 +19,14 @@ export const registerGuildMemberAdd = (
user: member.user,
kind: "welcome",
}).catch((error) => {
console.error("[event:guildMemberAdd] failed to send welcome message", error);
log.error(
{
guildId: member.guild.id,
userId: member.user.id,
err: error,
},
"failed to send welcome message",
);
});
});
};
+12 -2
View File
@@ -1,7 +1,10 @@
import { Events, type Client } from "discord.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { I18nService } from "../i18n/index.js";
import type { MemberMessageService } from "../modules/memberMessages/index.js";
const log = createScopedLogger("event:guildMemberRemove");
export const registerGuildMemberRemove = (
client: Client,
@@ -16,7 +19,14 @@ export const registerGuildMemberRemove = (
user: member.user,
kind: "goodbye",
}).catch((error) => {
console.error("[event:guildMemberRemove] failed to send goodbye message", error);
log.error(
{
guildId: member.guild.id,
userId: member.user.id,
err: error,
},
"failed to send goodbye message",
);
});
});
};
+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 { CommandRegistry } from "../core/commands/registry.js";
import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js";
import type { I18nService } from "../i18n/index.js";
import { registerGuildCreate } from "./guildCreate.js";
import { registerGuildDelete } from "./guildDelete.js";
@@ -20,6 +21,7 @@ export const registerEvents = (
},
registry: CommandRegistry,
services: AppFeatureServices,
leaderCoordinator: LeaderCoordinator,
): void => {
registerMessageCreate(client, handlers.onPrefixMessage);
registerInteractionCreate(client, handlers.onSlashInteraction);
@@ -30,5 +32,5 @@ export const registerEvents = (
registerGuildCreate(client);
registerGuildDelete(client, services.memberMessageService);
registerClientReady(client, registry, i18n, services.presenceService);
registerClientReady(client, registry, i18n, services.presenceService, leaderCoordinator);
};
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client, type ChatInputCommandInteraction } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:interactionCreate");
/**
* Enregistre le listener `interactionCreate` pour les commandes slash (chat input).
@@ -16,7 +19,7 @@ export const registerInteractionCreate = (
}
void onSlashInteraction(interaction).catch((error) => {
console.error("[event:interactionCreate] handler failed", error);
log.error({ err: error }, "handler failed");
});
});
};
+4 -1
View File
@@ -1,4 +1,7 @@
import { Events, type Client, type Message } from "discord.js";
import { createScopedLogger } from "../core/logging/logger.js";
const log = createScopedLogger("event:messageCreate");
/**
* Enregistre le listener `messageCreate` en déléguant au handler fourni.
@@ -9,7 +12,7 @@ import { Events, type Client, type Message } from "discord.js";
export const registerMessageCreate = (client: Client, onPrefixMessage: (message: Message) => Promise<void>): void => {
client.on(Events.MessageCreate, (message: Message) => {
void onPrefixMessage(message).catch((error) => {
console.error("[event:messageCreate] handler failed", error);
log.error({ err: error }, "handler failed");
});
});
};
+36 -12
View File
@@ -2,36 +2,60 @@ import { Events, type Client } from "discord.js";
import { env } from "../config/env.js";
import { deployApplicationCommands } from "../core/commands/deploy.js";
import { createScopedLogger } from "../core/logging/logger.js";
import type { LeaderCoordinator } from "../core/runtime/leaderCoordinator.js";
import type { CommandRegistry } from "../core/commands/registry.js";
import type { PresenceService } from "../features/presence/service.js";
import type { I18nService } from "../i18n/index.js";
import type { PresenceService } from "../modules/presence/index.js";
const log = createScopedLogger("event:ready");
export const registerClientReady = (
client: Client,
registry: CommandRegistry,
i18n: I18nService,
presenceService: PresenceService,
leaderCoordinator: LeaderCoordinator,
): void => {
client.once(Events.ClientReady, async () => {
console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`);
log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
try {
await presenceService.restoreFromStorage(client);
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", async () => {
await presenceService.restoreFromStorage(client);
});
if (!restoredByLeader) {
log.info("presence restore skipped: leader lock already held by another instance");
}
} catch (error) {
console.error("[ready] failed to restore bot presence", error);
log.error({ err: error }, "failed to restore bot presence");
}
if (env.AUTO_DEPLOY_SLASH) {
try {
const result = await deployApplicationCommands({
token: env.DISCORD_TOKEN,
clientId: env.DISCORD_CLIENT_ID,
registry,
i18n,
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", async () => {
const result = await deployApplicationCommands({
token: env.DISCORD_TOKEN,
clientId: env.DISCORD_CLIENT_ID,
registry,
i18n,
...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}),
});
log.info(
{
scope: result.scope,
count: result.count,
},
"slash sync completed",
);
});
console.log(`[ready] slash sync done (${result.scope}, ${result.count} commands)`);
if (!deployedByLeader) {
log.info("slash sync skipped: leader lock already held by another instance");
}
} catch (error) {
console.error("[ready] slash sync failed", error);
log.error({ err: error }, "slash sync failed");
}
}
});
+4 -1
View File
@@ -12,6 +12,7 @@ import {
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import { createScopedLogger } from "../../core/logging/logger.js";
import type { I18nService } from "../../i18n/index.js";
import type { CommandExecutionContext } from "../../types/command.js";
import type {
@@ -28,6 +29,8 @@ import {
} from "../../validators/memberMessages.js";
import type { MemberMessageService } from "./service.js";
const log = createScopedLogger("command:memberMessagesPanel");
const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => {
@@ -334,7 +337,7 @@ export const createMemberMessagePanelExecute = (
flags: [MessageFlags.Ephemeral],
});
} catch (error) {
console.error(`[command:${kind}] interaction failed`, error);
log.error({ kind, err: error }, "interaction failed");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({
+4 -1
View File
@@ -26,6 +26,9 @@ import {
import { getPresenceTemplateHelpText } from "./templateVariables.js";
import type { PresenceService } from "./service.js";
import type { PresenceCustomIds } from "./types.js";
import { createScopedLogger } from "../../core/logging/logger.js";
const log = createScopedLogger("command:presence");
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
@@ -354,7 +357,7 @@ export const createPresenceCommandExecute = (presenceService: PresenceService) =
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) {
console.error("[command:presence] interaction failed", error);
log.error({ err: error }, "interaction failed");
const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) {
+51 -6
View File
@@ -1,10 +1,14 @@
import type {
CommandExecutionContext,
ExecutionContext,
I18nContext,
SupportedLang,
TransportContext,
TranslationVars,
} from "../types/command.js";
import type { BuildExecutionContextInput, HandlerExecutionDeps } from "../types/handlers.js";
import type { I18nService } from "../i18n/index.js";
import { createDiscordMemberPermissionsResolver } from "./discordPermissionResolver.js";
export const createTranslator = (
i18n: I18nService,
@@ -21,24 +25,65 @@ export const buildCommandExecutionContext = (
const ct = (relativeKey: string, vars?: TranslationVars): string =>
deps.i18n.commandT(input.lang, input.command.meta.name, relativeKey, vars);
return {
const execution: ExecutionContext = {
requestId: input.requestId,
receivedAt: Date.now(),
source: input.source,
commandName: input.command.meta.name,
commandCategory: input.command.meta.category,
args: input.args,
actor: {
userId: input.user.id,
guildId: input.guild?.id ?? null,
channelId: input.channel?.id ?? null,
},
};
const transport: TransportContext = {
kind: "discord",
client: input.client,
user: input.user,
guild: input.guild,
channel: input.channel,
raw: input.raw,
reply: input.reply,
resolveMemberPermissions: createDiscordMemberPermissionsResolver(input),
};
const i18nContext: I18nContext = {
lang: input.lang,
args: input.args,
command: input.command,
source: input.source,
t,
ct,
commandText: deps.i18n.commandObject(input.lang, input.command.meta.name),
format: (template, vars) => deps.i18n.format(template, vars),
i18n: deps.i18n,
raw: input.raw,
reply: input.reply,
registry: deps.registry,
prefix: deps.prefix,
defaultLang: deps.defaultLang,
};
return {
execution,
transport,
i18nContext,
client: transport.client,
user: transport.user,
guild: transport.guild,
channel: transport.channel,
lang: i18nContext.lang,
args: execution.args,
command: input.command,
source: execution.source,
t: i18nContext.t,
ct: i18nContext.ct,
commandText: i18nContext.commandText,
format: i18nContext.format,
i18n: i18nContext.i18n,
raw: transport.raw,
reply: transport.reply,
registry: i18nContext.registry,
prefix: i18nContext.prefix,
defaultLang: i18nContext.defaultLang,
};
};
+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 { BotCommand, SupportedLang } from "../types/command.js";
import type { PrefixHandlerDeps } from "../types/handlers.js";
@@ -66,9 +68,10 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
return;
}
await deps.executor.run(
await deps.dispatcher.dispatch(
command,
buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command,
source: "prefix",
lang,
+4 -1
View File
@@ -1,3 +1,5 @@
import { randomUUID } from "node:crypto";
import { MessageFlags, type ChatInputCommandInteraction } from "discord.js";
import type { SlashHandlerDeps } from "../types/handlers.js";
@@ -35,9 +37,10 @@ export const createSlashHandler = (deps: SlashHandlerDeps) => {
return;
}
await deps.executor.run(
await deps.dispatcher.dispatch(
command,
buildCommandExecutionContext(deps, {
requestId: randomUUID(),
command,
source: "slash",
lang,
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "You are missing permissions: {{permissions}}"
},
"execution": "An unexpected error happened while running this command.",
"cooldown": "Please wait {{seconds}}s before using this command again."
"cooldown": "Please wait {{seconds}}s before using this command again.",
"rateLimit": "Too many requests. Please wait {{seconds}}s before retrying.",
"executionQueued": "Command accepted and queued for processing."
},
"categories": {
"fun": "Fun",
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "Te faltan permisos: {{permissions}}"
},
"execution": "Ocurrio un error inesperado al ejecutar este comando.",
"cooldown": "Espera {{seconds}}s antes de volver a usar este comando."
"cooldown": "Espera {{seconds}}s antes de volver a usar este comando.",
"rateLimit": "Demasiadas solicitudes. Espera {{seconds}}s antes de reintentar.",
"executionQueued": "Comando aceptado y encolado para procesamiento."
},
"categories": {
"fun": "Diversion",
+3 -1
View File
@@ -14,7 +14,9 @@
"user": "Tu n as pas les permissions: {{permissions}}"
},
"execution": "Une erreur inattendue est survenue pendant l execution.",
"cooldown": "Patiente {{seconds}}s avant de reutiliser cette commande."
"cooldown": "Patiente {{seconds}}s avant de reutiliser cette commande.",
"rateLimit": "Trop de requetes. Patiente {{seconds}}s avant de reessayer.",
"executionQueued": "Commande acceptee et mise en file de traitement."
},
"categories": {
"fun": "Fun",
+4 -1
View File
@@ -1,6 +1,9 @@
import { bootstrap } from "./app/bootstrap.js";
import { createScopedLogger } from "./core/logging/logger.js";
const log = createScopedLogger("boot");
bootstrap().catch((error) => {
console.error("[boot] fatal error", error);
log.fatal({ err: error }, "fatal startup error");
process.exit(1);
});
+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,
MessageCreateOptions,
MessageReplyOptions,
PermissionsBitField,
PermissionResolvable,
Role,
TextBasedChannel,
@@ -72,7 +73,49 @@ export interface CommandI18nTools {
format: (template: string, vars?: TranslationVars) => string;
}
export interface ExecutionContext {
requestId: string;
receivedAt: number;
source: CommandSource;
commandName: string;
commandCategory: string;
args: Record<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 {
execution: ExecutionContext;
transport: TransportContext;
i18nContext: I18nContext;
// Backward-compatible aliases kept for existing command implementations.
client: Client;
user: User;
guild: Guild | null;
@@ -97,6 +140,7 @@ export interface BotCommandInput {
meta: CommandMeta;
args?: CommandArgument[];
permissions?: PermissionResolvable[];
sensitive?: boolean;
examples?: CommandExample[];
cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>;
@@ -106,6 +150,7 @@ export interface BotCommand {
meta: CommandMeta;
args: CommandArgument[];
permissions: PermissionResolvable[];
sensitive: boolean;
examples: CommandExample[];
cooldown?: number;
execute: (ctx: CommandExecutionContext) => Promise<void>;
+4 -3
View File
@@ -1,5 +1,5 @@
import type { CommandRegistry } from "../core/commands/registry.js";
import type { CommandExecutor } from "../core/execution/CommandExecutor.js";
import type { CommandDispatchPort } from "../core/execution/dispatch.js";
import type { I18nService } from "../i18n/index.js";
import type {
BotCommand,
@@ -16,6 +16,7 @@ export interface HandlerExecutionDeps {
}
export interface BuildExecutionContextInput {
requestId: string;
command: BotCommand;
source: CommandSource;
lang: SupportedLang;
@@ -29,9 +30,9 @@ export interface BuildExecutionContextInput {
}
export interface PrefixHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor;
dispatcher: CommandDispatchPort;
}
export interface SlashHandlerDeps extends HandlerExecutionDeps {
executor: CommandExecutor;
dispatcher: CommandDispatchPort;
}
+21
View File
@@ -35,5 +35,26 @@ test("defineCommand applique les valeurs par defaut", () => {
assert.deepEqual(command.args, []);
assert.deepEqual(command.permissions, []);
assert.deepEqual(command.examples, []);
assert.equal(command.sensitive, false);
assert.equal(command.cooldown, undefined);
});
test("defineCommand refuse une commande sensible sans permission", () => {
assert.throws(() => {
defineCommand({
meta: { name: "sensitiveNoPerm", category: "test" },
sensitive: true,
execute: async () => undefined,
});
});
});
test("defineCommand marque sensible automatiquement si permissions presentes", () => {
const command = defineCommand({
meta: { name: "secured", category: "test" },
permissions: ["ManageGuild"],
execute: async () => undefined,
});
assert.equal(command.sensitive, true);
});
+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);
});