diff --git a/.env.example b/.env.example index 652bfe2..cfd1a76 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ ALLOW_INSECURE_DB_SSL=false PRESENCE_STREAM_URL=https://twitch.tv/discord POSTGRES_DB=discord_bots POSTGRES_USER=discord_bot -POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD +POSTGRES_PASSWORD=CHANGE_ME_STRONG_POSTGRES_PASSWORD +REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD POSTGRES_PORT=5432 PREFIX=+ DEFAULT_LANG=en @@ -18,7 +19,8 @@ LOG_LEVEL=info STATE_BACKEND=memory REDIS_URL= COMMAND_DISPATCH_MODE=local -COMMAND_QUEUE_NAME=bot:command-jobs +COMMAND_QUEUE_NAME=bot:${botId}:command-jobs GLOBAL_RATE_LIMIT_MAX_REQUESTS=20 GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10 +RATE_LIMIT_FAIL_OPEN=false ENABLE_LEADER_ELECTION=true diff --git a/README.md b/README.md index 1851429..58ab6c6 100644 --- a/README.md +++ b/README.md @@ -57,42 +57,32 @@ Professional command framework template for Discord.js `14.26.2` with: - npm run test - npm run check -## Docker Deployment (Bot + PostgreSQL) +## Docker Deployment (2 Bots + PostgreSQL) -1. Configure `.env` with at least: - - `DISCORD_TOKEN` - - `DISCORD_CLIENT_ID` +1. Fill production env files: + - `.env.bot-alpha.prod` + - `.env.bot-beta.prod` +2. Set strong shared DB credentials: - `POSTGRES_DB` - `POSTGRES_USER` - `POSTGRES_PASSWORD` -2. Start stack: +3. Start stack: docker compose up -d --build -3. Stop stack: +4. Stop stack: docker compose down By default, `docker-compose.yml` provisions: -- `bot`: the Discord bot container +- `bot_alpha`: Discord bot instance A +- `bot_beta`: Discord bot instance B - `postgres`: PostgreSQL 16 with persistent volume `postgres_data` -The bot container uses: -- `DATABASE_URL=postgresql://:@postgres:5432/` +Both bots use the same PostgreSQL service, and data remains isolated per bot through `bot_id` keys. Security defaults: -- PostgreSQL is bound to `127.0.0.1` by default in Compose. -- Database TLS verification is strict by default when SSL is enabled. -- Keep strong values for `POSTGRES_PASSWORD` and rotate credentials if exposed. - -## Multi-Bot With One DB - -You can run several bot services against the same PostgreSQL instance. - -- Keep one shared `postgres` service. -- Add additional bot services with different `DISCORD_TOKEN` / `DISCORD_CLIENT_ID`. -- Keep `DATABASE_URL` pointing to the same PostgreSQL service. - -Because rows are keyed by `bot_id`, each bot keeps its own independent presence configuration. - -An example with two bot services is available in `docker-compose.multi-bot.example.yml`. +- PostgreSQL is exposed only on the internal Compose network (`expose: 5432`, no host bind). +- Bot containers enforce `no-new-privileges` and graceful stop. +- Log rotation is enabled (`max-size=10m`, `max-file=3`). +- Database TLS verification remains strict by default when SSL is enabled. ## Architecture diff --git a/docker-compose.multi-bot.example.yml b/docker-compose.multi-bot.example.yml deleted file mode 100644 index 2193f48..0000000 --- a/docker-compose.multi-bot.example.yml +++ /dev/null @@ -1,49 +0,0 @@ -services: - bot_alpha: - build: - context: . - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - env_file: - - .env.bot-alpha - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - DATABASE_SSL: ${DATABASE_SSL:-false} - - bot_beta: - build: - context: . - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - env_file: - - .env.bot-beta - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - DATABASE_SSL: ${DATABASE_SSL:-false} - - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - ports: - - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - -volumes: - postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 6c40312..90d0275 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,108 @@ +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +x-bot-common: &bot-common + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + init: true + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s + networks: + - edge_net + - backend_net + logging: *default-logging + services: - bot: - build: - context: . - dockerfile: Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy + bot_alpha: + <<: *bot-common env_file: - - .env + - .env.bot-alpha.prod environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - DATABASE_SSL: ${DATABASE_SSL:-false} + STATE_BACKEND: redis + COMMAND_DISPATCH_MODE: local + + bot_beta: + <<: *bot-common + env_file: + - .env.bot-beta.prod + environment: + STATE_BACKEND: redis + COMMAND_DISPATCH_MODE: local postgres: image: postgres:16-alpine restart: unless-stopped + init: true environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - ports: - - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + POSTGRES_DB: discord_bots + POSTGRES_USER: discord_bot + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 --auth-local=scram-sha-256 volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + security_opt: + - no-new-privileges:true + stop_grace_period: 30s + networks: + - backend_net + logging: *default-logging + + redis: + image: redis:7-alpine + restart: unless-stopped + init: true + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + command: + - sh + - -ec + - > + exec redis-server + --appendonly yes + --save 60 1000 + --requirepass "$${REDIS_PASSWORD}" + --maxmemory-policy noeviction + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"] interval: 10s timeout: 5s retries: 5 start_period: 10s + security_opt: + - no-new-privileges:true + stop_grace_period: 20s + networks: + - backend_net + logging: *default-logging volumes: postgres_data: + redis_data: + +networks: + edge_net: + driver: bridge + backend_net: + driver: bridge + internal: true diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts index 5a325ee..8015a32 100644 --- a/src/app/bootstrap.ts +++ b/src/app/bootstrap.ts @@ -8,8 +8,8 @@ import { env } from "../config/env.js"; import { CommandRegistry } from "../core/commands/registry.js"; import { CommandExecutor } from "../core/execution/CommandExecutor.js"; import { + type CommandDispatchMode, LocalCommandDispatchPort, - WorkerCommandDispatchPort, } from "../core/execution/dispatch.js"; import { MemoryCooldownStore, @@ -19,7 +19,6 @@ import { MemoryGlobalRateLimitStore, RedisGlobalRateLimitStore, } from "../core/execution/globalRateLimitStore.js"; -import { RedisCommandJobPublisher } from "../core/execution/redisCommandJobPublisher.js"; import { createScopedLogger } from "../core/logging/logger.js"; import { LocalLeaderCoordinator, @@ -67,6 +66,11 @@ const bindFatalProcessHandlers = ( }; export const bootstrap = async (): Promise => { + const dispatchMode: CommandDispatchMode = env.COMMAND_DISPATCH_MODE; + if (dispatchMode === "worker") { + throw new Error("Worker mode is not implemented and must not be used in production"); + } + const pool = new Pool({ connectionString: env.DATABASE_URL, ssl: env.DATABASE_SSL @@ -77,21 +81,32 @@ export const bootstrap = async (): Promise => { : undefined, }); - const requiresRedis = env.STATE_BACKEND === "redis" || env.COMMAND_DISPATCH_MODE === "worker"; + const requiresRedis = env.STATE_BACKEND === "redis"; 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"); + throw new Error("REDIS_URL is required when STATE_BACKEND=redis"); } - redis = new Redis(redisUrl, { - maxRetriesPerRequest: null, - enableReadyCheck: true, - }); + try { + redis = new Redis(redisUrl, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); - await redis.ping(); + await redis.ping(); + } catch (error) { + log.error( + { + err: error, + stateBackend: env.STATE_BACKEND, + }, + "redis unavailable at startup", + ); + throw error; + } } const presenceStore = new PostgresPresenceStore(pool); @@ -127,23 +142,11 @@ export const bootstrap = async (): Promise => { limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS, windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS, }, + rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN, 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 dispatcher = new LocalCommandDispatchPort(executor); const leaderCoordinator = env.ENABLE_LEADER_ELECTION ? new PostgresLeaderCoordinator(pool, "discordjs-framework-template") @@ -240,7 +243,9 @@ export const bootstrap = async (): Promise => { log.info( { stateBackend: env.STATE_BACKEND, - commandDispatchMode: env.COMMAND_DISPATCH_MODE, + commandDispatchMode: dispatchMode, + configuredCommandDispatchMode: env.COMMAND_DISPATCH_MODE, + rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN, rateLimit: { maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS, windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS, diff --git a/src/config/env.ts b/src/config/env.ts index e0bb23d..b1217bd 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -83,9 +83,14 @@ const envSchema = z.object({ 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"), + COMMAND_QUEUE_NAME: z.string().trim().min(1).default("bot:${botId}:command-jobs"), GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(20), GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10), + RATE_LIMIT_FAIL_OPEN: z + .string() + .optional() + .default("false") + .transform(toBoolean), ENABLE_LEADER_ELECTION: z .string() .optional() @@ -101,9 +106,9 @@ if (parsed.DATABASE_SSL && !parsed.DATABASE_SSL_REJECT_UNAUTHORIZED && !parsed.A ); } -if ((parsed.STATE_BACKEND === "redis" || parsed.COMMAND_DISPATCH_MODE === "worker") && !parsed.REDIS_URL) { +if (parsed.STATE_BACKEND === "redis" && !parsed.REDIS_URL) { throw new Error( - "REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker.", + "REDIS_URL is required when STATE_BACKEND=redis.", ); } diff --git a/src/core/execution/CommandExecutor.ts b/src/core/execution/CommandExecutor.ts index 4529c54..8f7b457 100644 --- a/src/core/execution/CommandExecutor.ts +++ b/src/core/execution/CommandExecutor.ts @@ -15,6 +15,7 @@ export interface CommandExecutorDeps { cooldownStore: CooldownStore; globalRateLimitStore: GlobalRateLimitStore; globalRateLimitPolicy: GlobalRateLimitPolicy; + rateLimitFailOpen: boolean; logger: AppLogger; } @@ -44,7 +45,7 @@ export class CommandExecutor { return; } - const remainingCooldownSeconds = await this.consumeCooldown(command, ctx.execution.actor.userId); + const remainingCooldownSeconds = await this.consumeCooldown(command, ctx); if (remainingCooldownSeconds > 0) { await ctx.reply(ctx.t("errors.cooldown", { seconds: remainingCooldownSeconds })); return; @@ -128,30 +129,60 @@ export class CommandExecutor { .trim(); } - private async consumeCooldown(command: BotCommand, userId: string): Promise { + private async consumeCooldown(command: BotCommand, ctx: CommandExecutionContext): Promise { if (command.cooldown === undefined || command.cooldown <= 0) { return 0; } + const botId = this.resolveBotId(ctx); + if (!botId) { + return this.deps.rateLimitFailOpen ? 0 : Math.max(1, command.cooldown); + } + + const userId = ctx.execution.actor.userId; + try { - const result = await this.deps.cooldownStore.consume(command.meta.name, userId, command.cooldown); + const result = await this.deps.cooldownStore.consume(botId, command.meta.name, userId, command.cooldown); return result.allowed ? 0 : result.retryAfterSeconds; } catch (error) { - this.deps.logger.warn( + if (this.deps.rateLimitFailOpen) { + this.deps.logger.error( + { + requestId: ctx.execution.requestId, + command: command.meta.name, + userId, + botId, + err: error, + }, + "cooldown store unavailable, fail-open enabled", + ); + return 0; + } + + this.deps.logger.error( { + requestId: ctx.execution.requestId, command: command.meta.name, userId, + botId, err: error, }, - "cooldown store unavailable, continuing without cooldown enforcement", + "cooldown store unavailable, fail-closed blocking command", ); - return 0; + + return Math.max(1, command.cooldown); } } private async consumeGlobalRateLimit(ctx: CommandExecutionContext): Promise { + const botId = this.resolveBotId(ctx); + if (!botId) { + return this.deps.rateLimitFailOpen ? 0 : Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds); + } + try { const result = await this.deps.globalRateLimitStore.consume( + botId, ctx.execution.actor.userId, this.deps.globalRateLimitPolicy, ); @@ -160,21 +191,63 @@ export class CommandExecutor { return 0; } - return result.retryAfterSeconds; - } catch (error) { this.deps.logger.warn( { requestId: ctx.execution.requestId, userId: ctx.execution.actor.userId, - err: error, + botId, + retryAfterSeconds: result.retryAfterSeconds, + remaining: result.remaining, + limit: result.limit, }, - "global rate limit store unavailable, continuing without rate limiting", + "global rate limit blocked command", ); - return 0; + return result.retryAfterSeconds; + } catch (error) { + if (this.deps.rateLimitFailOpen) { + this.deps.logger.error( + { + requestId: ctx.execution.requestId, + userId: ctx.execution.actor.userId, + botId, + err: error, + }, + "global rate limit store unavailable, fail-open enabled", + ); + return 0; + } + + this.deps.logger.error( + { + requestId: ctx.execution.requestId, + userId: ctx.execution.actor.userId, + botId, + err: error, + }, + "global rate limit store unavailable, fail-closed blocking command", + ); + + return Math.max(1, this.deps.globalRateLimitPolicy.windowSeconds); } } + private resolveBotId(ctx: CommandExecutionContext): string | null { + const botId = ctx.transport.client.user?.id; + if (!botId) { + this.deps.logger.error( + { + requestId: ctx.execution.requestId, + source: ctx.execution.source, + }, + "runtime bot id unavailable for scoped execution stores", + ); + return null; + } + + return botId; + } + private isSensitiveCommand(command: BotCommand): boolean { return command.sensitive || command.permissions.length > 0; } diff --git a/src/core/execution/cooldownStore.ts b/src/core/execution/cooldownStore.ts index 444c005..598516d 100644 --- a/src/core/execution/cooldownStore.ts +++ b/src/core/execution/cooldownStore.ts @@ -9,19 +9,24 @@ export interface CooldownConsumeResult { } export interface CooldownStore { - consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise; + consume(botId: string, commandName: string, subjectId: string, cooldownSeconds: number): Promise; } export class MemoryCooldownStore implements CooldownStore { private readonly cooldowns = new Map(); private lastSweepAt = 0; - public async consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise { + public async consume( + botId: string, + commandName: string, + subjectId: string, + cooldownSeconds: number, + ): Promise { if (cooldownSeconds <= 0) { return { allowed: true, retryAfterSeconds: 0 }; } - const key = this.key(commandName, subjectId); + const key = this.key(botId, commandName, subjectId); const now = Date.now(); this.sweepExpired(now); @@ -37,8 +42,8 @@ export class MemoryCooldownStore implements CooldownStore { return { allowed: true, retryAfterSeconds: 0 }; } - private key(commandName: string, subjectId: string): string { - return `${commandName}:${subjectId}`; + private key(botId: string, commandName: string, subjectId: string): string { + return `${botId}:${commandName}:${subjectId}`; } private sweepExpired(now: number): void { @@ -65,15 +70,20 @@ export class MemoryCooldownStore implements CooldownStore { export class RedisCooldownStore implements CooldownStore { public constructor( private readonly redis: Redis, - private readonly keyPrefix = "bot:cooldown", + private readonly keyPrefix = "bot", ) {} - public async consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise { + public async consume( + botId: string, + commandName: string, + subjectId: string, + cooldownSeconds: number, + ): Promise { if (cooldownSeconds <= 0) { return { allowed: true, retryAfterSeconds: 0 }; } - const key = this.key(commandName, subjectId); + const key = this.key(botId, commandName, subjectId); const result = await this.redis.set(key, "1", "EX", cooldownSeconds, "NX"); if (result === "OK") { return { @@ -82,14 +92,19 @@ export class RedisCooldownStore implements CooldownStore { }; } - const ttl = await this.redis.ttl(key); + let ttl = await this.redis.ttl(key); + if (ttl <= 0) { + await this.redis.expire(key, cooldownSeconds); + ttl = cooldownSeconds; + } + return { allowed: false, - retryAfterSeconds: ttl > 0 ? ttl : cooldownSeconds, + retryAfterSeconds: ttl, }; } - private key(commandName: string, subjectId: string): string { - return `${this.keyPrefix}:${commandName}:${subjectId}`; + private key(botId: string, commandName: string, subjectId: string): string { + return `${this.keyPrefix}:${botId}:cooldown:${commandName}:${subjectId}`; } } diff --git a/src/core/execution/dispatch.ts b/src/core/execution/dispatch.ts index 0416369..cfbe2aa 100644 --- a/src/core/execution/dispatch.ts +++ b/src/core/execution/dispatch.ts @@ -15,6 +15,7 @@ export interface CommandDispatchPort { export type CommandDispatchMode = "local" | "worker"; export interface CommandExecutionJob { + botId: string; requestId: string; commandName: string; source: CommandSource; @@ -65,11 +66,17 @@ export class WorkerCommandDispatchPort implements CommandDispatchPort { } const toExecutionJob = (command: BotCommand, ctx: CommandExecutionContext): CommandExecutionJob => { + const botId = ctx.transport.client.user?.id; + if (!botId) { + throw new Error("runtime bot id unavailable for worker command dispatch"); + } + const serializedArgs = Object.fromEntries( Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]), ); return { + botId, requestId: ctx.execution.requestId, commandName: command.meta.name, source: ctx.execution.source, diff --git a/src/core/execution/globalRateLimitStore.ts b/src/core/execution/globalRateLimitStore.ts index b2ecbb8..7338aa2 100644 --- a/src/core/execution/globalRateLimitStore.ts +++ b/src/core/execution/globalRateLimitStore.ts @@ -13,7 +13,7 @@ export interface GlobalRateLimitDecision { } export interface GlobalRateLimitStore { - consume(userId: string, policy: GlobalRateLimitPolicy): Promise; + consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise; } interface WindowCounterEntry { @@ -24,10 +24,10 @@ interface WindowCounterEntry { export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore { private readonly counters = new Map(); - public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise { + public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise { const sanitized = sanitizePolicy(policy); const now = Date.now(); - const key = this.key(userId, sanitized.windowSeconds); + const key = this.key(botId, userId, sanitized.windowSeconds); const existing = this.counters.get(key); if (!existing || existing.resetAt <= now) { @@ -58,28 +58,51 @@ export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore { }; } - private key(userId: string, windowSeconds: number): string { - return `${userId}:${windowSeconds}`; + private key(botId: string, userId: string, windowSeconds: number): string { + return `${botId}:${userId}:${windowSeconds}`; } } export class RedisGlobalRateLimitStore implements GlobalRateLimitStore { + private static readonly CONSUME_WINDOW_SCRIPT = ` + local key = KEYS[1] + local window = tonumber(ARGV[1]) + + if not window or window <= 0 then + return redis.error_reply("invalid window") + end + + local count = redis.call("INCR", key) + if count == 1 then + redis.call("EXPIRE", key, window) + return { count, window } + end + + local ttl = redis.call("TTL", key) + if ttl < 0 then + redis.call("EXPIRE", key, window) + ttl = window + end + + return { count, ttl } + `; + public constructor( private readonly redis: Redis, - private readonly keyPrefix = "bot:ratelimit:user", + private readonly keyPrefix = "bot", ) {} - public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise { + public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise { const sanitized = sanitizePolicy(policy); - const key = this.key(userId); + const key = this.key(botId, 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 rawResult = await this.redis.eval( + RedisGlobalRateLimitStore.CONSUME_WINDOW_SCRIPT, + 1, + key, + String(sanitized.windowSeconds), + ); + const { count, retryAfterSeconds } = parseScriptResult(rawResult, sanitized.windowSeconds); const allowed = count <= sanitized.limit; return { @@ -90,8 +113,8 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore { }; } - private key(userId: string): string { - return `${this.keyPrefix}:${userId}`; + private key(botId: string, userId: string): string { + return `${this.keyPrefix}:${botId}:ratelimit:user:${userId}`; } } @@ -106,3 +129,36 @@ const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy => windowSeconds, }; }; + +const parseScriptResult = (rawResult: unknown, fallbackWindowSeconds: number): { count: number; retryAfterSeconds: number } => { + if (!Array.isArray(rawResult) || rawResult.length < 2) { + throw new Error("Redis rate limit script returned an unexpected payload"); + } + + const count = toPositiveInt(rawResult[0]); + if (!count) { + throw new Error("Redis rate limit script returned an invalid counter value"); + } + + const ttl = toPositiveInt(rawResult[1]) ?? fallbackWindowSeconds; + + return { + count, + retryAfterSeconds: ttl, + }; +}; + +const toPositiveInt = (value: unknown): number | null => { + const numeric = typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : Number.NaN; + + if (!Number.isFinite(numeric)) { + return null; + } + + const integer = Math.floor(numeric); + return integer > 0 ? integer : null; +}; diff --git a/src/core/execution/redisCommandJobPublisher.ts b/src/core/execution/redisCommandJobPublisher.ts index cd71dd3..37ca7ca 100644 --- a/src/core/execution/redisCommandJobPublisher.ts +++ b/src/core/execution/redisCommandJobPublisher.ts @@ -5,10 +5,26 @@ import type { CommandExecutionJob, CommandJobPublisher } from "./dispatch.js"; export class RedisCommandJobPublisher implements CommandJobPublisher { public constructor( private readonly redis: Redis, - private readonly queueName = "bot:command-jobs", + private readonly queueName = "bot:${botId}:command-jobs", ) {} public async publish(job: CommandExecutionJob): Promise { - await this.redis.lpush(this.queueName, JSON.stringify(job)); + await this.redis.lpush(this.resolveQueueName(job.botId), JSON.stringify(job)); + } + + private resolveQueueName(botId: string): string { + if (this.queueName.includes("${botId}")) { + return this.queueName.replaceAll("${botId}", botId); + } + + if (this.queueName === "bot:command-jobs") { + return `bot:${botId}:command-jobs`; + } + + if (this.queueName.startsWith("bot:")) { + return this.queueName; + } + + return `bot:${botId}:${this.queueName}`; } } diff --git a/src/core/runtime/leaderCoordinator.ts b/src/core/runtime/leaderCoordinator.ts index 07a40af..4d94277 100644 --- a/src/core/runtime/leaderCoordinator.ts +++ b/src/core/runtime/leaderCoordinator.ts @@ -1,11 +1,11 @@ import type { Pool } from "pg"; export interface LeaderCoordinator { - runIfLeader(lockName: string, task: () => Promise): Promise; + runIfLeader(lockName: string, botId: string, task: () => Promise): Promise; } export class LocalLeaderCoordinator implements LeaderCoordinator { - public async runIfLeader(_lockName: string, task: () => Promise): Promise { + public async runIfLeader(_lockName: string, _botId: string, task: () => Promise): Promise { await task(); return true; } @@ -17,8 +17,8 @@ export class PostgresLeaderCoordinator implements LeaderCoordinator { private readonly namespace = "discord-bot", ) {} - public async runIfLeader(lockName: string, task: () => Promise): Promise { - const advisoryLockKey = hashToInt32(`${this.namespace}:${lockName}`); + public async runIfLeader(lockName: string, botId: string, task: () => Promise): Promise { + const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`); const client = await this.pool.connect(); try { diff --git a/src/events/ready.ts b/src/events/ready.ts index a734b31..9c77731 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -19,8 +19,15 @@ export const registerClientReady = ( ): void => { client.once(Events.ClientReady, async () => { log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready"); + + const botId = client.user?.id; + if (!botId) { + log.error("client ready event received without bot id, leader-only tasks skipped"); + return; + } + try { - const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", async () => { + const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", botId, async () => { await presenceService.restoreFromStorage(client); }); @@ -33,7 +40,7 @@ export const registerClientReady = ( if (env.AUTO_DEPLOY_SLASH) { try { - const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", async () => { + const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => { const result = await deployApplicationCommands({ token: env.DISCORD_TOKEN, clientId: env.DISCORD_CLIENT_ID, diff --git a/src/handlers/prefixHandler.ts b/src/handlers/prefixHandler.ts index 3e895f9..ced0752 100644 --- a/src/handlers/prefixHandler.ts +++ b/src/handlers/prefixHandler.ts @@ -12,6 +12,36 @@ import { } from "./commandExecutionContext.js"; import { createPrefixReply } from "./replyAdapter.js"; +const PREFIX_PRE_PARSE_THROTTLE_MS = 300; +const PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS = 60_000; +const PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS = 5 * 60_000; + +const prefixPreParseThrottle = new Map(); +let prefixPreParseThrottleLastSweepAt = 0; + +const shouldThrottlePrefixPreParse = (userId: string): boolean => { + const now = Date.now(); + const lastSeenAt = prefixPreParseThrottle.get(userId); + + if (lastSeenAt !== undefined && now - lastSeenAt < PREFIX_PRE_PARSE_THROTTLE_MS) { + return true; + } + + prefixPreParseThrottle.set(userId, now); + + if (now - prefixPreParseThrottleLastSweepAt >= PREFIX_PRE_PARSE_THROTTLE_SWEEP_INTERVAL_MS) { + for (const [trackedUserId, trackedAt] of prefixPreParseThrottle.entries()) { + if (now - trackedAt >= PREFIX_PRE_PARSE_THROTTLE_MAX_AGE_MS) { + prefixPreParseThrottle.delete(trackedUserId); + } + } + + prefixPreParseThrottleLastSweepAt = now; + } + + return false; +}; + const resolvePrefixLang = ( deps: PrefixHandlerDeps, command: BotCommand, @@ -50,6 +80,10 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => { return; } + if (shouldThrottlePrefixPreParse(message.author.id)) { + return; + } + const reply = createPrefixReply(message); const command = match.command; diff --git a/tests/executionStores.test.ts b/tests/executionStores.test.ts index 0362979..21778e7 100644 --- a/tests/executionStores.test.ts +++ b/tests/executionStores.test.ts @@ -7,15 +7,27 @@ import { MemoryGlobalRateLimitStore } from "../src/core/execution/globalRateLimi test("MemoryCooldownStore bloque les executions pendant la fenetre", async () => { const store = new MemoryCooldownStore(); - const first = await store.consume("ping", "user-1", 10); + const first = await store.consume("bot-1", "ping", "user-1", 10); assert.equal(first.allowed, true); assert.equal(first.retryAfterSeconds, 0); - const second = await store.consume("ping", "user-1", 10); + const second = await store.consume("bot-1", "ping", "user-1", 10); assert.equal(second.allowed, false); assert.ok(second.retryAfterSeconds > 0); }); +test("MemoryCooldownStore isole les cooldowns par bot", async () => { + const store = new MemoryCooldownStore(); + + const firstBot = await store.consume("bot-a", "ping", "user-1", 10); + const secondBot = await store.consume("bot-b", "ping", "user-1", 10); + const sameBotAgain = await store.consume("bot-a", "ping", "user-1", 10); + + assert.equal(firstBot.allowed, true); + assert.equal(secondBot.allowed, true); + assert.equal(sameBotAgain.allowed, false); +}); + test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async () => { const store = new MemoryGlobalRateLimitStore(); const policy = { @@ -23,15 +35,31 @@ test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async windowSeconds: 30, }; - const first = await store.consume("user-rl", policy); + const first = await store.consume("bot-1", "user-rl", policy); assert.equal(first.allowed, true); assert.equal(first.remaining, 1); - const second = await store.consume("user-rl", policy); + const second = await store.consume("bot-1", "user-rl", policy); assert.equal(second.allowed, true); assert.equal(second.remaining, 0); - const third = await store.consume("user-rl", policy); + const third = await store.consume("bot-1", "user-rl", policy); assert.equal(third.allowed, false); assert.ok(third.retryAfterSeconds > 0); }); + +test("MemoryGlobalRateLimitStore isole les compteurs par bot", async () => { + const store = new MemoryGlobalRateLimitStore(); + const policy = { + limit: 1, + windowSeconds: 30, + }; + + const botAFirst = await store.consume("bot-a", "user-rl", policy); + const botBFirst = await store.consume("bot-b", "user-rl", policy); + const botASecond = await store.consume("bot-a", "user-rl", policy); + + assert.equal(botAFirst.allowed, true); + assert.equal(botBFirst.allowed, true); + assert.equal(botASecond.allowed, false); +});