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