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_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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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