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_TOKEN=
DISCORD_CLIENT_ID= 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 DATABASE_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 POSTGRES_USER=discord_bot
POSTGRES_PASSWORD=discord POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
POSTGRES_PORT=5432 POSTGRES_PORT=5432
PREFIX=+ PREFIX=+
DEFAULT_LANG=en DEFAULT_LANG=en
-2
View File
@@ -8,7 +8,6 @@ RUN npm ci
COPY tsconfig.json ./ COPY tsconfig.json ./
COPY src ./src COPY src ./src
COPY locales ./locales COPY locales ./locales
COPY data ./data
RUN npm run build RUN npm run build
@@ -22,6 +21,5 @@ RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY locales ./locales COPY locales ./locales
COPY data ./data
CMD ["node", "dist/index.js"] 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 npm run deploy:commands
6. Start in development: 6. Start in development:
npm run dev npm run dev
7. Validate code quality:
- npm run typecheck
- npm run test
- npm run check
## Docker Deployment (Bot + PostgreSQL) ## 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: 2. Start stack:
docker compose up -d --build docker compose up -d --build
3. Stop stack: 3. Stop stack:
@@ -52,6 +61,10 @@ By default, `docker-compose.yml` provisions:
The bot container uses: The bot container uses:
- `DATABASE_URL=postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@postgres:5432/<POSTGRES_DB>` - `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 ## Multi-Bot With One DB
You can run several bot services against the same PostgreSQL instance. You can run several bot services against the same PostgreSQL instance.
+9 -9
View File
@@ -10,8 +10,8 @@ services:
env_file: env_file:
- .env.bot-alpha - .env.bot-alpha
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
DATABASE_SSL: "false" DATABASE_SSL: ${DATABASE_SSL:-false}
bot_beta: bot_beta:
build: build:
@@ -24,22 +24,22 @@ services:
env_file: env_file:
- .env.bot-beta - .env.bot-beta
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
DATABASE_SSL: "false" DATABASE_SSL: ${DATABASE_SSL:-false}
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-discord_bots} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:-discord} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "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:-discord} -d ${POSTGRES_DB:-discord_bots}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+7 -7
View File
@@ -10,22 +10,22 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
DATABASE_SSL: "false" DATABASE_SSL: ${DATABASE_SSL:-false}
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-discord_bots} POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER:-discord} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports: ports:
- "${POSTGRES_PORT:-5432}:5432" - "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:-discord} -d ${POSTGRES_DB:-discord_bots}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+2
View File
@@ -6,6 +6,8 @@
"invalidNumber": "Argument must be a number. Received: {{value}}", "invalidNumber": "Argument must be a number. Received: {{value}}",
"invalidBoolean": "Argument must be a boolean value. Received: {{value}}", "invalidBoolean": "Argument must be a boolean value. Received: {{value}}",
"invalidUser": "Could not resolve a user from {{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}}" "tooMany": "Too many arguments detected ({{count}}): {{extras}}. Usage: {{usage}}"
}, },
"permissions": { "permissions": {
+2
View File
@@ -6,6 +6,8 @@
"invalidNumber": "El argumento debe ser un numero. Recibido: {{value}}", "invalidNumber": "El argumento debe ser un numero. Recibido: {{value}}",
"invalidBoolean": "El argumento debe ser un valor booleano. Recibido: {{value}}", "invalidBoolean": "El argumento debe ser un valor booleano. Recibido: {{value}}",
"invalidUser": "No se pudo resolver un usuario desde {{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}}" "tooMany": "Se detectaron demasiados argumentos ({{count}}): {{extras}}. Uso: {{usage}}"
}, },
"permissions": { "permissions": {
+2
View File
@@ -6,6 +6,8 @@
"invalidNumber": "L argument doit etre un nombre. Recu: {{value}}", "invalidNumber": "L argument doit etre un nombre. Recu: {{value}}",
"invalidBoolean": "L argument doit etre un booleen. Recu: {{value}}", "invalidBoolean": "L argument doit etre un booleen. Recu: {{value}}",
"invalidUser": "Impossible de trouver un utilisateur depuis {{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}}" "tooMany": "Trop d arguments ({{count}}) detectes: {{extras}}. Usage: {{usage}}"
}, },
"permissions": { "permissions": {
+3
View File
@@ -9,6 +9,9 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json", "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", "start": "node dist/index.js",
"deploy:commands": "tsx src/scripts/deployCommands.ts" "deploy:commands": "tsx src/scripts/deployCommands.ts"
}, },
+152 -79
View File
@@ -51,9 +51,63 @@ interface PresenceCustomIds {
intervalInput: string; intervalInput: string;
} }
let dynamicPresenceRefreshTimer: NodeJS.Timeout | null = null; interface PresencePanelSession {
let presenceRotationTimer: NodeJS.Timeout | null = null; collector: ReturnType<Message["createMessageComponentCollector"]>;
let activePresenceTextIndex = 0; 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> = { const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing, PLAYING: ActivityType.Playing,
@@ -71,64 +125,55 @@ const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatu
const resolveBotId = (client: Client): string | null => client.user?.id ?? null; 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); const activityTexts = sanitizeActivityTexts(state.activity.texts);
state.activity.texts = activityTexts; state.activity.texts = activityTexts;
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text); state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds); state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
if (activePresenceTextIndex >= activityTexts.length) { if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
activePresenceTextIndex = 0; runtimeState.activePresenceTextIndex = 0;
} }
}; };
const getActivePresenceTemplateText = (state: PresenceState): string => { const getActivePresenceTemplateText = (state: PresenceState, runtimeState: PresenceRuntimeState): string => {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
return state.activity.texts[activePresenceTextIndex] ?? state.activity.text; return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
}; };
const hasTemplateVariablesInPresenceState = (state: PresenceState): boolean => { const hasTemplateVariablesInPresenceState = (state: PresenceState, runtimeState: PresenceRuntimeState): boolean => {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText)); return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
}; };
const syncDynamicPresenceTimers = (client: Client, state: PresenceState): void => { const syncDynamicPresenceTimers = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (dynamicPresenceRefreshTimer) {
clearInterval(dynamicPresenceRefreshTimer);
dynamicPresenceRefreshTimer = null;
}
if (presenceRotationTimer) {
clearInterval(presenceRotationTimer);
presenceRotationTimer = null;
}
if (state.activity.texts.length > 1) { if (state.activity.texts.length > 1) {
presenceRotationTimer = setInterval(() => { runtimeState.presenceRotationTimer = setInterval(() => {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
if (state.activity.texts.length <= 1) { if (state.activity.texts.length <= 1) {
activePresenceTextIndex = 0; runtimeState.activePresenceTextIndex = 0;
return; return;
} }
activePresenceTextIndex = (activePresenceTextIndex + 1) % state.activity.texts.length; runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
applyPresenceState(client, state); applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000); }, state.activity.rotationIntervalSeconds * 1_000);
presenceRotationTimer.unref?.(); runtimeState.presenceRotationTimer.unref?.();
} }
if (!hasTemplateVariablesInPresenceState(state)) { if (!hasTemplateVariablesInPresenceState(state, runtimeState)) {
return; return;
} }
dynamicPresenceRefreshTimer = setInterval(() => { runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
applyPresenceState(client, state); applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS); }, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
dynamicPresenceRefreshTimer.unref?.(); runtimeState.dynamicPresenceRefreshTimer.unref?.();
}; };
const loadPresenceState = async (client: Client): Promise<PresenceState> => { const loadPresenceState = async (client: Client): Promise<PresenceState> => {
@@ -149,15 +194,15 @@ const savePresenceState = async (client: Client, state: PresenceState): Promise<
await getPresenceStore().upsertByBotId(botId, state); await getPresenceStore().upsertByBotId(botId, state);
}; };
const applyPresenceState = (client: Client, state: PresenceState): void => { const applyPresenceState = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
if (!client.user) { if (!client.user) {
return; return;
} }
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
const status = resolveDiscordStatus(state.status); const status = resolveDiscordStatus(state.status);
const templateText = getActivePresenceTemplateText(state); const templateText = getActivePresenceTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText); const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") { if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({ client.user.setPresence({
@@ -218,13 +263,17 @@ const statusLabel = (ctx: CommandExecutionContext, status: PresenceStatusValue):
const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string => const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string =>
ctx.ct(`ui.activity.options.${activityType}.label`); ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (ctx: CommandExecutionContext, state: PresenceState): string => { const panelContent = (
normalizePresenceActivityState(state); 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 activityPreview = renderPresenceTemplate(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | "); 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", { const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status), status: statusLabel(ctx, state.status),
@@ -246,6 +295,7 @@ const buildContainer = (
ctx: CommandExecutionContext, ctx: CommandExecutionContext,
state: PresenceState, state: PresenceState,
customIds: PresenceCustomIds, customIds: PresenceCustomIds,
runtimeState: PresenceRuntimeState,
disabled = false, disabled = false,
): ContainerBuilder => { ): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder() const statusSelect = new StringSelectMenuBuilder()
@@ -293,7 +343,7 @@ const buildContainer = (
const container = new ContainerBuilder(); const container = new ContainerBuilder();
container.addTextDisplayComponents( container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, state)), new TextDisplayBuilder().setContent(panelContent(ctx, state, runtimeState)),
); );
container.addActionRowComponents( container.addActionRowComponents(
@@ -335,17 +385,22 @@ const resolveReplyMessage = (value: unknown): Message | null => {
return isMessageResult(message) ? message : null; return isMessageResult(message) ? message : null;
}; };
const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise<void> => { const persistAndApplyPresence = async (
applyPresenceState(ctx.client, state); ctx: CommandExecutionContext,
syncDynamicPresenceTimers(ctx.client, state); state: PresenceState,
runtimeState: PresenceRuntimeState,
): Promise<void> => {
applyPresenceState(ctx.client, state, runtimeState);
syncDynamicPresenceTimers(ctx.client, state, runtimeState);
await savePresenceState(ctx.client, state); await savePresenceState(ctx.client, state);
}; };
export const restorePresenceFromStorage = async (client: Client): Promise<void> => { export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
const state = await loadPresenceState(client); const state = await loadPresenceState(client);
activePresenceTextIndex = 0; const runtimeState = resolveRuntimeState(client);
applyPresenceState(client, state); runtimeState.activePresenceTextIndex = 0;
syncDynamicPresenceTimers(client, state); applyPresenceState(client, state, runtimeState);
syncDynamicPresenceTimers(client, state, runtimeState);
}; };
export const presenceCommand = defineCommand({ export const presenceCommand = defineCommand({
@@ -360,15 +415,16 @@ export const presenceCommand = defineCommand({
}, },
], ],
execute: async (ctx) => { execute: async (ctx) => {
const runtimeState = resolveRuntimeState(ctx.client);
const state = await loadPresenceState(ctx.client); const state = await loadPresenceState(ctx.client);
applyPresenceState(ctx.client, state); applyPresenceState(ctx.client, state, runtimeState);
syncDynamicPresenceTimers(ctx.client, state); syncDynamicPresenceTimers(ctx.client, state, runtimeState);
const customIds = createCustomIds(); const customIds = createCustomIds();
const replyResult = await ctx.reply({ const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)], components: [buildContainer(ctx, state, customIds, runtimeState)],
withResponse: true, withResponse: true,
}); });
@@ -378,13 +434,30 @@ export const presenceCommand = defineCommand({
} }
const ownerId = ctx.user.id; 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 }); const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
runtimeState.activePanelsByUserId.set(ownerId, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => { collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) { if (interaction.user.id !== ownerId) {
await interaction.reply({ await interaction.reply({
content: ctx.ct("responses.notOwner"), content: ctx.ct("responses.notOwner"),
ephemeral: true, flags: [MessageFlags.Ephemeral],
}); });
return; return;
} }
@@ -394,15 +467,15 @@ export const presenceCommand = defineCommand({
if (interaction.customId === customIds.statusSelect) { if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0]; const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) { 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; return;
} }
state.status = nextStatus; state.status = nextStatus;
await persistAndApplyPresence(ctx, state); await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)], components: [buildContainer(ctx, state, customIds, runtimeState)],
}); });
return; return;
} }
@@ -410,26 +483,26 @@ export const presenceCommand = defineCommand({
if (interaction.customId === customIds.activitySelect) { if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0]; const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) { 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; return;
} }
state.activity.type = nextType; state.activity.type = nextType;
await persistAndApplyPresence(ctx, state); await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({ await interaction.update({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)], components: [buildContainer(ctx, state, customIds, runtimeState)],
}); });
return; return;
} }
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return; return;
} }
if (interaction.isButton()) { if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) { if (interaction.customId === customIds.textButton) {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(customIds.textModal) .setCustomId(customIds.textModal)
@@ -463,19 +536,19 @@ export const presenceCommand = defineCommand({
state.activity.texts = nextTexts; state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text; state.activity.text = nextTexts[0] ?? state.activity.text;
activePresenceTextIndex = 0; runtimeState.activePresenceTextIndex = 0;
await persistAndApplyPresence(ctx, state); await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate(); await submitted.deferUpdate();
await replyMessage.edit({ await replyMessage.edit({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)], components: [buildContainer(ctx, state, customIds, runtimeState)],
}); });
} catch { } catch {
await interaction.followUp({ await interaction.followUp({
content: ctx.ct("responses.modalTimeout"), content: ctx.ct("responses.modalTimeout"),
ephemeral: true, flags: [MessageFlags.Ephemeral],
}).catch(() => undefined); }).catch(() => undefined);
} }
@@ -483,7 +556,7 @@ export const presenceCommand = defineCommand({
} }
if (interaction.customId === customIds.intervalButton) { if (interaction.customId === customIds.intervalButton) {
normalizePresenceActivityState(state); normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal) .setCustomId(customIds.intervalModal)
@@ -518,7 +591,7 @@ export const presenceCommand = defineCommand({
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}), }),
ephemeral: true, flags: [MessageFlags.Ephemeral],
}); });
return; return;
} }
@@ -530,55 +603,55 @@ export const presenceCommand = defineCommand({
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS, minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS, maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}), }),
ephemeral: true, flags: [MessageFlags.Ephemeral],
}); });
return; return;
} }
state.activity.rotationIntervalSeconds = nextSeconds; state.activity.rotationIntervalSeconds = nextSeconds;
await persistAndApplyPresence(ctx, state); await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate(); await submitted.deferUpdate();
await replyMessage.edit({ await replyMessage.edit({
flags: MessageFlags.IsComponentsV2, flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds)], components: [buildContainer(ctx, state, customIds, runtimeState)],
}); });
} catch { } catch {
await interaction.followUp({ await interaction.followUp({
content: ctx.ct("responses.modalTimeout"), content: ctx.ct("responses.modalTimeout"),
ephemeral: true, flags: [MessageFlags.Ephemeral],
}).catch(() => undefined); }).catch(() => undefined);
} }
return; return;
} }
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return; return;
} }
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) { } catch (error) {
console.error("[command:presence] interaction failed", error); console.error("[command:presence] interaction failed", error);
const fallback = ctx.t("errors.execution"); const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) { 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; return;
} }
await interaction.followUp({ content: fallback, ephemeral: true }).catch(() => undefined); await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
} }
}); });
collector.on("end", async () => { collector.on("end", async () => {
await replyMessage const currentPanel = runtimeState.activePanelsByUserId.get(ownerId);
.edit({ if (currentPanel?.collector === collector) {
flags: MessageFlags.IsComponentsV2, runtimeState.activePanelsByUserId.delete(ownerId);
components: [buildContainer(ctx, state, customIds, true)], }
})
.catch(() => undefined); 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"; 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_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_TRUE = new Set(["true", "1", "yes", "y", "on"]);
const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]); 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[] => { export const tokenizePrefixInput = (raw: string): string[] => {
const tokens: string[] = []; const tokens: string[] = [];
const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g; const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
@@ -103,16 +109,17 @@ const parseByTypeFromPrefix = async (
return { ok: true, value: token }; return { ok: true, value: token };
case "int": { case "int": {
const value = Number.parseInt(token, 10); if (!/^-?\d+$/.test(token)) {
if (Number.isNaN(value)) {
return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } }; return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } };
} }
const value = Number.parseInt(token, 10);
return { ok: true, value }; return { ok: true, value };
} }
case "number": { case "number": {
const value = Number(token); 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: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } };
} }
return { ok: true, value }; return { ok: true, value };
@@ -131,7 +138,7 @@ const parseByTypeFromPrefix = async (
case "user": { case "user": {
const mentionMatch = token.match(USER_MENTION_PATTERN); 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]; const userId = mentionMatch?.[1] ?? idMatch?.[1];
if (!userId) { 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: default:
return { ok: true, value: token }; return { ok: true, value: token };
} }
@@ -7,8 +7,12 @@ import {
import type { BotCommand, CommandExecutionContext } from "../types/command.js"; import type { BotCommand, CommandExecutionContext } from "../types/command.js";
const COOLDOWN_SWEEP_INTERVAL_MS = 60_000;
const COOLDOWN_SWEEP_MIN_ENTRIES = 512;
export class CommandExecutor { export class CommandExecutor {
private readonly cooldowns = new Map<string, number>(); private readonly cooldowns = new Map<string, number>();
private lastCooldownSweepAt = 0;
public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> { public async run(command: BotCommand, ctx: CommandExecutionContext): Promise<void> {
const missingUserPermissions = this.getMissingPermissions(command.permissions, this.memberPermissions(ctx)); 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 key = this.cooldownKey(command.meta.name, userId);
const now = Date.now(); const now = Date.now();
this.sweepExpiredCooldowns(now);
const expiresAt = this.cooldowns.get(key); const expiresAt = this.cooldowns.get(key);
if (expiresAt !== undefined && expiresAt > now) { if (expiresAt !== undefined && expiresAt > now) {
return Math.ceil((expiresAt - now) / 1000); return Math.ceil((expiresAt - now) / 1000);
} }
if (expiresAt !== undefined) {
this.cooldowns.delete(key);
}
this.cooldowns.set(key, now + command.cooldown * 1000); this.cooldowns.set(key, now + command.cooldown * 1000);
return 0; 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 { private cooldownKey(commandName: string, userId: string): string {
return `${commandName}:${userId}`; 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_EPHEMERAL_DELETE_DELAY_MS = 10_000;
const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<MessageReplyOptions["allowedMentions"]> = { const PREFIX_ALLOWED_MENTIONS_DEFAULT: NonNullable<MessageReplyOptions["allowedMentions"]> = {
parse: [],
repliedUser: false, repliedUser: false,
}; };
const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {}; const SLASH_ALLOWED_MENTIONS_DEFAULT: NonNullable<InteractionReplyOptions["allowedMentions"]> = {
parse: [],
};
type PrefixReplyObject = Exclude<ReplyPayload, string>; type PrefixReplyObject = Exclude<ReplyPayload, string>;
const EPHEMERAL_FLAG = 64n; 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 } => { const hasBitfieldLike = (value: unknown): value is { bitfield: bigint | number } => {
return Boolean(value) && typeof value === "object" && "bitfield" in (value as Record<string, unknown>); 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) { if (flags === undefined || flags === null) {
return false; return null;
} }
if (typeof flags === "number" || typeof flags === "bigint") { if (typeof flags === "number" || typeof flags === "bigint") {
return (BigInt(flags) & EPHEMERAL_FLAG) !== 0n; return BigInt(flags);
} }
if (typeof flags === "string") { 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)) { 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)) { 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 => { const hasEphemeral = (payload: PrefixReplyObject): boolean => {
@@ -80,6 +120,7 @@ const withSlashAllowedMentions = (options: InteractionReplyOptions): Interaction
const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => { const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageReplyOptions => {
const rest = { ...(payload as Record<string, unknown>) }; 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. // Drop interaction-only fields so prefix replies stay valid message payloads.
delete rest.fetchReply; delete rest.fetchReply;
@@ -87,6 +128,10 @@ const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageR
delete rest.ephemeral; delete rest.ephemeral;
delete rest.flags; delete rest.flags;
if (sanitizedFlags !== undefined) {
rest.flags = sanitizedFlags;
}
return rest as MessageReplyOptions; return rest as MessageReplyOptions;
}; };
+5 -3
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { SUPPORTED_LANGS, type SupportedLang, type TranslationVars } from "../types/command.js"; 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", "es-419": "es",
}; };
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(CURRENT_DIR, "../../../locales");
export class I18nService { export class I18nService {
private readonly dictionaries: Record<SupportedLang, JsonObject>; private readonly dictionaries: Record<SupportedLang, JsonObject>;
@@ -96,10 +100,8 @@ export class I18nService {
} }
private loadDictionaries(): Record<SupportedLang, JsonObject> { private loadDictionaries(): Record<SupportedLang, JsonObject> {
const localeDir = path.join(process.cwd(), "locales");
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((acc, lang) => { 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"); const raw = readFileSync(filePath, "utf-8");
acc[lang] = JSON.parse(raw) as JsonObject; acc[lang] = JSON.parse(raw) as JsonObject;
return acc; return acc;
+20 -4
View File
@@ -1,7 +1,7 @@
import { Client, Events, GatewayIntentBits } from "discord.js"; import { Client, Events, GatewayIntentBits } from "discord.js";
import { commandList } from "./commands/index.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 { deployApplicationCommands } from "./framework/commands/deploy.js";
import { CommandRegistry } from "./framework/commands/registry.js"; import { CommandRegistry } from "./framework/commands/registry.js";
import { env } from "./framework/config/env.js"; import { env } from "./framework/config/env.js";
@@ -14,6 +14,7 @@ import { initPresenceStore, shutdownPresenceStore } from "./framework/presence/p
const bindGracefulShutdown = (): void => { const bindGracefulShutdown = (): void => {
const shutdown = async (signal: string): Promise<void> => { const shutdown = async (signal: string): Promise<void> => {
console.log(`[shutdown] ${signal}`); console.log(`[shutdown] ${signal}`);
shutdownPresenceRuntime();
await shutdownPresenceStore().catch((error) => { await shutdownPresenceStore().catch((error) => {
console.error("[shutdown] presence store close failed", error); console.error("[shutdown] presence store close failed", error);
}); });
@@ -33,6 +34,14 @@ const bootstrap = async (): Promise<void> => {
await initPresenceStore(); await initPresenceStore();
bindGracefulShutdown(); 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({ const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
}); });
@@ -57,14 +66,20 @@ const bootstrap = async (): Promise<void> => {
defaultLang: env.DEFAULT_LANG, 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()) { if (!interaction.isChatInputCommand()) {
return; return;
} }
await onSlashInteraction(interaction); void onSlashInteraction(interaction).catch((error) => {
console.error("[event:interactionCreate] handler failed", error);
});
}); });
client.once(Events.ClientReady, async () => { client.once(Events.ClientReady, async () => {
@@ -96,6 +111,7 @@ const bootstrap = async (): Promise<void> => {
bootstrap().catch(async (error) => { bootstrap().catch(async (error) => {
console.error("[boot] fatal error", error); console.error("[boot] fatal error", error);
shutdownPresenceRuntime();
await shutdownPresenceStore().catch((closeError) => { await shutdownPresenceStore().catch((closeError) => {
console.error("[boot] failed to close presence store", 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);
});