From f6a2e2b9705bcec61230e84502e933f4ddd7cbec Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 16:07:27 +0200 Subject: [PATCH] feat: update environment configuration and enhance presence management - Updated .env.example to use more secure database credentials. - Modified Dockerfile to remove unnecessary data copy. - Enhanced README with additional setup instructions and security notes. - Updated docker-compose files to improve database connection handling and security. - Added error messages for invalid channel and role resolutions in locales. - Introduced new scripts for code quality checks in package.json. - Refactored presence management logic to improve state handling and timer management. - Added tests for argument tokenizer, command definition, presence types, and template variables. --- .env.example | 6 +- Dockerfile | 2 - README.md | 15 +- docker-compose.multi-bot.example.yml | 18 +- docker-compose.yml | 14 +- locales/en.json | 2 + locales/es.json | 2 + locales/fr.json | 2 + package.json | 3 + src/commands/utility/presence.ts | 231 ++++++++++++++------- src/framework/commands/argParser.ts | 90 +++++++- src/framework/execution/CommandExecutor.ts | 30 +++ src/framework/handlers/replyAdapter.ts | 61 +++++- src/framework/i18n/I18nService.ts | 8 +- src/index.ts | 24 ++- src/tests/argTokenizer.test.ts | 19 ++ src/tests/defineCommand.test.ts | 39 ++++ src/tests/presenceTypes.test.ts | 54 +++++ src/tests/templateVariables.test.ts | 47 +++++ 19 files changed, 545 insertions(+), 122 deletions(-) create mode 100644 src/tests/argTokenizer.test.ts create mode 100644 src/tests/defineCommand.test.ts create mode 100644 src/tests/presenceTypes.test.ts create mode 100644 src/tests/templateVariables.test.ts diff --git a/.env.example b/.env.example index 9e22ac3..33c91ce 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ DISCORD_TOKEN= DISCORD_CLIENT_ID= -DATABASE_URL=postgresql://discord:discord@localhost:5432/discord_bots +DATABASE_URL=postgresql://discord_bot:CHANGE_ME_STRONG_PASSWORD@localhost:5432/discord_bots DATABASE_SSL=false PRESENCE_STREAM_URL=https://twitch.tv/discord POSTGRES_DB=discord_bots -POSTGRES_USER=discord -POSTGRES_PASSWORD=discord +POSTGRES_USER=discord_bot +POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD POSTGRES_PORT=5432 PREFIX=+ DEFAULT_LANG=en diff --git a/Dockerfile b/Dockerfile index 2e49864..2e158ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,6 @@ RUN npm ci COPY tsconfig.json ./ COPY src ./src COPY locales ./locales -COPY data ./data RUN npm run build @@ -22,6 +21,5 @@ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY locales ./locales -COPY data ./data CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index 23a2652..0491d95 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,19 @@ Professional command framework template for Discord.js `14.26.2` with: npm run deploy:commands 6. Start in development: npm run dev +7. Validate code quality: + - npm run typecheck + - npm run test + - npm run check ## Docker Deployment (Bot + PostgreSQL) -1. Configure `.env` (at minimum `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`). +1. Configure `.env` with at least: + - `DISCORD_TOKEN` + - `DISCORD_CLIENT_ID` + - `POSTGRES_DB` + - `POSTGRES_USER` + - `POSTGRES_PASSWORD` 2. Start stack: docker compose up -d --build 3. Stop stack: @@ -52,6 +61,10 @@ By default, `docker-compose.yml` provisions: The bot container uses: - `DATABASE_URL=postgresql://:@postgres:5432/` +Security defaults: +- PostgreSQL is bound to `127.0.0.1` by default in Compose. +- 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. diff --git a/docker-compose.multi-bot.example.yml b/docker-compose.multi-bot.example.yml index 633712d..2193f48 100644 --- a/docker-compose.multi-bot.example.yml +++ b/docker-compose.multi-bot.example.yml @@ -10,8 +10,8 @@ services: env_file: - .env.bot-alpha environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} - DATABASE_SSL: "false" + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + DATABASE_SSL: ${DATABASE_SSL:-false} bot_beta: build: @@ -24,22 +24,22 @@ services: env_file: - .env.bot-beta environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} - DATABASE_SSL: "false" + 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:-discord_bots} - POSTGRES_USER: ${POSTGRES_USER:-discord} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-discord} -d ${POSTGRES_DB:-discord_bots}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 0b90680..6c40312 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,22 +10,22 @@ services: env_file: - .env environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} - DATABASE_SSL: "false" + 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:-discord_bots} - POSTGRES_USER: ${POSTGRES_USER:-discord} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "${POSTGRES_PORT:-5432}:5432" + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-discord} -d ${POSTGRES_DB:-discord_bots}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 diff --git a/locales/en.json b/locales/en.json index 483a890..0eb3e9c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -6,6 +6,8 @@ "invalidNumber": "Argument must be a number. Received: {{value}}", "invalidBoolean": "Argument must be a boolean value. Received: {{value}}", "invalidUser": "Could not resolve a user from {{value}}", + "invalidChannel": "Could not resolve a channel from {{value}}", + "invalidRole": "Could not resolve a role from {{value}}", "tooMany": "Too many arguments detected ({{count}}): {{extras}}. Usage: {{usage}}" }, "permissions": { diff --git a/locales/es.json b/locales/es.json index 3e48958..0a353c0 100644 --- a/locales/es.json +++ b/locales/es.json @@ -6,6 +6,8 @@ "invalidNumber": "El argumento debe ser un numero. Recibido: {{value}}", "invalidBoolean": "El argumento debe ser un valor booleano. Recibido: {{value}}", "invalidUser": "No se pudo resolver un usuario desde {{value}}", + "invalidChannel": "No se pudo resolver un canal desde {{value}}", + "invalidRole": "No se pudo resolver un rol desde {{value}}", "tooMany": "Se detectaron demasiados argumentos ({{count}}): {{extras}}. Uso: {{usage}}" }, "permissions": { diff --git a/locales/fr.json b/locales/fr.json index d0ca897..77a4672 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -6,6 +6,8 @@ "invalidNumber": "L argument doit etre un nombre. Recu: {{value}}", "invalidBoolean": "L argument doit etre un booleen. Recu: {{value}}", "invalidUser": "Impossible de trouver un utilisateur depuis {{value}}", + "invalidChannel": "Impossible de trouver un salon depuis {{value}}", + "invalidRole": "Impossible de trouver un role depuis {{value}}", "tooMany": "Trop d arguments ({{count}}) detectes: {{extras}}. Usage: {{usage}}" }, "permissions": { diff --git a/package.json b/package.json index a6745d9..bb95b08 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --import tsx --test src/**/*.test.ts", + "check": "npm run typecheck && npm run test", "start": "node dist/index.js", "deploy:commands": "tsx src/scripts/deployCommands.ts" }, diff --git a/src/commands/utility/presence.ts b/src/commands/utility/presence.ts index cd89ecf..2a6fea1 100644 --- a/src/commands/utility/presence.ts +++ b/src/commands/utility/presence.ts @@ -51,9 +51,63 @@ interface PresenceCustomIds { intervalInput: string; } -let dynamicPresenceRefreshTimer: NodeJS.Timeout | null = null; -let presenceRotationTimer: NodeJS.Timeout | null = null; -let activePresenceTextIndex = 0; +interface PresencePanelSession { + collector: ReturnType; + disable: () => Promise; +} + +interface PresenceRuntimeState { + dynamicPresenceRefreshTimer: NodeJS.Timeout | null; + presenceRotationTimer: NodeJS.Timeout | null; + activePresenceTextIndex: number; + activePanelsByUserId: Map; +} + +const presenceRuntimeByBotId = new Map(); + +const createPresenceRuntimeState = (): PresenceRuntimeState => ({ + dynamicPresenceRefreshTimer: null, + presenceRotationTimer: null, + activePresenceTextIndex: 0, + activePanelsByUserId: new Map(), +}); + +const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => { + if (runtimeState.dynamicPresenceRefreshTimer) { + clearInterval(runtimeState.dynamicPresenceRefreshTimer); + runtimeState.dynamicPresenceRefreshTimer = null; + } + + if (runtimeState.presenceRotationTimer) { + clearInterval(runtimeState.presenceRotationTimer); + runtimeState.presenceRotationTimer = null; + } +}; + +const resolveRuntimeState = (client: Client): PresenceRuntimeState => { + const botId = resolveBotId(client) ?? "unbound"; + const existing = presenceRuntimeByBotId.get(botId); + if (existing) { + return existing; + } + + const next = createPresenceRuntimeState(); + presenceRuntimeByBotId.set(botId, next); + return next; +}; + +export const shutdownPresenceRuntime = (): void => { + for (const runtimeState of presenceRuntimeByBotId.values()) { + clearRuntimeTimers(runtimeState); + + for (const panelSession of runtimeState.activePanelsByUserId.values()) { + panelSession.collector.stop("shutdown"); + } + runtimeState.activePanelsByUserId.clear(); + } + + presenceRuntimeByBotId.clear(); +}; const DISCORD_ACTIVITY_TYPES: Record = { PLAYING: ActivityType.Playing, @@ -71,64 +125,55 @@ const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatu const resolveBotId = (client: Client): string | null => client.user?.id ?? null; -const normalizePresenceActivityState = (state: PresenceState): void => { +const normalizePresenceActivityState = (state: PresenceState, runtimeState: PresenceRuntimeState): void => { const activityTexts = sanitizeActivityTexts(state.activity.texts); state.activity.texts = activityTexts; state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text); state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds); - if (activePresenceTextIndex >= activityTexts.length) { - activePresenceTextIndex = 0; + if (runtimeState.activePresenceTextIndex >= activityTexts.length) { + runtimeState.activePresenceTextIndex = 0; } }; -const getActivePresenceTemplateText = (state: PresenceState): string => { - normalizePresenceActivityState(state); - return state.activity.texts[activePresenceTextIndex] ?? state.activity.text; +const getActivePresenceTemplateText = (state: PresenceState, runtimeState: PresenceRuntimeState): string => { + normalizePresenceActivityState(state, runtimeState); + return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text; }; -const hasTemplateVariablesInPresenceState = (state: PresenceState): boolean => { - normalizePresenceActivityState(state); +const hasTemplateVariablesInPresenceState = (state: PresenceState, runtimeState: PresenceRuntimeState): boolean => { + normalizePresenceActivityState(state, runtimeState); return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText)); }; -const syncDynamicPresenceTimers = (client: Client, state: PresenceState): void => { - normalizePresenceActivityState(state); - - if (dynamicPresenceRefreshTimer) { - clearInterval(dynamicPresenceRefreshTimer); - dynamicPresenceRefreshTimer = null; - } - - if (presenceRotationTimer) { - clearInterval(presenceRotationTimer); - presenceRotationTimer = null; - } +const syncDynamicPresenceTimers = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => { + normalizePresenceActivityState(state, runtimeState); + clearRuntimeTimers(runtimeState); if (state.activity.texts.length > 1) { - presenceRotationTimer = setInterval(() => { - normalizePresenceActivityState(state); + runtimeState.presenceRotationTimer = setInterval(() => { + normalizePresenceActivityState(state, runtimeState); if (state.activity.texts.length <= 1) { - activePresenceTextIndex = 0; + runtimeState.activePresenceTextIndex = 0; return; } - activePresenceTextIndex = (activePresenceTextIndex + 1) % state.activity.texts.length; - applyPresenceState(client, state); + runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length; + applyPresenceState(client, state, runtimeState); }, state.activity.rotationIntervalSeconds * 1_000); - presenceRotationTimer.unref?.(); + runtimeState.presenceRotationTimer.unref?.(); } - if (!hasTemplateVariablesInPresenceState(state)) { + if (!hasTemplateVariablesInPresenceState(state, runtimeState)) { return; } - dynamicPresenceRefreshTimer = setInterval(() => { - applyPresenceState(client, state); + runtimeState.dynamicPresenceRefreshTimer = setInterval(() => { + applyPresenceState(client, state, runtimeState); }, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS); - dynamicPresenceRefreshTimer.unref?.(); + runtimeState.dynamicPresenceRefreshTimer.unref?.(); }; const loadPresenceState = async (client: Client): Promise => { @@ -149,15 +194,15 @@ const savePresenceState = async (client: Client, state: PresenceState): Promise< await getPresenceStore().upsertByBotId(botId, state); }; -const applyPresenceState = (client: Client, state: PresenceState): void => { +const applyPresenceState = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => { if (!client.user) { return; } - normalizePresenceActivityState(state); + normalizePresenceActivityState(state, runtimeState); const status = resolveDiscordStatus(state.status); - const templateText = getActivePresenceTemplateText(state); + const templateText = getActivePresenceTemplateText(state, runtimeState); const text = renderPresenceTemplate(client, templateText); if (state.status === "streaming" || state.activity.type === "STREAMING") { client.user.setPresence({ @@ -218,13 +263,17 @@ const statusLabel = (ctx: CommandExecutionContext, status: PresenceStatusValue): const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string => ctx.ct(`ui.activity.options.${activityType}.label`); -const panelContent = (ctx: CommandExecutionContext, state: PresenceState): string => { - normalizePresenceActivityState(state); +const panelContent = ( + ctx: CommandExecutionContext, + state: PresenceState, + runtimeState: PresenceRuntimeState, +): string => { + normalizePresenceActivityState(state, runtimeState); - const templateText = getActivePresenceTemplateText(state); + const templateText = getActivePresenceTemplateText(state, runtimeState); const activityPreview = renderPresenceTemplate(ctx.client, templateText); const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | "); - const currentIndex = Math.min(activePresenceTextIndex + 1, state.activity.texts.length); + const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length); const summary = ctx.ct("responses.panel", { status: statusLabel(ctx, state.status), @@ -246,6 +295,7 @@ const buildContainer = ( ctx: CommandExecutionContext, state: PresenceState, customIds: PresenceCustomIds, + runtimeState: PresenceRuntimeState, disabled = false, ): ContainerBuilder => { const statusSelect = new StringSelectMenuBuilder() @@ -293,7 +343,7 @@ const buildContainer = ( const container = new ContainerBuilder(); container.addTextDisplayComponents( - new TextDisplayBuilder().setContent(panelContent(ctx, state)), + new TextDisplayBuilder().setContent(panelContent(ctx, state, runtimeState)), ); container.addActionRowComponents( @@ -335,17 +385,22 @@ const resolveReplyMessage = (value: unknown): Message | null => { return isMessageResult(message) ? message : null; }; -const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise => { - applyPresenceState(ctx.client, state); - syncDynamicPresenceTimers(ctx.client, state); +const persistAndApplyPresence = async ( + ctx: CommandExecutionContext, + state: PresenceState, + runtimeState: PresenceRuntimeState, +): Promise => { + applyPresenceState(ctx.client, state, runtimeState); + syncDynamicPresenceTimers(ctx.client, state, runtimeState); await savePresenceState(ctx.client, state); }; export const restorePresenceFromStorage = async (client: Client): Promise => { const state = await loadPresenceState(client); - activePresenceTextIndex = 0; - applyPresenceState(client, state); - syncDynamicPresenceTimers(client, state); + const runtimeState = resolveRuntimeState(client); + runtimeState.activePresenceTextIndex = 0; + applyPresenceState(client, state, runtimeState); + syncDynamicPresenceTimers(client, state, runtimeState); }; export const presenceCommand = defineCommand({ @@ -360,15 +415,16 @@ export const presenceCommand = defineCommand({ }, ], execute: async (ctx) => { + const runtimeState = resolveRuntimeState(ctx.client); const state = await loadPresenceState(ctx.client); - applyPresenceState(ctx.client, state); - syncDynamicPresenceTimers(ctx.client, state); + applyPresenceState(ctx.client, state, runtimeState); + syncDynamicPresenceTimers(ctx.client, state, runtimeState); const customIds = createCustomIds(); const replyResult = await ctx.reply({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], + components: [buildContainer(ctx, state, customIds, runtimeState)], withResponse: true, }); @@ -378,13 +434,30 @@ export const presenceCommand = defineCommand({ } const ownerId = ctx.user.id; + const existingPanel = runtimeState.activePanelsByUserId.get(ownerId); + if (existingPanel) { + existingPanel.collector.stop("replaced"); + await existingPanel.disable().catch(() => undefined); + runtimeState.activePanelsByUserId.delete(ownerId); + } + + const disablePanel = async (): Promise => { + await replyMessage + .edit({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds, runtimeState, true)], + }) + .catch(() => undefined); + }; + const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 }); + runtimeState.activePanelsByUserId.set(ownerId, { collector, disable: disablePanel }); collector.on("collect", async (interaction) => { if (interaction.user.id !== ownerId) { await interaction.reply({ content: ctx.ct("responses.notOwner"), - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } @@ -394,15 +467,15 @@ export const presenceCommand = defineCommand({ if (interaction.customId === customIds.statusSelect) { const nextStatus = interaction.values[0]; if (!nextStatus || !isPresenceStatusValue(nextStatus)) { - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); return; } state.status = nextStatus; - await persistAndApplyPresence(ctx, state); + await persistAndApplyPresence(ctx, state, runtimeState); await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], + components: [buildContainer(ctx, state, customIds, runtimeState)], }); return; } @@ -410,26 +483,26 @@ export const presenceCommand = defineCommand({ if (interaction.customId === customIds.activitySelect) { const nextType = interaction.values[0]; if (!nextType || !isPresenceActivityTypeValue(nextType)) { - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); return; } state.activity.type = nextType; - await persistAndApplyPresence(ctx, state); + await persistAndApplyPresence(ctx, state, runtimeState); await interaction.update({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], + components: [buildContainer(ctx, state, customIds, runtimeState)], }); return; } - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); return; } if (interaction.isButton()) { if (interaction.customId === customIds.textButton) { - normalizePresenceActivityState(state); + normalizePresenceActivityState(state, runtimeState); const modal = new ModalBuilder() .setCustomId(customIds.textModal) @@ -463,19 +536,19 @@ export const presenceCommand = defineCommand({ state.activity.texts = nextTexts; state.activity.text = nextTexts[0] ?? state.activity.text; - activePresenceTextIndex = 0; - await persistAndApplyPresence(ctx, state); + runtimeState.activePresenceTextIndex = 0; + await persistAndApplyPresence(ctx, state, runtimeState); await submitted.deferUpdate(); await replyMessage.edit({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], + components: [buildContainer(ctx, state, customIds, runtimeState)], }); } catch { await interaction.followUp({ content: ctx.ct("responses.modalTimeout"), - ephemeral: true, + flags: [MessageFlags.Ephemeral], }).catch(() => undefined); } @@ -483,7 +556,7 @@ export const presenceCommand = defineCommand({ } if (interaction.customId === customIds.intervalButton) { - normalizePresenceActivityState(state); + normalizePresenceActivityState(state, runtimeState); const modal = new ModalBuilder() .setCustomId(customIds.intervalModal) @@ -518,7 +591,7 @@ export const presenceCommand = defineCommand({ minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, }), - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } @@ -530,55 +603,55 @@ export const presenceCommand = defineCommand({ minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, }), - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; } state.activity.rotationIntervalSeconds = nextSeconds; - await persistAndApplyPresence(ctx, state); + await persistAndApplyPresence(ctx, state, runtimeState); await submitted.deferUpdate(); await replyMessage.edit({ flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds)], + components: [buildContainer(ctx, state, customIds, runtimeState)], }); } catch { await interaction.followUp({ content: ctx.ct("responses.modalTimeout"), - ephemeral: true, + flags: [MessageFlags.Ephemeral], }).catch(() => undefined); } return; } - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); return; } - await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] }); } catch (error) { console.error("[command:presence] interaction failed", error); const fallback = ctx.t("errors.execution"); if (!interaction.replied && !interaction.deferred) { - await interaction.reply({ content: fallback, ephemeral: true }).catch(() => undefined); + await interaction.reply({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined); return; } - await interaction.followUp({ content: fallback, ephemeral: true }).catch(() => undefined); + await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined); } }); collector.on("end", async () => { - await replyMessage - .edit({ - flags: MessageFlags.IsComponentsV2, - components: [buildContainer(ctx, state, customIds, true)], - }) - .catch(() => undefined); + const currentPanel = runtimeState.activePanelsByUserId.get(ownerId); + if (currentPanel?.collector === collector) { + runtimeState.activePanelsByUserId.delete(ownerId); + } + + await disablePanel(); }); }, }); diff --git a/src/framework/commands/argParser.ts b/src/framework/commands/argParser.ts index 6699177..af1476a 100644 --- a/src/framework/commands/argParser.ts +++ b/src/framework/commands/argParser.ts @@ -1,4 +1,4 @@ -import type { ChatInputCommandInteraction, Message } from "discord.js"; +import type { ChatInputCommandInteraction, GuildBasedChannel, Message } from "discord.js"; import type { CommandArgValue, CommandArgument, TranslationVars } from "../types/command.js"; @@ -13,11 +13,17 @@ export interface ParsedArgumentsResult { } const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/; -const USER_ID_PATTERN = /^(\d{16,22})$/; +const CHANNEL_MENTION_PATTERN = /^<#(\d{16,22})>$/; +const ROLE_MENTION_PATTERN = /^<@&(\d{16,22})>$/; +const SNOWFLAKE_ID_PATTERN = /^(\d{16,22})$/; const BOOLEAN_TRUE = new Set(["true", "1", "yes", "y", "on"]); const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]); +const isGuildBasedChannel = (value: unknown): value is GuildBasedChannel => { + return Boolean(value) && typeof value === "object" && "guild" in (value as Record); +}; + export const tokenizePrefixInput = (raw: string): string[] => { const tokens: string[] = []; const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g; @@ -103,16 +109,17 @@ const parseByTypeFromPrefix = async ( return { ok: true, value: token }; case "int": { - const value = Number.parseInt(token, 10); - if (Number.isNaN(value)) { + if (!/^-?\d+$/.test(token)) { return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } }; } + + const value = Number.parseInt(token, 10); return { ok: true, value }; } case "number": { const value = Number(token); - if (Number.isNaN(value)) { + if (!Number.isFinite(value)) { return { ok: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } }; } return { ok: true, value }; @@ -131,7 +138,7 @@ const parseByTypeFromPrefix = async ( case "user": { const mentionMatch = token.match(USER_MENTION_PATTERN); - const idMatch = token.match(USER_ID_PATTERN); + const idMatch = token.match(SNOWFLAKE_ID_PATTERN); const userId = mentionMatch?.[1] ?? idMatch?.[1]; if (!userId) { @@ -151,6 +158,77 @@ const parseByTypeFromPrefix = async ( } } + case "channel": { + const mentionMatch = token.match(CHANNEL_MENTION_PATTERN); + const idMatch = token.match(SNOWFLAKE_ID_PATTERN); + const channelId = mentionMatch?.[1] ?? idMatch?.[1]; + + if (!channelId) { + return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } }; + } + + const fromMention = message.mentions.channels.get(channelId); + if (fromMention && isGuildBasedChannel(fromMention)) { + return { ok: true, value: fromMention }; + } + + const fromGuildCache = message.guild?.channels.cache.get(channelId); + if (fromGuildCache) { + return { ok: true, value: fromGuildCache }; + } + + try { + const fetchedFromGuild = await message.guild?.channels.fetch(channelId); + if (fetchedFromGuild) { + return { ok: true, value: fetchedFromGuild }; + } + } catch { + // Try the global cache/fetch fallback below. + } + + try { + const fetched = await message.client.channels.fetch(channelId); + if (fetched && isGuildBasedChannel(fetched)) { + return { ok: true, value: fetched }; + } + } catch { + // Falls through to invalid channel error. + } + + return { ok: false, error: { key: "errors.args.invalidChannel", vars: { value: token } } }; + } + + case "role": { + const mentionMatch = token.match(ROLE_MENTION_PATTERN); + const idMatch = token.match(SNOWFLAKE_ID_PATTERN); + const roleId = mentionMatch?.[1] ?? idMatch?.[1]; + + if (!roleId || !message.guild) { + return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } }; + } + + const fromMention = message.mentions.roles.get(roleId); + if (fromMention) { + return { ok: true, value: fromMention }; + } + + const fromCache = message.guild.roles.cache.get(roleId); + if (fromCache) { + return { ok: true, value: fromCache }; + } + + try { + const fetched = await message.guild.roles.fetch(roleId); + if (fetched) { + return { ok: true, value: fetched }; + } + } catch { + // Falls through to invalid role error. + } + + return { ok: false, error: { key: "errors.args.invalidRole", vars: { value: token } } }; + } + default: return { ok: true, value: token }; } diff --git a/src/framework/execution/CommandExecutor.ts b/src/framework/execution/CommandExecutor.ts index 98bef8e..61c2ab5 100644 --- a/src/framework/execution/CommandExecutor.ts +++ b/src/framework/execution/CommandExecutor.ts @@ -7,8 +7,12 @@ import { import type { BotCommand, CommandExecutionContext } from "../types/command.js"; +const COOLDOWN_SWEEP_INTERVAL_MS = 60_000; +const COOLDOWN_SWEEP_MIN_ENTRIES = 512; + export class CommandExecutor { private readonly cooldowns = new Map(); + private lastCooldownSweepAt = 0; public async run(command: BotCommand, ctx: CommandExecutionContext): Promise { const missingUserPermissions = this.getMissingPermissions(command.permissions, this.memberPermissions(ctx)); @@ -89,16 +93,42 @@ export class CommandExecutor { const key = this.cooldownKey(command.meta.name, userId); const now = Date.now(); + this.sweepExpiredCooldowns(now); + const expiresAt = this.cooldowns.get(key); if (expiresAt !== undefined && expiresAt > now) { return Math.ceil((expiresAt - now) / 1000); } + if (expiresAt !== undefined) { + this.cooldowns.delete(key); + } + this.cooldowns.set(key, now + command.cooldown * 1000); return 0; } + private sweepExpiredCooldowns(now: number): void { + if (this.cooldowns.size === 0) { + return; + } + + const shouldSweepBySize = this.cooldowns.size >= COOLDOWN_SWEEP_MIN_ENTRIES; + const shouldSweepByTime = now - this.lastCooldownSweepAt >= COOLDOWN_SWEEP_INTERVAL_MS; + if (!shouldSweepBySize && !shouldSweepByTime) { + return; + } + + for (const [key, expiresAt] of this.cooldowns.entries()) { + if (expiresAt <= now) { + this.cooldowns.delete(key); + } + } + + this.lastCooldownSweepAt = now; + } + private cooldownKey(commandName: string, userId: string): string { return `${commandName}:${userId}`; } diff --git a/src/framework/handlers/replyAdapter.ts b/src/framework/handlers/replyAdapter.ts index a806613..f6891d4 100644 --- a/src/framework/handlers/replyAdapter.ts +++ b/src/framework/handlers/replyAdapter.ts @@ -10,41 +10,81 @@ import type { ReplyPayload } from "../types/command.js"; const PREFIX_EPHEMERAL_DELETE_DELAY_MS = 10_000; const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable = { + parse: [], repliedUser: false, }; -const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable = {}; +const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable = { + parse: [], +}; type PrefixReplyObject = Exclude; const EPHEMERAL_FLAG = 64n; +const FLAG_NAME_TO_BITS: Record = { + Ephemeral: 64n, + SuppressEmbeds: 4n, + SuppressNotifications: 4096n, + IsComponentsV2: 32768n, +}; const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => { return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record); }; -const hasEphemeralInFlags = (flags: unknown): boolean => { +const toFlagBits = (flags: unknown): bigint | null => { if (flags === undefined || flags === null) { - return false; + return null; } if (typeof flags === "number" || typeof flags === "bigint") { - return (BigInt(flags) & EPHEMERAL_FLAG) !== 0n; + return BigInt(flags); } if (typeof flags === "string") { - return flags === "Ephemeral" || flags === "64"; + if (/^\d+$/.test(flags)) { + return BigInt(flags); + } + + return FLAG_NAME_TO_BITS[flags] ?? null; } if (Array.isArray(flags)) { - return flags.some((entry) => hasEphemeralInFlags(entry)); + let merged = 0n; + for (const entry of flags) { + const bits = toFlagBits(entry); + if (bits !== null) { + merged |= bits; + } + } + + return merged; } if (hasBitfieldLike(flags)) { - return (BigInt(flags.bitfield) & EPHEMERAL_FLAG) !== 0n; + return BigInt(flags.bitfield); } - return false; + return null; +}; + +const hasEphemeralInFlags = (flags: unknown): boolean => { + const bits = toFlagBits(flags); + return bits !== null && (bits & EPHEMERAL_FLAG) !== 0n; +}; + +const sanitizePrefixFlags = (flags: unknown): MessageReplyOptions["flags"] | undefined => { + const bits = toFlagBits(flags); + if (bits === null) { + return undefined; + } + + const sanitizedBits = bits & ~EPHEMERAL_FLAG; + if (sanitizedBits === 0n) { + return undefined; + } + + return Number(sanitizedBits); }; const hasEphemeral = (payload: PrefixReplyObject): boolean => { @@ -80,6 +120,7 @@ const withSlashAllowedMentions = (options: InteractionReplyOptions): Interaction const toMessageReplyOptions = (payload: Exclude): MessageReplyOptions => { const rest = { ...(payload as Record) }; + const sanitizedFlags = sanitizePrefixFlags((payload as InteractionReplyOptions).flags); // Drop interaction-only fields so prefix replies stay valid message payloads. delete rest.fetchReply; @@ -87,6 +128,10 @@ const toMessageReplyOptions = (payload: Exclude): MessageR delete rest.ephemeral; delete rest.flags; + if (sanitizedFlags !== undefined) { + rest.flags = sanitizedFlags; + } + return rest as MessageReplyOptions; }; diff --git a/src/framework/i18n/I18nService.ts b/src/framework/i18n/I18nService.ts index 1a89842..66f41e9 100644 --- a/src/framework/i18n/I18nService.ts +++ b/src/framework/i18n/I18nService.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { SUPPORTED_LANGS, type SupportedLang, type TranslationVars } from "../types/command.js"; @@ -16,6 +17,9 @@ const DISCORD_LOCALE_MAP: Record = { "es-419": "es", }; +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = path.resolve(CURRENT_DIR, "../../../locales"); + export class I18nService { private readonly dictionaries: Record; @@ -96,10 +100,8 @@ export class I18nService { } private loadDictionaries(): Record { - const localeDir = path.join(process.cwd(), "locales"); - return SUPPORTED_LANGS.reduce>((acc, lang) => { - const filePath = path.join(localeDir, `${lang}.json`); + const filePath = path.join(LOCALES_DIR, `${lang}.json`); const raw = readFileSync(filePath, "utf-8"); acc[lang] = JSON.parse(raw) as JsonObject; return acc; diff --git a/src/index.ts b/src/index.ts index 0fbf1dd..a9b5331 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { Client, Events, GatewayIntentBits } from "discord.js"; import { commandList } from "./commands/index.js"; -import { restorePresenceFromStorage } from "./commands/utility/presence.js"; +import { restorePresenceFromStorage, shutdownPresenceRuntime } from "./commands/utility/presence.js"; import { deployApplicationCommands } from "./framework/commands/deploy.js"; import { CommandRegistry } from "./framework/commands/registry.js"; import { env } from "./framework/config/env.js"; @@ -14,6 +14,7 @@ import { initPresenceStore, shutdownPresenceStore } from "./framework/presence/p const bindGracefulShutdown = (): void => { const shutdown = async (signal: string): Promise => { console.log(`[shutdown] ${signal}`); + shutdownPresenceRuntime(); await shutdownPresenceStore().catch((error) => { console.error("[shutdown] presence store close failed", error); }); @@ -33,6 +34,14 @@ const bootstrap = async (): Promise => { await initPresenceStore(); bindGracefulShutdown(); + process.on("unhandledRejection", (reason) => { + console.error("[process] unhandled rejection", reason); + }); + + process.on("uncaughtException", (error) => { + console.error("[process] uncaught exception", error); + }); + const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], }); @@ -57,14 +66,20 @@ const bootstrap = async (): Promise => { defaultLang: env.DEFAULT_LANG, }); - client.on(Events.MessageCreate, onPrefixMessage); + client.on(Events.MessageCreate, (message) => { + void onPrefixMessage(message).catch((error) => { + console.error("[event:messageCreate] handler failed", error); + }); + }); - client.on(Events.InteractionCreate, async (interaction) => { + client.on(Events.InteractionCreate, (interaction) => { if (!interaction.isChatInputCommand()) { return; } - await onSlashInteraction(interaction); + void onSlashInteraction(interaction).catch((error) => { + console.error("[event:interactionCreate] handler failed", error); + }); }); client.once(Events.ClientReady, async () => { @@ -96,6 +111,7 @@ const bootstrap = async (): Promise => { bootstrap().catch(async (error) => { console.error("[boot] fatal error", error); + shutdownPresenceRuntime(); await shutdownPresenceStore().catch((closeError) => { console.error("[boot] failed to close presence store", closeError); }); diff --git a/src/tests/argTokenizer.test.ts b/src/tests/argTokenizer.test.ts new file mode 100644 index 0000000..7262813 --- /dev/null +++ b/src/tests/argTokenizer.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { tokenizePrefixInput } from "../framework/commands/argParser.js"; + +test("tokenizePrefixInput parse les quotes simples et doubles", () => { + const tokens = tokenizePrefixInput('"hello world" test \'foo bar\''); + assert.deepEqual(tokens, ["hello world", "test", "foo bar"]); +}); + +test("tokenizePrefixInput conserve les tokens non quotes", () => { + const tokens = tokenizePrefixInput("alpha beta gamma"); + assert.deepEqual(tokens, ["alpha", "beta", "gamma"]); +}); + +test("tokenizePrefixInput gere les quotes echappees", () => { + const tokens = tokenizePrefixInput('"say \\\"hello\\\"" done'); + assert.deepEqual(tokens, ['say "hello"', "done"]); +}); diff --git a/src/tests/defineCommand.test.ts b/src/tests/defineCommand.test.ts new file mode 100644 index 0000000..119d833 --- /dev/null +++ b/src/tests/defineCommand.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { defineCommand } from "../framework/commands/defineCommand.js"; + +test("defineCommand refuse un argument requis apres un optionnel", () => { + assert.throws(() => { + defineCommand({ + meta: { name: "broken", category: "test" }, + args: [ + { name: "optional", type: "string", required: false, descriptionKey: "args.optional" }, + { name: "required", type: "string", required: true, descriptionKey: "args.required" }, + ], + execute: async () => undefined, + }); + }); +}); + +test("defineCommand refuse un cooldown invalide", () => { + assert.throws(() => { + defineCommand({ + meta: { name: "brokenCooldown", category: "test" }, + cooldown: 0, + execute: async () => undefined, + }); + }); +}); + +test("defineCommand applique les valeurs par defaut", () => { + const command = defineCommand({ + meta: { name: "ok", category: "test" }, + execute: async () => undefined, + }); + + assert.deepEqual(command.args, []); + assert.deepEqual(command.permissions, []); + assert.deepEqual(command.examples, []); + assert.equal(command.cooldown, undefined); +}); diff --git a/src/tests/presenceTypes.test.ts b/src/tests/presenceTypes.test.ts new file mode 100644 index 0000000..f033c8b --- /dev/null +++ b/src/tests/presenceTypes.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_ACTIVITY_TEXT, + MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, + MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, + parsePresenceState, + sanitizeActivityTexts, + sanitizePresenceRotationIntervalSeconds, +} from "../framework/presence/presenceTypes.js"; + +test("sanitizeActivityTexts fallback sur le texte par defaut", () => { + const texts = sanitizeActivityTexts([" ", ""]); + assert.deepEqual(texts, [DEFAULT_ACTIVITY_TEXT]); +}); + +test("sanitizePresenceRotationIntervalSeconds borne les valeurs", () => { + assert.equal(sanitizePresenceRotationIntervalSeconds(1), MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS); + assert.equal( + sanitizePresenceRotationIntervalSeconds(MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS + 100), + MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, + ); +}); + +test("parsePresenceState reconstruit un etat valide", () => { + const parsed = parsePresenceState({ + status: "online", + activity: { + type: "PLAYING", + texts: ["Hello", "World"], + rotationIntervalSeconds: 20, + }, + }); + + assert.ok(parsed); + assert.equal(parsed?.status, "online"); + assert.equal(parsed?.activity.type, "PLAYING"); + assert.deepEqual(parsed?.activity.texts, ["Hello", "World"]); + assert.equal(parsed?.activity.rotationIntervalSeconds, 20); +}); + +test("parsePresenceState retourne null sur statut invalide", () => { + const parsed = parsePresenceState({ + status: "invalid", + activity: { + type: "PLAYING", + texts: ["Hello"], + rotationIntervalSeconds: 20, + }, + }); + + assert.equal(parsed, null); +}); diff --git a/src/tests/templateVariables.test.ts b/src/tests/templateVariables.test.ts new file mode 100644 index 0000000..09eb12f --- /dev/null +++ b/src/tests/templateVariables.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + extractTemplateVariables, + hasTemplateVariable, + renderTemplate, +} from "../framework/utils/templateVariables.js"; + +test("renderTemplate remplace les variables et applique les alias", () => { + const output = renderTemplate( + "Hello {{name}} from {{guilds}}", + { + name: "Arthur", + guild_count: "42", + }, + { + aliases: { + guilds: "guild_count", + }, + }, + ); + + assert.equal(output, "Hello Arthur from 42"); +}); + +test("renderTemplate conserve les variables inconnues par defaut", () => { + const output = renderTemplate("Missing {{unknown}}", {}, { keepUnknown: true }); + assert.equal(output, "Missing {{unknown}}"); +}); + +test("extractTemplateVariables normalise les alias", () => { + const variables = extractTemplateVariables("{{bot}} {{guilds}}", { + bot: "bot_name", + guilds: "guild_count", + }); + + assert.deepEqual(variables.sort(), ["bot_name", "guild_count"]); +}); + +test("hasTemplateVariable detecte les variables connues", () => { + const hasKnown = hasTemplateVariable("{{prefix}} {{other}}", ["prefix", "guild_count"]); + assert.equal(hasKnown, true); + + const hasUnknownOnly = hasTemplateVariable("{{other}}", ["prefix", "guild_count"]); + assert.equal(hasUnknownOnly, false); +});