feat: améliorer la gestion des bots multiples avec des identifiants uniques et des limites de taux

This commit is contained in:
Puechberty Arthur
2026-04-15 23:51:18 +02:00
parent e5effd62fa
commit 8f9aa4d1bb
15 changed files with 434 additions and 172 deletions
+4 -2
View File
@@ -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
+14 -24
View File
@@ -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_USER>:<POSTGRES_PASSWORD>@postgres:5432/<POSTGRES_DB>`
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
-49
View File
@@ -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:
+90 -17
View File
@@ -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
+29 -24
View File
@@ -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<void> => {
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<void> => {
: 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<void> => {
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<void> => {
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,
+8 -3
View File
@@ -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.",
);
}
+84 -11
View File
@@ -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<number> {
private async consumeCooldown(command: BotCommand, ctx: CommandExecutionContext): Promise<number> {
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<number> {
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;
}
+27 -12
View File
@@ -9,19 +9,24 @@ export interface CooldownConsumeResult {
}
export interface CooldownStore {
consume(commandName: string, subjectId: string, cooldownSeconds: number): Promise<CooldownConsumeResult>;
consume(botId: string, 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> {
public async consume(
botId: string,
commandName: string,
subjectId: string,
cooldownSeconds: number,
): Promise<CooldownConsumeResult> {
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<CooldownConsumeResult> {
public async consume(
botId: string,
commandName: string,
subjectId: string,
cooldownSeconds: number,
): Promise<CooldownConsumeResult> {
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}`;
}
}
+7
View File
@@ -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,
+73 -17
View File
@@ -13,7 +13,7 @@ export interface GlobalRateLimitDecision {
}
export interface GlobalRateLimitStore {
consume(userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
}
interface WindowCounterEntry {
@@ -24,10 +24,10 @@ interface WindowCounterEntry {
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
private readonly counters = new Map<string, WindowCounterEntry>();
public async consume(userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
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<GlobalRateLimitDecision> {
public async consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision> {
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;
};
+18 -2
View File
@@ -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<void> {
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}`;
}
}
+4 -4
View File
@@ -1,11 +1,11 @@
import type { Pool } from "pg";
export interface LeaderCoordinator {
runIfLeader(lockName: string, task: () => Promise<void>): Promise<boolean>;
runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean>;
}
export class LocalLeaderCoordinator implements LeaderCoordinator {
public async runIfLeader(_lockName: string, task: () => Promise<void>): Promise<boolean> {
public async runIfLeader(_lockName: string, _botId: string, task: () => Promise<void>): Promise<boolean> {
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<void>): Promise<boolean> {
const advisoryLockKey = hashToInt32(`${this.namespace}:${lockName}`);
public async runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean> {
const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`);
const client = await this.pool.connect();
try {
+9 -2
View File
@@ -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,
+34
View File
@@ -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<string, number>();
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;
+33 -5
View File
@@ -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);
});