mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
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:
+3
-3
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user