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.
This commit is contained in:
Puechberty Arthur
2026-04-12 16:07:27 +02:00
parent bd5ef564bb
commit f6a2e2b970
19 changed files with 545 additions and 122 deletions
+3 -3
View File
@@ -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
-2
View File
@@ -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"]
+14 -1
View File
@@ -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_USER>:<POSTGRES_PASSWORD>@postgres:5432/<POSTGRES_DB>`
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.
+9 -9
View File
@@ -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
+7 -7
View File
@@ -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
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+3
View File
@@ -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"
},
+152 -79
View File
@@ -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<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
interface PresenceRuntimeState {
dynamicPresenceRefreshTimer: NodeJS.Timeout | null;
presenceRotationTimer: NodeJS.Timeout | null;
activePresenceTextIndex: number;
activePanelsByUserId: Map<string, PresencePanelSession>;
}
const presenceRuntimeByBotId = new Map<string, PresenceRuntimeState>();
const createPresenceRuntimeState = (): PresenceRuntimeState => ({
dynamicPresenceRefreshTimer: null,
presenceRotationTimer: null,
activePresenceTextIndex: 0,
activePanelsByUserId: new Map<string, PresencePanelSession>(),
});
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<PresenceActivityTypeValue, ActivityType> = {
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<PresenceState> => {
@@ -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<void> => {
applyPresenceState(ctx.client, state);
syncDynamicPresenceTimers(ctx.client, state);
const persistAndApplyPresence = async (
ctx: CommandExecutionContext,
state: PresenceState,
runtimeState: PresenceRuntimeState,
): Promise<void> => {
applyPresenceState(ctx.client, state, runtimeState);
syncDynamicPresenceTimers(ctx.client, state, runtimeState);
await savePresenceState(ctx.client, state);
};
export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
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<void> => {
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();
});
},
});
+84 -6
View File
@@ -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<string, unknown>);
};
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 };
}
@@ -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<string, number>();
private lastCooldownSweepAt = 0;
public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
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}`;
}
+53 -8
View File
@@ -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<MessageReplyOptions["allowedMentions"]> = {
parse: [],
repliedUser: false,
};
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {};
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {
parse: [],
};
type PrefixReplyObject = Exclude<ReplyPayload, string>;
const EPHEMERAL_FLAG = 64n;
const FLAG_NAME_TO_BITS: Record<string, bigint> = {
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<string, unknown>);
};
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<ReplyPayload, string>): MessageReplyOptions => {
const rest = { ...(payload as Record<string, unknown>) };
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<ReplyPayload, string>): MessageR
delete rest.ephemeral;
delete rest.flags;
if (sanitizedFlags !== undefined) {
rest.flags = sanitizedFlags;
}
return rest as MessageReplyOptions;
};
+5 -3
View File
@@ -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<string, SupportedLang> = {
"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<SupportedLang, JsonObject>;
@@ -96,10 +100,8 @@ export class I18nService {
}
private loadDictionaries(): Record<SupportedLang, JsonObject> {
const localeDir = path.join(process.cwd(), "locales");
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((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;
+20 -4
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
bootstrap().catch(async (error) => {
console.error("[boot] fatal error", error);
shutdownPresenceRuntime();
await shutdownPresenceStore().catch((closeError) => {
console.error("[boot] failed to close presence store", closeError);
});
+19
View File
@@ -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"]);
});
+39
View File
@@ -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);
});
+54
View File
@@ -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);
});
+47
View File
@@ -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);
});