mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
feat: améliorer la gestion des bots multiples avec des identifiants uniques et des limites de taux
This commit is contained in:
+4
-2
@@ -8,7 +8,8 @@ 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
|
||||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
POSTGRES_PASSWORD=CHANGE_ME_STRONG_POSTGRES_PASSWORD
|
||||||
|
REDIS_PASSWORD=CHANGE_ME_STRONG_REDIS_PASSWORD
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
PREFIX=+
|
PREFIX=+
|
||||||
DEFAULT_LANG=en
|
DEFAULT_LANG=en
|
||||||
@@ -18,7 +19,8 @@ LOG_LEVEL=info
|
|||||||
STATE_BACKEND=memory
|
STATE_BACKEND=memory
|
||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
COMMAND_DISPATCH_MODE=local
|
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_MAX_REQUESTS=20
|
||||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10
|
GLOBAL_RATE_LIMIT_WINDOW_SECONDS=10
|
||||||
|
RATE_LIMIT_FAIL_OPEN=false
|
||||||
ENABLE_LEADER_ELECTION=true
|
ENABLE_LEADER_ELECTION=true
|
||||||
|
|||||||
@@ -57,42 +57,32 @@ Professional command framework template for Discord.js `14.26.2` with:
|
|||||||
- npm run test
|
- npm run test
|
||||||
- npm run check
|
- npm run check
|
||||||
|
|
||||||
## Docker Deployment (Bot + PostgreSQL)
|
## Docker Deployment (2 Bots + PostgreSQL)
|
||||||
|
|
||||||
1. Configure `.env` with at least:
|
1. Fill production env files:
|
||||||
- `DISCORD_TOKEN`
|
- `.env.bot-alpha.prod`
|
||||||
- `DISCORD_CLIENT_ID`
|
- `.env.bot-beta.prod`
|
||||||
|
2. Set strong shared DB credentials:
|
||||||
- `POSTGRES_DB`
|
- `POSTGRES_DB`
|
||||||
- `POSTGRES_USER`
|
- `POSTGRES_USER`
|
||||||
- `POSTGRES_PASSWORD`
|
- `POSTGRES_PASSWORD`
|
||||||
2. Start stack:
|
3. Start stack:
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
3. Stop stack:
|
4. Stop stack:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
By default, `docker-compose.yml` provisions:
|
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`
|
- `postgres`: PostgreSQL 16 with persistent volume `postgres_data`
|
||||||
|
|
||||||
The bot container uses:
|
Both bots use the same PostgreSQL service, and data remains isolated per bot through `bot_id` keys.
|
||||||
- `DATABASE_URL=postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@postgres:5432/<POSTGRES_DB>`
|
|
||||||
|
|
||||||
Security defaults:
|
Security defaults:
|
||||||
- PostgreSQL is bound to `127.0.0.1` by default in Compose.
|
- PostgreSQL is exposed only on the internal Compose network (`expose: 5432`, no host bind).
|
||||||
- Database TLS verification is strict by default when SSL is enabled.
|
- Bot containers enforce `no-new-privileges` and graceful stop.
|
||||||
- Keep strong values for `POSTGRES_PASSWORD` and rotate credentials if exposed.
|
- Log rotation is enabled (`max-size=10m`, `max-file=3`).
|
||||||
|
- Database TLS verification remains strict by default when SSL is enabled.
|
||||||
## 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`.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
services:
|
||||||
bot:
|
bot_alpha:
|
||||||
build:
|
<<: *bot-common
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.bot-alpha.prod
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
STATE_BACKEND: redis
|
||||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
COMMAND_DISPATCH_MODE: local
|
||||||
|
|
||||||
|
bot_beta:
|
||||||
|
<<: *bot-common
|
||||||
|
env_file:
|
||||||
|
- .env.bot-beta.prod
|
||||||
|
environment:
|
||||||
|
STATE_BACKEND: redis
|
||||||
|
COMMAND_DISPATCH_MODE: local
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: discord_bots
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: discord_bot
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
ports:
|
POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 --auth-local=scram-sha-256
|
||||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
stop_grace_period: 20s
|
||||||
|
networks:
|
||||||
|
- backend_net
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge_net:
|
||||||
|
driver: bridge
|
||||||
|
backend_net:
|
||||||
|
driver: bridge
|
||||||
|
internal: true
|
||||||
|
|||||||
+29
-24
@@ -8,8 +8,8 @@ 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 {
|
import {
|
||||||
|
type CommandDispatchMode,
|
||||||
LocalCommandDispatchPort,
|
LocalCommandDispatchPort,
|
||||||
WorkerCommandDispatchPort,
|
|
||||||
} from "../core/execution/dispatch.js";
|
} from "../core/execution/dispatch.js";
|
||||||
import {
|
import {
|
||||||
MemoryCooldownStore,
|
MemoryCooldownStore,
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
MemoryGlobalRateLimitStore,
|
MemoryGlobalRateLimitStore,
|
||||||
RedisGlobalRateLimitStore,
|
RedisGlobalRateLimitStore,
|
||||||
} from "../core/execution/globalRateLimitStore.js";
|
} from "../core/execution/globalRateLimitStore.js";
|
||||||
import { RedisCommandJobPublisher } from "../core/execution/redisCommandJobPublisher.js";
|
|
||||||
import { createScopedLogger } from "../core/logging/logger.js";
|
import { createScopedLogger } from "../core/logging/logger.js";
|
||||||
import {
|
import {
|
||||||
LocalLeaderCoordinator,
|
LocalLeaderCoordinator,
|
||||||
@@ -67,6 +66,11 @@ const bindFatalProcessHandlers = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const bootstrap = async (): Promise<void> => {
|
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({
|
const pool = new Pool({
|
||||||
connectionString: env.DATABASE_URL,
|
connectionString: env.DATABASE_URL,
|
||||||
ssl: env.DATABASE_SSL
|
ssl: env.DATABASE_SSL
|
||||||
@@ -77,21 +81,32 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requiresRedis = env.STATE_BACKEND === "redis" || env.COMMAND_DISPATCH_MODE === "worker";
|
const requiresRedis = env.STATE_BACKEND === "redis";
|
||||||
let redis: Redis | null = null;
|
let redis: Redis | null = null;
|
||||||
|
|
||||||
if (requiresRedis) {
|
if (requiresRedis) {
|
||||||
const redisUrl = env.REDIS_URL;
|
const redisUrl = env.REDIS_URL;
|
||||||
if (!redisUrl) {
|
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, {
|
try {
|
||||||
maxRetriesPerRequest: null,
|
redis = new Redis(redisUrl, {
|
||||||
enableReadyCheck: true,
|
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);
|
const presenceStore = new PostgresPresenceStore(pool);
|
||||||
@@ -127,23 +142,11 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
limit: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
||||||
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
||||||
},
|
},
|
||||||
|
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
|
||||||
logger: createScopedLogger("command-executor"),
|
logger: createScopedLogger("command-executor"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatcher = (() => {
|
const dispatcher = new LocalCommandDispatchPort(executor);
|
||||||
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
|
const leaderCoordinator = env.ENABLE_LEADER_ELECTION
|
||||||
? new PostgresLeaderCoordinator(pool, "discordjs-framework-template")
|
? new PostgresLeaderCoordinator(pool, "discordjs-framework-template")
|
||||||
@@ -240,7 +243,9 @@ export const bootstrap = async (): Promise<void> => {
|
|||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
stateBackend: env.STATE_BACKEND,
|
stateBackend: env.STATE_BACKEND,
|
||||||
commandDispatchMode: env.COMMAND_DISPATCH_MODE,
|
commandDispatchMode: dispatchMode,
|
||||||
|
configuredCommandDispatchMode: env.COMMAND_DISPATCH_MODE,
|
||||||
|
rateLimitFailOpen: env.RATE_LIMIT_FAIL_OPEN,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
maxRequests: env.GLOBAL_RATE_LIMIT_MAX_REQUESTS,
|
||||||
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
windowSeconds: env.GLOBAL_RATE_LIMIT_WINDOW_SECONDS,
|
||||||
|
|||||||
+8
-3
@@ -83,9 +83,14 @@ const envSchema = z.object({
|
|||||||
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
STATE_BACKEND: z.enum(["memory", "redis"]).default("memory"),
|
||||||
REDIS_URL: z.preprocess(parseOptionalUrl, z.string().url("REDIS_URL must be a valid URL").optional()),
|
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_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_MAX_REQUESTS: z.coerce.number().int().positive().default(20),
|
||||||
GLOBAL_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().positive().default(10),
|
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
|
ENABLE_LEADER_ELECTION: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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(
|
throw new Error(
|
||||||
"REDIS_URL is required when STATE_BACKEND=redis or COMMAND_DISPATCH_MODE=worker.",
|
"REDIS_URL is required when STATE_BACKEND=redis.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface CommandExecutorDeps {
|
|||||||
cooldownStore: CooldownStore;
|
cooldownStore: CooldownStore;
|
||||||
globalRateLimitStore: GlobalRateLimitStore;
|
globalRateLimitStore: GlobalRateLimitStore;
|
||||||
globalRateLimitPolicy: GlobalRateLimitPolicy;
|
globalRateLimitPolicy: GlobalRateLimitPolicy;
|
||||||
|
rateLimitFailOpen: boolean;
|
||||||
logger: AppLogger;
|
logger: AppLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export class CommandExecutor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx.execution.actor.userId);
|
const remainingCooldownSeconds = await this.consumeCooldown(command, ctx);
|
||||||
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;
|
||||||
@@ -128,30 +129,60 @@ export class CommandExecutor {
|
|||||||
.trim();
|
.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) {
|
if (command.cooldown === undefined || command.cooldown <= 0) {
|
||||||
return 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 {
|
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;
|
return result.allowed ? 0 : result.retryAfterSeconds;
|
||||||
} catch (error) {
|
} 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,
|
command: command.meta.name,
|
||||||
userId,
|
userId,
|
||||||
|
botId,
|
||||||
err: error,
|
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> {
|
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 {
|
try {
|
||||||
const result = await this.deps.globalRateLimitStore.consume(
|
const result = await this.deps.globalRateLimitStore.consume(
|
||||||
|
botId,
|
||||||
ctx.execution.actor.userId,
|
ctx.execution.actor.userId,
|
||||||
this.deps.globalRateLimitPolicy,
|
this.deps.globalRateLimitPolicy,
|
||||||
);
|
);
|
||||||
@@ -160,21 +191,63 @@ export class CommandExecutor {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.retryAfterSeconds;
|
|
||||||
} catch (error) {
|
|
||||||
this.deps.logger.warn(
|
this.deps.logger.warn(
|
||||||
{
|
{
|
||||||
requestId: ctx.execution.requestId,
|
requestId: ctx.execution.requestId,
|
||||||
userId: ctx.execution.actor.userId,
|
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 {
|
private isSensitiveCommand(command: BotCommand): boolean {
|
||||||
return command.sensitive || command.permissions.length > 0;
|
return command.sensitive || command.permissions.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,24 @@ export interface CooldownConsumeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CooldownStore {
|
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 {
|
export class MemoryCooldownStore implements CooldownStore {
|
||||||
private readonly cooldowns = new Map<string, number>();
|
private readonly cooldowns = new Map<string, number>();
|
||||||
private lastSweepAt = 0;
|
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) {
|
if (cooldownSeconds <= 0) {
|
||||||
return { allowed: true, retryAfterSeconds: 0 };
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = this.key(commandName, subjectId);
|
const key = this.key(botId, commandName, subjectId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.sweepExpired(now);
|
this.sweepExpired(now);
|
||||||
|
|
||||||
@@ -37,8 +42,8 @@ export class MemoryCooldownStore implements CooldownStore {
|
|||||||
return { allowed: true, retryAfterSeconds: 0 };
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(commandName: string, subjectId: string): string {
|
private key(botId: string, commandName: string, subjectId: string): string {
|
||||||
return `${commandName}:${subjectId}`;
|
return `${botId}:${commandName}:${subjectId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sweepExpired(now: number): void {
|
private sweepExpired(now: number): void {
|
||||||
@@ -65,15 +70,20 @@ export class MemoryCooldownStore implements CooldownStore {
|
|||||||
export class RedisCooldownStore implements CooldownStore {
|
export class RedisCooldownStore implements CooldownStore {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly redis: Redis,
|
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) {
|
if (cooldownSeconds <= 0) {
|
||||||
return { allowed: true, retryAfterSeconds: 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");
|
const result = await this.redis.set(key, "1", "EX", cooldownSeconds, "NX");
|
||||||
if (result === "OK") {
|
if (result === "OK") {
|
||||||
return {
|
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 {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
retryAfterSeconds: ttl > 0 ? ttl : cooldownSeconds,
|
retryAfterSeconds: ttl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(commandName: string, subjectId: string): string {
|
private key(botId: string, commandName: string, subjectId: string): string {
|
||||||
return `${this.keyPrefix}:${commandName}:${subjectId}`;
|
return `${this.keyPrefix}:${botId}:cooldown:${commandName}:${subjectId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface CommandDispatchPort {
|
|||||||
export type CommandDispatchMode = "local" | "worker";
|
export type CommandDispatchMode = "local" | "worker";
|
||||||
|
|
||||||
export interface CommandExecutionJob {
|
export interface CommandExecutionJob {
|
||||||
|
botId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
commandName: string;
|
commandName: string;
|
||||||
source: CommandSource;
|
source: CommandSource;
|
||||||
@@ -65,11 +66,17 @@ export class WorkerCommandDispatchPort implements CommandDispatchPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toExecutionJob = (command: BotCommand, ctx: CommandExecutionContext): CommandExecutionJob => {
|
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(
|
const serializedArgs = Object.fromEntries(
|
||||||
Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]),
|
Object.entries(ctx.execution.args).map(([key, value]) => [key, serializeArgValue(value)]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
botId,
|
||||||
requestId: ctx.execution.requestId,
|
requestId: ctx.execution.requestId,
|
||||||
commandName: command.meta.name,
|
commandName: command.meta.name,
|
||||||
source: ctx.execution.source,
|
source: ctx.execution.source,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface GlobalRateLimitDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalRateLimitStore {
|
export interface GlobalRateLimitStore {
|
||||||
consume(userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
|
consume(botId: string, userId: string, policy: GlobalRateLimitPolicy): Promise<GlobalRateLimitDecision>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowCounterEntry {
|
interface WindowCounterEntry {
|
||||||
@@ -24,10 +24,10 @@ interface WindowCounterEntry {
|
|||||||
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
||||||
private readonly counters = new Map<string, WindowCounterEntry>();
|
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 sanitized = sanitizePolicy(policy);
|
||||||
const now = Date.now();
|
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);
|
const existing = this.counters.get(key);
|
||||||
|
|
||||||
if (!existing || existing.resetAt <= now) {
|
if (!existing || existing.resetAt <= now) {
|
||||||
@@ -58,28 +58,51 @@ export class MemoryGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(userId: string, windowSeconds: number): string {
|
private key(botId: string, userId: string, windowSeconds: number): string {
|
||||||
return `${userId}:${windowSeconds}`;
|
return `${botId}:${userId}:${windowSeconds}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
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(
|
public constructor(
|
||||||
private readonly redis: Redis,
|
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 sanitized = sanitizePolicy(policy);
|
||||||
const key = this.key(userId);
|
const key = this.key(botId, userId);
|
||||||
|
|
||||||
const count = await this.redis.incr(key);
|
const rawResult = await this.redis.eval(
|
||||||
if (count === 1) {
|
RedisGlobalRateLimitStore.CONSUME_WINDOW_SCRIPT,
|
||||||
await this.redis.expire(key, sanitized.windowSeconds);
|
1,
|
||||||
}
|
key,
|
||||||
|
String(sanitized.windowSeconds),
|
||||||
const ttl = await this.redis.ttl(key);
|
);
|
||||||
const retryAfterSeconds = ttl > 0 ? ttl : sanitized.windowSeconds;
|
const { count, retryAfterSeconds } = parseScriptResult(rawResult, sanitized.windowSeconds);
|
||||||
const allowed = count <= sanitized.limit;
|
const allowed = count <= sanitized.limit;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -90,8 +113,8 @@ export class RedisGlobalRateLimitStore implements GlobalRateLimitStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private key(userId: string): string {
|
private key(botId: string, userId: string): string {
|
||||||
return `${this.keyPrefix}:${userId}`;
|
return `${this.keyPrefix}:${botId}:ratelimit:user:${userId}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,3 +129,36 @@ const sanitizePolicy = (policy: GlobalRateLimitPolicy): GlobalRateLimitPolicy =>
|
|||||||
windowSeconds,
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,10 +5,26 @@ import type { CommandExecutionJob, CommandJobPublisher } from "./dispatch.js";
|
|||||||
export class RedisCommandJobPublisher implements CommandJobPublisher {
|
export class RedisCommandJobPublisher implements CommandJobPublisher {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly redis: Redis,
|
private readonly redis: Redis,
|
||||||
private readonly queueName = "bot:command-jobs",
|
private readonly queueName = "bot:${botId}:command-jobs",
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async publish(job: CommandExecutionJob): Promise<void> {
|
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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
export interface LeaderCoordinator {
|
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 {
|
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();
|
await task();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ export class PostgresLeaderCoordinator implements LeaderCoordinator {
|
|||||||
private readonly namespace = "discord-bot",
|
private readonly namespace = "discord-bot",
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async runIfLeader(lockName: string, task: () => Promise<void>): Promise<boolean> {
|
public async runIfLeader(lockName: string, botId: string, task: () => Promise<void>): Promise<boolean> {
|
||||||
const advisoryLockKey = hashToInt32(`${this.namespace}:${lockName}`);
|
const advisoryLockKey = hashToInt32(`${this.namespace}:bot:${botId}:leader:${lockName}`);
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+9
-2
@@ -19,8 +19,15 @@ export const registerClientReady = (
|
|||||||
): void => {
|
): void => {
|
||||||
client.once(Events.ClientReady, async () => {
|
client.once(Events.ClientReady, async () => {
|
||||||
log.info({ botTag: client.user?.tag ?? "unknown" }, "client ready");
|
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 {
|
try {
|
||||||
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", async () => {
|
const restoredByLeader = await leaderCoordinator.runIfLeader("presence-restore", botId, async () => {
|
||||||
await presenceService.restoreFromStorage(client);
|
await presenceService.restoreFromStorage(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +40,7 @@ export const registerClientReady = (
|
|||||||
|
|
||||||
if (env.AUTO_DEPLOY_SLASH) {
|
if (env.AUTO_DEPLOY_SLASH) {
|
||||||
try {
|
try {
|
||||||
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", async () => {
|
const deployedByLeader = await leaderCoordinator.runIfLeader("slash-deploy", botId, async () => {
|
||||||
const result = await deployApplicationCommands({
|
const result = await deployApplicationCommands({
|
||||||
token: env.DISCORD_TOKEN,
|
token: env.DISCORD_TOKEN,
|
||||||
clientId: env.DISCORD_CLIENT_ID,
|
clientId: env.DISCORD_CLIENT_ID,
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ import {
|
|||||||
} from "./commandExecutionContext.js";
|
} from "./commandExecutionContext.js";
|
||||||
import { createPrefixReply } from "./replyAdapter.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 = (
|
const resolvePrefixLang = (
|
||||||
deps: PrefixHandlerDeps,
|
deps: PrefixHandlerDeps,
|
||||||
command: BotCommand,
|
command: BotCommand,
|
||||||
@@ -50,6 +80,10 @@ export const createPrefixHandler = (deps: PrefixHandlerDeps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldThrottlePrefixPreParse(message.author.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reply = createPrefixReply(message);
|
const reply = createPrefixReply(message);
|
||||||
|
|
||||||
const command = match.command;
|
const command = match.command;
|
||||||
|
|||||||
@@ -7,15 +7,27 @@ import { MemoryGlobalRateLimitStore } from "../src/core/execution/globalRateLimi
|
|||||||
test("MemoryCooldownStore bloque les executions pendant la fenetre", async () => {
|
test("MemoryCooldownStore bloque les executions pendant la fenetre", async () => {
|
||||||
const store = new MemoryCooldownStore();
|
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.allowed, true);
|
||||||
assert.equal(first.retryAfterSeconds, 0);
|
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.equal(second.allowed, false);
|
||||||
assert.ok(second.retryAfterSeconds > 0);
|
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 () => {
|
test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async () => {
|
||||||
const store = new MemoryGlobalRateLimitStore();
|
const store = new MemoryGlobalRateLimitStore();
|
||||||
const policy = {
|
const policy = {
|
||||||
@@ -23,15 +35,31 @@ test("MemoryGlobalRateLimitStore applique la limite globale utilisateur", async
|
|||||||
windowSeconds: 30,
|
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.allowed, true);
|
||||||
assert.equal(first.remaining, 1);
|
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.allowed, true);
|
||||||
assert.equal(second.remaining, 0);
|
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.equal(third.allowed, false);
|
||||||
assert.ok(third.retryAfterSeconds > 0);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user