finalisation de la mise au propre du projet

This commit is contained in:
Puechberty Arthur
2026-04-14 00:09:12 +02:00
parent e47cb68bc7
commit aa3cec7127
35 changed files with 1201 additions and 1252 deletions
+87 -72
View File
@@ -1,86 +1,101 @@
# AGENT.md - Guide du projet template_discordjs # AGENT.md - Guide du projet template_discordjs
Version: 1.0 Version: 2.0
But But
--- ---
Ce fichier décrit le projet, sa structure, les conventions de code et la procédure recommandée pour ajouter de nouvelles fonctionnalités (commandes, événements, services). Il sert de référence pour les contributeurs et pour les agents automatisés qui travaillent sur le dépôt. Ce fichier decrit la structure actuelle du projet, les conventions d'architecture et le workflow recommande pour contribuer sans introduire de dette technique.
Vue d'ensemble du projet Vue d'ensemble
----------------------- --------------
- Tech stack: TypeScript + Discord.js (bot template). Le code source est dans `src/` et la sortie build dans `build/` (à ignorer). - Stack: TypeScript + Discord.js 14.
- Localisation: `src/i18n/` contient `en.json`, `fr.json`, `es.json`. - Build: esbuild + typecheck TypeScript strict.
- Localisation: `src/i18n/en.json`, `src/i18n/fr.json`, `src/i18n/es.json`.
- Base de donnees: PostgreSQL, stores injectes via lifecycle centralise.
Organisation des fichiers Organisation des dossiers
------------------------ -------------------------
- Racine: - Racine:
- `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml` — scripts et infra. - `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml`.
- `src/` (code TypeScript): - `src/app/`
- `index.ts` — point d'entrée, boot du bot. - `bootstrap.ts`: wiring des dependances, init DB, handlers, shutdown.
- `commands/` — définitions de commandes (chaque fichier expose une commande). - `container.ts`: contrats de services injectes.
- `events/` — un fichier par événement Discord (ex: `guildMemberAdd.ts`). `src/events/index.ts` centralise l'enregistrement. - `src/commands/`
- `framework/` — bibliothèque interne: helpers commandes, `i18n/`, `memberMessages/`, `presence/`, `execution/`, `handlers/`, `config/`, `types/`. - Wrappers de commandes uniquement.
- `utils/` — helpers génériques (ex: `templateVariables.ts`). - Chaque fichier retourne une commande via `defineCommand(...)`.
- `tests/` — tests unitaires ciblant managers, stores et utilitaires. - Aucune logique metier lourde dans cette couche.
- `src/features/`
- `presence/`: orchestration panel, runtime, service, repository contract.
- `memberMessages/`: orchestration panel, dispatch, image rendering, repository contract.
- `src/core/`
- `commands/`: parser, registry, slash builder, usage.
- `execution/`: pipeline d'execution des commandes.
- `discord/`: helpers partages (resolveReplyMessage, session registry).
- `src/database/`
- `stores/`: implementations PostgreSQL concretes.
- `dbLifecycle.ts`: init/shutdown centralise.
- `src/validators/`
- Validation et sanitation metier (presence, member messages).
- `src/types/`
- Types purs partages (pas de logique metier).
- `src/events/`
- Enregistrement des listeners Discord, relies aux services injectes.
- `src/handlers/`
- Entrees prefix/slash et adaptation du contexte d'execution.
- `src/utils/`
- Utilitaires generiques transverses.
Principes et conventions de code Principes d'architecture
------------------------------- ------------------------
- Commands: chaque commande doit être définie avec `defineCommand({...})` et exposer uniquement la configuration + une fonction `execute()` très mince qui délègue la logique métier à un service/manager. - Commands UI only:
- Services / Managers: placer la logique métier testable dans `src/framework/*`. Ces modules doivent être découplés de Discord.js — recevoir des adaptateurs ou des interfaces plutôt que des objets Discord directement. - Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`.
- Events: un seul fichier par événement; exporter une fonction `registerX(client, i18n)` ou une fonction d'enregistrement équivalente. Centraliser les imports dans `src/events/index.ts`. - Features own business logic:
- Typage: utiliser TypeScript strict, signatures fortement typées pour les handlers (`onPrefixMessage(message: Message)` etc.). - Toute logique metier testable va dans `src/features/*`.
- Tests: privilégier les tests unitaires pour managers/stores/transformations. Mockez les adaptateurs Discord. - Core is shared infra:
- i18n: utiliser `I18nService` pour traductions. Ajouter toutes les clés dans `src/i18n/*.json`. - Helpers communs Discord, execution pipeline, parser, registry.
- Nommage: fichiers en lowerCamelCase (ex: `welcome.ts`, `memberMessagePanel.ts`). Exports nommés préférés pour faciliter le mocking. - Validators isolate rules:
- Sécurité: ne pas committer de secrets (`.env*` doit être dans `.gitignore`). - Les regles de validation/sanitation ne vont pas dans `src/types`.
- Database via repository contracts:
- Les features dependent d'interfaces, pas de singletons globaux.
- Bootstrap owns lifecycle:
- Init/shutdown DB et services centralises dans `src/app/bootstrap.ts`.
Procédure standard pour ajouter une nouvelle commande Conventions de code
---------------------------------------------------- -------------------
1. Créer le fichier dans `src/commands/myCommand.ts`. - TypeScript strict obligatoire.
2. Utiliser `defineCommand({ meta, args, examples, execute })` pour déclarer la commande. - Exports nommes preferes.
3. Implémenter `execute()` de façon minimale: valider les args et appeler un service dans `src/framework/` si la logique est non triviale. - Fichiers en lowerCamelCase.
4. Ajouter les clés de traduction dans `src/i18n/en.json`, `src/i18n/fr.json`, `src/i18n/es.json` (ex: `commands.myCommand.success`). - Commentaires courts uniquement pour clarifier un bloc non trivial.
5. Écrire des tests unitaires dans `tests/` pour le service/manager; si la commande est juste un wrapper, testez le service. - Eviter tout couplage direct d'une feature vers une autre sans passer par contrats explicites.
6. Si c'est une slash command, vérifier que le registre inclut la commande; redémarrer le bot avec `AUTO_DEPLOY_SLASH=true` si synchronisation nécessaire.
7. Lancer `npm run build` puis `npm test` (ou la suite de scripts définie dans `package.json`).
8. Ouvrir une PR documentant le changement et incluant les tests et les traductions.
Procédure standard pour ajouter un nouvel événement Procedure: ajouter une commande
-------------------------------------------------- --------------------------------
1. Créer `src/events/<eventName>.ts` et y exporter `register<EventName>(client, i18n)`. 1. Creer un wrapper dans `src/commands/`.
2. Respecter la signature projet (voir `src/events/index.ts` pour l'exemple d'enregistrement). 2. Declarer la commande avec `defineCommand({ ... })`.
3. Mettre la logique testable dans un manager/service et tester cette logique séparément. 3. Deleguer `execute` vers un module `src/features/...`.
4. Ajouter l'import et l'appel d'enregistrement dans `src/events/index.ts`. 4. Ajouter la commande dans `createCommandList` (`src/commands/index.ts`).
5. Ajouter les cles i18n dans `src/i18n/*.json`.
6. Ajouter les tests cibles (`tests/`) selon la logique introduite.
Procédure pour ajouter un service/manager dans `framework` Procedure: ajouter une feature
-------------------------------------------------------- ------------------------------
1. Créer un dossier `src/framework/<feature>/` contenant `manager.ts`, `store.ts` (si nécessaire), `types.ts` et tests. 1. Creer `src/features/<feature>/`.
2. Interfacez le manager pour qu'il accepte des adaptateurs (ex: `DiscordAdapter`) au lieu d'utiliser directement `client`. 2. Definir les contrats repository dans la feature.
3. Documenter les comportements et écrire des tests unitaires couvrant les cas critiques. 3. Implementer le service metier independant de Discord quand possible.
4. Ajouter/adapter le store PostgreSQL sous `src/database/stores/`.
5. Cablage d'injection dans `src/app/bootstrap.ts`.
6. Ajouter tests unitaires et, si necessaire, integration.
i18n — bonnes pratiques Tests et verification
----------------------- ---------------------
- Utiliser des clés structurées: `commands.<name>.<key>` ou `presence.<action>`. - Verification minimale avant PR:
- Garder `en.json` comme référence complète; synchroniser les autres langues. - `npm run typecheck`
- `npm test`
- En cas de refactor de structure:
- verifier README + AGENT.md + i18n + imports.
Tests et CI Securite
----------- --------
- Prioriser les tests sur managers, stores et utilitaires. - Ne jamais committer de secrets.
- Mocks: extraire les dépendances externes (Discord API) derrière des adaptateurs pour pouvoir les mocker. - `.env*` (sauf `.env.example`) doit rester ignore.
- Scripts: utiliser `npm test` et `npm run build` depuis la racine (vérifier `package.json`). - Eviter d'exposer des credentials dans logs/scripts de debug.
Déploiement / exécution
-----------------------
- Utiliser `Dockerfile` / `docker-compose.yml` fournis pour le déploiement en conteneur.
- Pour les slash commands, activer `AUTO_DEPLOY_SLASH=true` pour une synchronisation automatique au démarrage.
Revue et PR
-----------
- Inclure toujours:
- modifications de traduction,
- tests unitaires pour toute logique métier ajoutée,
- notes de migration si la structure des commandes/events change.
Remarques finales
-----------------
- Ce fichier est la source de vérité pour les agents et contributeurs. Pour toute modification structurelle majeure (réorganisation de dossiers, changement d'API interne), mettez à jour `AGENT.md` et ouvrez une PR dédiée.
+13 -11
View File
@@ -79,23 +79,25 @@ An example with two bot services is available in `docker-compose.multi-bot.examp
## Architecture ## Architecture
- `src/types/command.ts`: strict command schema - `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown
- `src/core/commands/defineCommand.ts`: default command completion - `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute)
- `src/core/commands/registry.ts`: trigger/name mapping generated from i18n dictionaries - `src/features/presence/*`: presence runtime, panel orchestration, repository contract
- `src/core/commands/argParser.ts`: prefix/slash args parsing from schema - `src/features/memberMessages/*`: member-message dispatch, panel orchestration, image rendering, repository contract
- `src/core/execution/CommandExecutor.ts`: unified pipeline (permissions/cooldown/execute) - `src/core/commands/*`: registry, parsing, slash payload, usage helpers
- `src/handlers/prefixHandler.ts`: prefix entrypoint - `src/core/execution/CommandExecutor.ts`: unified execution pipeline (permissions/cooldown/error handling)
- `src/handlers/slashHandler.ts`: slash entrypoint - `src/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry)
- `src/database/presence/presenceStore.ts`: PostgreSQL presence storage - `src/database/stores/*`: PostgreSQL store implementations
- `src/types/presenceTypes.ts`: shared presence types/validation - `src/database/dbLifecycle.ts`: centralized DB init/shutdown lifecycle
- `src/validators/*`: business validation/sanitization
- `src/types/*`: pure shared types and contracts
- `src/i18n/*.json`: external i18n dictionaries - `src/i18n/*.json`: external i18n dictionaries
- `src/commands/*`: business commands only (`execute`, optional `cooldown`)
## Included Commands ## Included Commands
- `kiss` (`fun`) with required `user` arg - `kiss` (`fun`) with required `user` arg
- `ping` (`utility`) - `ping` (`utility`)
- `advanced` (`utility`) with full argument/permission example - `welcome` (`utility`) interactive welcome-message panel
- `goodbye` (`utility`) interactive goodbye-message panel
- `presence` (`utility`) with interactive status/activity/text panel - `presence` (`utility`) with interactive status/activity/text panel
- `help` (`core`) with auto category and usage generation - `help` (`core`) with auto category and usage generation
+134
View File
@@ -0,0 +1,134 @@
import { Client, GatewayIntentBits } from "discord.js";
import { Pool } from "pg";
import type { AppFeatureServices } from "./container.js";
import { createCommandList } from "../commands/index.js";
import { env } from "../config/env.js";
import { CommandRegistry } from "../core/commands/registry.js";
import { CommandExecutor } from "../core/execution/CommandExecutor.js";
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
import { registerEvents } from "../events/index.js";
import { createPrefixHandler } from "../handlers/prefixHandler.js";
import { createSlashHandler } from "../handlers/slashHandler.js";
import { I18nService } from "../i18n/index.js";
import { MemberMessageService } from "../features/memberMessages/service.js";
import { PresenceService } from "../features/presence/service.js";
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
export const bootstrap = async (): Promise<void> => {
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const presenceStore = new PostgresPresenceStore(pool);
const memberMessageStore = new PostgresMemberMessageStore(pool);
const dbLifecycle = new DatabaseLifecycle(
[
{ name: "presenceStore", init: () => presenceStore.init() },
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
],
async () => {
await pool.end();
},
);
const services: AppFeatureServices = {
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
memberMessageService: new MemberMessageService(memberMessageStore),
};
let shuttingDown = false;
let client: Client | null = null;
const shutdown = async (signal: string, exitCode = 0): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`[shutdown] ${signal}`);
if (client) {
client.destroy();
}
await services.presenceService.shutdown().catch((error) => {
console.error("[shutdown] presence service close failed", error);
});
await dbLifecycle.shutdown().catch((error) => {
console.error("[shutdown] database shutdown failed", error);
});
process.exit(exitCode);
};
try {
await dbLifecycle.init();
bindGracefulShutdown((signal) => shutdown(signal));
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
console.error("[process] uncaught exception", error);
});
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
const executor = new CommandExecutor();
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
const onSlashInteraction = createSlashHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry, services);
await client.login(env.DISCORD_TOKEN);
} catch (error) {
await services.presenceService.shutdown().catch((closeError) => {
console.error("[boot] failed to close presence service", closeError);
});
await dbLifecycle.shutdown().catch((closeError) => {
console.error("[boot] failed to shutdown database", closeError);
});
throw error;
}
};
+7
View File
@@ -0,0 +1,7 @@
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { PresenceService } from "../features/presence/service.js";
export interface AppFeatureServices {
presenceService: PresenceService;
memberMessageService: MemberMessageService;
}
+5 -3
View File
@@ -7,10 +7,12 @@
import { PermissionFlagsBits } from "discord.js"; import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js"; import { defineCommand } from "../core/commands/defineCommand.js";
import { createMemberMessageExecute } from "./memberMessagePanel.js"; import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { I18nService } from "../i18n/index.js";
/** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */ /** Commande `goodbye` — ouvre le panneau de configuration des messages 'goodbye'. */
export const goodbyeCommand = defineCommand({ export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
meta: { meta: {
name: "goodbye", name: "goodbye",
category: "utility", category: "utility",
@@ -22,5 +24,5 @@ export const goodbyeCommand = defineCommand({
descriptionKey: "examples.slash", descriptionKey: "examples.slash",
}, },
], ],
execute: createMemberMessageExecute("goodbye"), execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n),
}); });
+9 -7
View File
@@ -6,19 +6,21 @@
*/ */
import { helpCommand } from "./help.js"; import { helpCommand } from "./help.js";
import { kissCommand } from "./kiss.js"; import { kissCommand } from "./kiss.js";
import { goodbyeCommand } from "./goodbye.js"; import { createGoodbyeCommand } from "./goodbye.js";
import { presenceCommand } from "./presence.js"; import { createPresenceCommand } from "./presence.js";
import { pingCommand } from "./ping.js"; import { pingCommand } from "./ping.js";
import { welcomeCommand } from "./welcome.js"; import { createWelcomeCommand } from "./welcome.js";
import type { AppFeatureServices } from "../app/container.js";
import type { I18nService } from "../i18n/index.js";
import type { BotCommand } from "../types/command.js"; import type { BotCommand } from "../types/command.js";
/** CommandList: tableau ordonné des commandes disponibles. */ /** CommandList: tableau ordonné des commandes disponibles. */
export const commandList: BotCommand[] = [ export const createCommandList = (services: AppFeatureServices, i18n: I18nService): BotCommand[] => [
kissCommand, kissCommand,
pingCommand, pingCommand,
welcomeCommand, createWelcomeCommand(services.memberMessageService, i18n),
goodbyeCommand, createGoodbyeCommand(services.memberMessageService, i18n),
presenceCommand, createPresenceCommand(services.presenceService),
helpCommand, helpCommand,
]; ];
+4 -642
View File
@@ -1,407 +1,8 @@
/**
* Module `presence` — gestion des présences du bot
*
* Contient la logique de timers, rotation d'activités, rendu de templates
* et l'UI panel d'administration. Le module expose des helpers pour restaurer
* l'état (`restorePresenceFromStorage`), arrêter les timers (`shutdownPresenceRuntime`)
* et la commande `presence` qui affiche le panneau.
*/
import {
ActivityType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
ModalBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
TextInputBuilder,
TextInputStyle,
type Client,
type Message,
} from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js"; import { defineCommand } from "../core/commands/defineCommand.js";
import { env } from "../config/env.js"; import { createPresenceCommandExecute } from "../features/presence/commandPanel.js";
import type { import type { PresenceService } from "../features/presence/service.js";
DiscordPresenceStatus,
PresenceActivityTypeValue,
PresenceCustomIds,
PresencePanelSession,
PresenceRuntimeState,
PresenceState,
PresenceStatusValue,
} from "../types/presence.js";
import { getPresenceStore } from "../database/presence/presenceStore.js";
import {
PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS,
containsPresenceTemplateVariables,
getPresenceTemplateHelpText,
renderPresenceTemplate,
} from "../services/presence/presenceTemplateVariables.js";
import {
PRESENCE_ACTIVITY_TYPES,
PRESENCE_STATUSES,
MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
createDefaultPresenceState,
isPresenceActivityTypeValue,
isPresenceRotationIntervalSecondsValue,
isPresenceStatusValue,
sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../types/presenceTypes.js";
import type { CommandExecutionContext } from "../types/command.js";
const presenceRuntimeByBotId = new Map<string, PresenceRuntimeState>(); export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({
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;
};
/**
* Stoppe toutes les tâches runtime liées aux présences et libère les sessions.
*/
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,
STREAMING: ActivityType.Streaming,
WATCHING: ActivityType.Watching,
LISTENING: ActivityType.Listening,
COMPETING: ActivityType.Competing,
CUSTOM: ActivityType.Custom,
};
const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatus =>
status === "streaming" ? "online" : status;
const resolveBotId = (client: Client): string | null => client.user?.id ?? null;
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 (runtimeState.activePresenceTextIndex >= activityTexts.length) {
runtimeState.activePresenceTextIndex = 0;
}
};
const getActivePresenceTemplateText = (state: PresenceState, runtimeState: PresenceRuntimeState): string => {
normalizePresenceActivityState(state, runtimeState);
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
};
const hasTemplateVariablesInPresenceState = (state: PresenceState, runtimeState: PresenceRuntimeState): boolean => {
normalizePresenceActivityState(state, runtimeState);
return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
};
const syncDynamicPresenceTimers = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
normalizePresenceActivityState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (state.activity.texts.length > 1) {
runtimeState.presenceRotationTimer = setInterval(() => {
normalizePresenceActivityState(state, runtimeState);
if (state.activity.texts.length <= 1) {
runtimeState.activePresenceTextIndex = 0;
return;
}
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000);
runtimeState.presenceRotationTimer.unref?.();
}
if (!hasTemplateVariablesInPresenceState(state, runtimeState)) {
return;
}
runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
runtimeState.dynamicPresenceRefreshTimer.unref?.();
};
const loadPresenceState = async (client: Client): Promise<PresenceState> => {
const botId = resolveBotId(client);
if (!botId) {
return createDefaultPresenceState();
}
return getPresenceStore().getByBotId(botId);
};
const savePresenceState = async (client: Client, state: PresenceState): Promise<void> => {
const botId = resolveBotId(client);
if (!botId) {
return;
}
await getPresenceStore().upsertByBotId(botId, state);
};
const applyPresenceState = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
if (!client.user) {
return;
}
normalizePresenceActivityState(state, runtimeState);
const status = resolveDiscordStatus(state.status);
const templateText = getActivePresenceTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Streaming,
name: text,
url: env.PRESENCE_STREAM_URL,
},
],
});
return;
}
if (state.activity.type === "CUSTOM") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Custom,
name: "Custom Status",
state: text,
},
],
});
return;
}
client.user.setPresence({
status,
activities: [
{
type: DISCORD_ACTIVITY_TYPES[state.activity.type],
name: text,
},
],
});
};
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, status: PresenceStatusValue): string =>
ctx.ct(`ui.status.options.${status}.label`);
const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string =>
ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (
ctx: CommandExecutionContext,
state: PresenceState,
runtimeState: PresenceRuntimeState,
): string => {
normalizePresenceActivityState(state, runtimeState);
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(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type),
activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
});
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
};
const buildContainer = (
ctx: CommandExecutionContext,
state: PresenceState,
customIds: PresenceCustomIds,
runtimeState: PresenceRuntimeState,
disabled = false,
): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.statusSelect)
.setPlaceholder(ctx.ct("ui.status.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_STATUSES.map((status) => ({
label: statusLabel(ctx, status),
description: ctx.ct(`ui.status.options.${status}.description`),
value: status,
default: status === state.status,
})),
);
const activitySelect = new StringSelectMenuBuilder()
.setCustomId(customIds.activitySelect)
.setPlaceholder(ctx.ct("ui.activity.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_ACTIVITY_TYPES.map((activityType) => ({
label: activityLabel(ctx, activityType),
description: ctx.ct(`ui.activity.options.${activityType}.description`),
value: activityType,
default: activityType === state.activity.type,
})),
);
const textButton = new ButtonBuilder()
.setCustomId(customIds.textButton)
.setLabel(ctx.ct("ui.textButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, state, runtimeState)),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
);
return container;
};
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
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);
};
/**
* Charge et applique l'état de présence depuis le stockage pour un client.
*/
export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
const state = await loadPresenceState(client);
const runtimeState = resolveRuntimeState(client);
runtimeState.activePresenceTextIndex = 0;
applyPresenceState(client, state, runtimeState);
syncDynamicPresenceTimers(client, state, runtimeState);
};
/**
* Commande `presence` — ouvre le panneau de gestion de la présence pour le bot.
*/
export const presenceCommand = defineCommand({
meta: { meta: {
name: "presence", name: "presence",
category: "utility", category: "utility",
@@ -412,244 +13,5 @@ export const presenceCommand = defineCommand({
descriptionKey: "examples.slash", descriptionKey: "examples.slash",
}, },
], ],
execute: async (ctx) => { execute: createPresenceCommandExecute(presenceService),
const runtimeState = resolveRuntimeState(ctx.client);
const state = await loadPresenceState(ctx.client);
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, runtimeState)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
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"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isStringSelectMenu()) {
if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.status = nextStatus;
await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
return;
}
if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.activity.type = nextType;
await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) {
normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
runtimeState.activePresenceTextIndex = 0;
await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
if (interaction.customId === customIds.intervalButton) {
normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
const nextSeconds = Number(rawSeconds);
if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
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, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
return;
}
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
}
});
collector.on("end", async () => {
const currentPanel = runtimeState.activePanelsByUserId.get(ownerId);
if (currentPanel?.collector === collector) {
runtimeState.activePanelsByUserId.delete(ownerId);
}
await disablePanel();
});
},
}); });
+5 -3
View File
@@ -7,10 +7,12 @@
import { PermissionFlagsBits } from "discord.js"; import { PermissionFlagsBits } from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js"; import { defineCommand } from "../core/commands/defineCommand.js";
import { createMemberMessageExecute } from "./memberMessagePanel.js"; import { createMemberMessagePanelExecute } from "../features/memberMessages/commandPanel.js";
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { I18nService } from "../i18n/index.js";
/** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */ /** Commande `welcome` — ouvre le panneau de configuration des messages 'welcome'. */
export const welcomeCommand = defineCommand({ export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({
meta: { meta: {
name: "welcome", name: "welcome",
category: "utility", category: "utility",
@@ -22,5 +24,5 @@ export const welcomeCommand = defineCommand({
descriptionKey: "examples.slash", descriptionKey: "examples.slash",
}, },
], ],
execute: createMemberMessageExecute("welcome"), execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n),
}); });
@@ -0,0 +1,45 @@
export interface ComponentPanelSession {
collector: {
stop: (reason?: string) => void;
};
disable: () => Promise<void>;
}
export class ComponentSessionRegistry<T extends ComponentPanelSession> {
private readonly sessions = new Map<string, T>();
public get(key: string): T | undefined {
return this.sessions.get(key);
}
public async replace(key: string, next: T): Promise<void> {
const existing = this.sessions.get(key);
if (existing) {
existing.collector.stop("replaced");
await existing.disable().catch(() => undefined);
}
this.sessions.set(key, next);
}
public deleteIfCollectorMatch(key: string, collector: T["collector"]): void {
const existing = this.sessions.get(key);
if (!existing) {
return;
}
if (existing.collector === collector) {
this.sessions.delete(key);
}
}
public async stopAll(reason = "shutdown"): Promise<void> {
const entries = [...this.sessions.values()];
this.sessions.clear();
for (const session of entries) {
session.collector.stop(reason);
await session.disable().catch(() => undefined);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Message } from "discord.js";
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
export const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
+26
View File
@@ -0,0 +1,26 @@
export interface DatabaseInitializable {
name: string;
init: () => Promise<void>;
}
export class DatabaseLifecycle {
public constructor(
private readonly initializables: DatabaseInitializable[],
private readonly shutdownFn: () => Promise<void>,
) {}
public async init(): Promise<void> {
for (const resource of this.initializables) {
try {
await resource.init();
} catch (error) {
await this.shutdownFn().catch(() => undefined);
throw new Error(`[db:init] ${resource.name} failed`, { cause: error });
}
}
}
public async shutdown(): Promise<void> {
await this.shutdownFn();
}
}
@@ -1,6 +1,5 @@
import { Pool } from "pg"; import type { Pool } from "pg";
import { env } from "../../config/env.js";
import type { import type {
MemberMessageConfig, MemberMessageConfig,
MemberMessageKind, MemberMessageKind,
@@ -9,7 +8,8 @@ import type {
import { import {
createDefaultMemberMessageConfig, createDefaultMemberMessageConfig,
isMemberMessageRenderTypeValue, isMemberMessageRenderTypeValue,
} from "../../types/memberMessageTypes.js"; } from "../../validators/memberMessages.js";
import type { MemberMessageRepository } from "../../features/memberMessages/repository.js";
const tableSql = ` const tableSql = `
CREATE TABLE IF NOT EXISTS bot_member_message_configs ( CREATE TABLE IF NOT EXISTS bot_member_message_configs (
@@ -45,7 +45,7 @@ const toConfig = (row: MemberMessageRow): MemberMessageConfig => {
}; };
}; };
class MemberMessageStore { export class PostgresMemberMessageStore implements MemberMessageRepository {
public constructor(private readonly pool: Pool) {} public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> { public async init(): Promise<void> {
@@ -101,50 +101,10 @@ class MemberMessageStore {
); );
} }
public async close(): Promise<void> { public async deleteByBotGuild(botId: string, guildId: string): Promise<void> {
await this.pool.end(); await this.pool.query(
"DELETE FROM bot_member_message_configs WHERE bot_id = $1 AND guild_id = $2",
[botId, guildId],
);
} }
} }
let store: MemberMessageStore | null = null;
export const initMemberMessageStore = async (): Promise<MemberMessageStore> => {
if (store) {
return store;
}
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const nextStore = new MemberMessageStore(pool);
try {
await nextStore.init();
store = nextStore;
return nextStore;
} catch (error) {
await pool.end().catch(() => {
// Ignore close errors; the original init error should be preserved.
});
throw error;
}
};
export const getMemberMessageStore = (): MemberMessageStore => {
if (!store) {
throw new Error("MemberMessageStore is not initialized. Call initMemberMessageStore() during bootstrap.");
}
return store;
};
export const shutdownMemberMessageStore = async (): Promise<void> => {
if (!store) {
return;
}
await store.close();
store = null;
};
@@ -1,6 +1,5 @@
import { Pool } from "pg"; import type { Pool } from "pg";
import { env } from "../../config/env.js";
import type { import type {
PresenceActivityTypeValue, PresenceActivityTypeValue,
PresenceRow, PresenceRow,
@@ -15,7 +14,8 @@ import {
sanitizeActivityText, sanitizeActivityText,
sanitizeActivityTexts, sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds, sanitizePresenceRotationIntervalSeconds,
} from "../../types/presenceTypes.js"; } from "../../validators/presence.js";
import type { PresenceRepository } from "../../features/presence/repository.js";
const tableSql = ` const tableSql = `
CREATE TABLE IF NOT EXISTS bot_presence_states ( CREATE TABLE IF NOT EXISTS bot_presence_states (
@@ -88,7 +88,7 @@ const toPresenceState = (row: PresenceRow): PresenceState | null => {
}; };
}; };
class PresenceStore { export class PostgresPresenceStore implements PresenceRepository {
public constructor(private readonly pool: Pool) {} public constructor(private readonly pool: Pool) {}
public async init(): Promise<void> { public async init(): Promise<void> {
@@ -147,50 +147,4 @@ class PresenceStore {
], ],
); );
} }
public async close(): Promise<void> {
await this.pool.end();
}
} }
let store: PresenceStore | null = null;
export const initPresenceStore = async (): Promise<PresenceStore> => {
if (store) {
return store;
}
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const nextStore = new PresenceStore(pool);
try {
await nextStore.init();
store = nextStore;
return nextStore;
} catch (error) {
await pool.end().catch(() => {
// Ignore close errors; the original init error is the one we want to surface.
});
throw error;
}
};
export const getPresenceStore = (): PresenceStore => {
if (!store) {
throw new Error("PresenceStore is not initialized. Call initPresenceStore() during bootstrap.");
}
return store;
};
export const shutdownPresenceStore = async (): Promise<void> => {
if (!store) {
return;
}
await store.close();
store = null;
};
+12 -6
View File
@@ -1,12 +1,18 @@
import { Events, type Client } from "discord.js"; import { Events, type Client } from "discord.js";
/** import type { MemberMessageService } from "../features/memberMessages/service.js";
* Enregistre le listener `guildDelete` (bot retiré d'un serveur).
* export const registerGuildDelete = (client: Client, memberMessageService: MemberMessageService): void => {
* Action minimale: log pour monitoring ; peut être étendu (cleanup, etc.).
*/
export const registerGuildDelete = (client: Client): void => {
client.on(Events.GuildDelete, (guild) => { client.on(Events.GuildDelete, (guild) => {
console.log(`[event:guildDelete] left guild ${guild.id} (${guild.name})`); console.log(`[event:guildDelete] left guild ${guild.id} (${guild.name})`);
const botId = memberMessageService.resolveBotId(client);
if (!botId) {
return;
}
void memberMessageService.cleanupGuild(botId, guild.id).catch((error) => {
console.error("[event:guildDelete] failed to cleanup guild config", error);
});
}); });
}; };
+9 -8
View File
@@ -1,14 +1,15 @@
import { Events, type Client } from "discord.js"; import { Events, type Client } from "discord.js";
import type { I18nService } from "../i18n/index.js";
import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js";
/** import type { MemberMessageService } from "../features/memberMessages/service.js";
* Enregistre le listener `guildMemberAdd` et déclenche l'envoi d'un message import type { I18nService } from "../i18n/index.js";
* de bienvenue via `dispatchMemberMessage`.
*/ export const registerGuildMemberAdd = (
export const registerGuildMemberAdd = (client: Client, i18n: I18nService): void => { client: Client,
i18n: I18nService,
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberAdd, (member) => { client.on(Events.GuildMemberAdd, (member) => {
void dispatchMemberMessage({ void memberMessageService.dispatch({
client, client,
i18n, i18n,
guild: member.guild, guild: member.guild,
+9 -8
View File
@@ -1,14 +1,15 @@
import { Events, type Client } from "discord.js"; import { Events, type Client } from "discord.js";
import type { I18nService } from "../i18n/index.js";
import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js";
/** import type { MemberMessageService } from "../features/memberMessages/service.js";
* Enregistre le listener `guildMemberRemove` et déclenche l'envoi d'un message import type { I18nService } from "../i18n/index.js";
* d'au revoir via `dispatchMemberMessage`.
*/ export const registerGuildMemberRemove = (
export const registerGuildMemberRemove = (client: Client, i18n: I18nService): void => { client: Client,
i18n: I18nService,
memberMessageService: MemberMessageService,
): void => {
client.on(Events.GuildMemberRemove, (member) => { client.on(Events.GuildMemberRemove, (member) => {
void dispatchMemberMessage({ void memberMessageService.dispatch({
client, client,
i18n, i18n,
guild: member.guild, guild: member.guild,
+17 -20
View File
@@ -1,37 +1,34 @@
import type { Client, Message, ChatInputCommandInteraction } from "discord.js"; import type { ChatInputCommandInteraction, Client, Message } from "discord.js";
import type { I18nService } from "../i18n/index.js";
import { registerMessageCreate } from "./messageCreate.js"; import type { AppFeatureServices } from "../app/container.js";
import { registerInteractionCreate } from "./interactionCreate.js"; import type { CommandRegistry } from "../core/commands/registry.js";
import { registerGuildMemberAdd } from "./guildMemberAdd.js"; import type { I18nService } from "../i18n/index.js";
import { registerGuildMemberRemove } from "./guildMemberRemove.js";
import { registerGuildCreate } from "./guildCreate.js"; import { registerGuildCreate } from "./guildCreate.js";
import { registerGuildDelete } from "./guildDelete.js"; import { registerGuildDelete } from "./guildDelete.js";
import { registerGuildMemberAdd } from "./guildMemberAdd.js";
import { registerGuildMemberRemove } from "./guildMemberRemove.js";
import { registerInteractionCreate } from "./interactionCreate.js";
import { registerMessageCreate } from "./messageCreate.js";
import { registerClientReady } from "./ready.js"; import { registerClientReady } from "./ready.js";
import type { CommandRegistry } from "../core/commands/registry.js";
/**
* Regroupe l'enregistrement des événements Discord les plus courants.
*
* @param client - instance du Client Discord
* @param i18n - instance de service i18n
* @param handlers - objets contenant les handlers pour message/interaction
*/
export const registerEvents = ( export const registerEvents = (
client: Client, client: Client,
i18n: I18nService, i18n: I18nService,
handlers: { onPrefixMessage: (m: Message) => Promise<void>; onSlashInteraction: (i: ChatInputCommandInteraction) => Promise<void> }, handlers: {
onPrefixMessage: (m: Message) => Promise<void>;
onSlashInteraction: (i: ChatInputCommandInteraction) => Promise<void>;
},
registry: CommandRegistry, registry: CommandRegistry,
services: AppFeatureServices,
): void => { ): void => {
registerMessageCreate(client, handlers.onPrefixMessage); registerMessageCreate(client, handlers.onPrefixMessage);
registerInteractionCreate(client, handlers.onSlashInteraction); registerInteractionCreate(client, handlers.onSlashInteraction);
registerGuildMemberAdd(client, i18n); registerGuildMemberAdd(client, i18n, services.memberMessageService);
registerGuildMemberRemove(client, i18n); registerGuildMemberRemove(client, i18n, services.memberMessageService);
registerGuildCreate(client); registerGuildCreate(client);
registerGuildDelete(client); registerGuildDelete(client, services.memberMessageService);
// Ready: tâches à exécuter au démarrage du client registerClientReady(client, registry, i18n, services.presenceService);
registerClientReady(client, registry, i18n);
}; };
+2 -12
View File
@@ -11,21 +11,11 @@ export const registerInteractionCreate = (
onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>, onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>,
): void => { ): void => {
client.on(Events.InteractionCreate, (interaction) => { client.on(Events.InteractionCreate, (interaction) => {
// On ne traite que les `ChatInputCommand` (slash) if (!interaction.isChatInputCommand()) {
if (!interaction || typeof (interaction as any).isChatInputCommand !== "function") {
return; return;
} }
try { void onSlashInteraction(interaction).catch((error) => {
if (!(interaction as any).isChatInputCommand()) {
return;
}
} catch {
return;
}
// Cast sécurisé par le test `isChatInputCommand()` effectué cidessus
void onSlashInteraction(interaction as ChatInputCommandInteraction).catch((error) => {
console.error("[event:interactionCreate] handler failed", error); console.error("[event:interactionCreate] handler failed", error);
}); });
}); });
+10 -9
View File
@@ -1,20 +1,21 @@
import { Events, type Client } from "discord.js"; import { Events, type Client } from "discord.js";
import { deployApplicationCommands } from "../core/commands/deploy.js";
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { restorePresenceFromStorage } from "../commands/presence.js"; import { deployApplicationCommands } from "../core/commands/deploy.js";
import type { CommandRegistry } from "../core/commands/registry.js"; import type { CommandRegistry } from "../core/commands/registry.js";
import type { PresenceService } from "../features/presence/service.js";
import type { I18nService } from "../i18n/index.js"; import type { I18nService } from "../i18n/index.js";
/** export const registerClientReady = (
* Attache le listener `ready` et exécute les tâches post-démarrage: client: Client,
* - restauration de la présence registry: CommandRegistry,
* - (optionnel) déploiement des commandes slash i18n: I18nService,
*/ presenceService: PresenceService,
export const registerClientReady = (client: Client, registry: CommandRegistry, i18n: I18nService): void => { ): void => {
client.once(Events.ClientReady, async () => { client.once(Events.ClientReady, async () => {
console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`);
try { try {
await restorePresenceFromStorage(client); await presenceService.restoreFromStorage(client);
} catch (error) { } catch (error) {
console.error("[ready] failed to restore bot presence", error); console.error("[ready] failed to restore bot presence", error);
} }
@@ -1,14 +1,3 @@
/**
* Panneau de configuration pour les messages de bienvenue / départ
*
* Contient la logique UI (Components v2), collectors et sessions pour afficher
* et gérer un panneau interactif permettant de configurer les messages
* d'accueil et d'au revoir par guild.
*
* Export:
* - `createMemberMessageExecute(kind)` : factory utilisée par les commandes
* `welcome` et `goodbye` pour attacher le panneau.
*/
import { import {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
@@ -19,18 +8,12 @@ import {
MessageFlags, MessageFlags,
StringSelectMenuBuilder, StringSelectMenuBuilder,
TextDisplayBuilder, TextDisplayBuilder,
type Message,
} from "discord.js"; } from "discord.js";
import { env } from "../config/env.js"; import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import { I18nService } from "../i18n/index.js"; import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js"; import type { I18nService } from "../../i18n/index.js";
import { getMemberMessageStore } from "../database/memberMessages/memberMessageStore.js"; import type { CommandExecutionContext } from "../../types/command.js";
import {
MEMBER_MESSAGE_RENDER_TYPES,
isMemberMessageRenderTypeValue,
} from "../types/memberMessageTypes.js";
import type { CommandExecutionContext } from "../types/command.js";
import type { import type {
MemberMessageConfig, MemberMessageConfig,
MemberMessageCustomIds, MemberMessageCustomIds,
@@ -38,14 +21,17 @@ import type {
MemberMessagePanelSession, MemberMessagePanelSession,
MemberMessagePanelUiState, MemberMessagePanelUiState,
MemberMessageRenderType, MemberMessageRenderType,
} from "../types/memberMessages.js"; } from "../../types/memberMessages.js";
import {
MEMBER_MESSAGE_RENDER_TYPES,
isMemberMessageRenderTypeValue,
} from "../../validators/memberMessages.js";
import type { MemberMessageService } from "./service.js";
const memberMessageI18n = new I18nService(env.DEFAULT_LANG); const panelSessions = new ComponentSessionRegistry<MemberMessagePanelSession>();
const activePanelsByUser = new Map<string, MemberMessagePanelSession>(); const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => {
return `${kind}:${botId}:${guildId}:${userId}`;
const panelSessionKey = (kind: MemberMessageKind, guildId: string, userId: string): string => {
return `${kind}:${guildId}:${userId}`;
}; };
const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => { const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => {
@@ -61,32 +47,6 @@ const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => {
}; };
}; };
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
const statusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => { const statusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => {
return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled"); return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled");
}; };
@@ -201,13 +161,11 @@ const testFeedbackKey = (reason: string): string => {
} }
}; };
/** export const createMemberMessagePanelExecute = (
* Factory qui crée l'exécuteur de commande pour un `MemberMessageKind` donné. kind: MemberMessageKind,
* memberMessageService: MemberMessageService,
* Le handler retourné gère l'affichage du panneau, la collecte d'interactions panelI18n: I18nService,
* et la persistance de la configuration via `memberMessageStore`. ) => {
*/
export const createMemberMessageExecute = (kind: MemberMessageKind) => {
return async (ctx: CommandExecutionContext): Promise<void> => { return async (ctx: CommandExecutionContext): Promise<void> => {
if (!ctx.guild) { if (!ctx.guild) {
await ctx.reply(ctx.ct("responses.guildOnly")); await ctx.reply(ctx.ct("responses.guildOnly"));
@@ -215,13 +173,13 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => {
} }
const guild = ctx.guild; const guild = ctx.guild;
const botId = ctx.client.user?.id; const botId = memberMessageService.resolveBotId(ctx.client);
if (!botId) { if (!botId) {
await ctx.reply(ctx.t("errors.execution")); await ctx.reply(ctx.t("errors.execution"));
return; return;
} }
const config = await getMemberMessageStore().getByBotGuildKind(botId, guild.id, kind); const config = await memberMessageService.getConfig(botId, guild.id, kind);
const customIds = createCustomIds(kind); const customIds = createCustomIds(kind);
const uiState: MemberMessagePanelUiState = { const uiState: MemberMessagePanelUiState = {
channelPickerOpen: false, channelPickerOpen: false,
@@ -239,16 +197,10 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => {
} }
const ownerId = ctx.user.id; const ownerId = ctx.user.id;
const sessionKey = panelSessionKey(kind, guild.id, ownerId); const sessionKey = panelSessionKey(kind, botId, guild.id, ownerId);
const existingPanel = activePanelsByUser.get(sessionKey);
if (existingPanel) {
existingPanel.collector.stop("replaced");
await existingPanel.disable().catch(() => undefined);
activePanelsByUser.delete(sessionKey);
}
const saveConfig = async (): Promise<void> => { const saveConfig = async (): Promise<void> => {
await getMemberMessageStore().upsertByBotGuildKind(botId, guild.id, kind, config); await memberMessageService.saveConfig(botId, guild.id, kind, config);
}; };
const disablePanel = async (): Promise<void> => { const disablePanel = async (): Promise<void> => {
@@ -261,7 +213,7 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => {
}; };
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 }); const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
activePanelsByUser.set(sessionKey, { await panelSessions.replace(sessionKey, {
collector, collector,
disable: disablePanel, disable: disablePanel,
}); });
@@ -307,9 +259,9 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => {
if (interaction.customId === customIds.testButton) { if (interaction.customId === customIds.testButton) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const testResult = await dispatchMemberMessage({ const testResult = await memberMessageService.dispatch({
client: ctx.client, client: ctx.client,
i18n: memberMessageI18n, i18n: panelI18n,
guild, guild,
user: ctx.user, user: ctx.user,
kind, kind,
@@ -400,11 +352,7 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => {
}); });
collector.on("end", async () => { collector.on("end", async () => {
const currentPanel = activePanelsByUser.get(sessionKey); panelSessions.deleteIfCollectorMatch(sessionKey, collector);
if (currentPanel?.collector === collector) {
activePanelsByUser.delete(sessionKey);
}
await disablePanel(); await disablePanel();
}); });
}; };
+10
View File
@@ -0,0 +1,10 @@
import type {
MemberMessageConfig,
MemberMessageKind,
} from "../../types/memberMessages.js";
export interface MemberMessageRepository {
getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig>;
upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise<void>;
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
}
@@ -15,13 +15,15 @@ import type { SupportedLang } from "../../types/command.js";
import type { import type {
DispatchMemberMessageInput, DispatchMemberMessageInput,
DispatchMemberMessageResult, DispatchMemberMessageResult,
MemberMessageConfig,
MemberMessageKind, MemberMessageKind,
MemberMessageRenderType, MemberMessageRenderType,
SendableChannel, SendableChannel,
TemplateSuffix, TemplateSuffix,
} from "../../types/memberMessages.js"; } from "../../types/memberMessages.js";
import { renderMemberMessageImage } from "./memberMessageImage.js"; import { renderMemberMessageImage } from "./imageRenderer.js";
import { getMemberMessageStore } from "../../database/memberMessages/memberMessageStore.js"; import type { MemberMessageRepository } from "./repository.js";
export type { export type {
DispatchMemberMessageFailureReason, DispatchMemberMessageFailureReason,
DispatchMemberMessageResult, DispatchMemberMessageResult,
@@ -180,86 +182,111 @@ const buildMemberMessagePayload = async (
}; };
}; };
export const dispatchMemberMessage = async (input: DispatchMemberMessageInput): Promise<DispatchMemberMessageResult> => { export class MemberMessageService {
const botId = input.client.user?.id; public constructor(private readonly repository: MemberMessageRepository) {}
if (!botId) {
return { public resolveBotId(client: DispatchMemberMessageInput["client"]): string | null {
sent: false, return client.user?.id ?? null;
reason: "bot_not_ready",
channelId: null,
};
} }
const config = await getMemberMessageStore().getByBotGuildKind(botId, input.guild.id, input.kind); public async getConfig(botId: string, guildId: string, kind: MemberMessageKind) {
return this.repository.getByBotGuildKind(botId, guildId, kind);
if (!input.ignoreEnabled && !config.enabled) {
return {
sent: false,
reason: "disabled",
channelId: config.channelId,
};
} }
if (!config.channelId) { public async saveConfig(
return { botId: string,
sent: false, guildId: string,
reason: "missing_channel", kind: MemberMessageKind,
channelId: null, config: MemberMessageConfig,
}; ): Promise<void> {
await this.repository.upsertByBotGuildKind(botId, guildId, kind, config);
} }
const channel = await input.guild.channels.fetch(config.channelId).catch(() => null); public async cleanupGuild(botId: string, guildId: string): Promise<void> {
if (!channel) { await this.repository.deleteByBotGuild(botId, guildId);
return {
sent: false,
reason: "channel_not_found",
channelId: config.channelId,
};
} }
if (!hasSendMethod(channel)) { public async dispatch(input: DispatchMemberMessageInput): Promise<DispatchMemberMessageResult> {
return { const botId = this.resolveBotId(input.client);
sent: false, if (!botId) {
reason: "channel_not_sendable",
channelId: config.channelId,
};
}
const me = input.guild.members.me;
if (me && "permissionsFor" in channel && typeof channel.permissionsFor === "function") {
const permissions = channel.permissionsFor(me);
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
return { return {
sent: false, sent: false,
reason: "missing_permissions", reason: "bot_not_ready",
channelId: config.channelId, channelId: null,
}; };
} }
}
const lang = input.i18n?.resolveLang(input.guild.preferredLocale ?? null) ?? "en"; const config = await this.repository.getByBotGuildKind(botId, input.guild.id, input.kind);
const payload = await buildMemberMessagePayload(input.i18n, lang, input.kind, config.messageType, input.guild, input.user);
try { if (!input.ignoreEnabled && !config.enabled) {
await channel.send(payload);
return {
sent: true,
reason: "sent",
channelId: config.channelId,
};
} catch (error) {
if (hasCode(error) && error.code === 50013) {
return { return {
sent: false, sent: false,
reason: "missing_permissions", reason: "disabled",
channelId: config.channelId, channelId: config.channelId,
}; };
} }
return { if (!config.channelId) {
sent: false, return {
reason: "send_failed", sent: false,
channelId: config.channelId, reason: "missing_channel",
}; channelId: null,
};
}
const channel = await input.guild.channels.fetch(config.channelId).catch(() => null);
if (!channel) {
return {
sent: false,
reason: "channel_not_found",
channelId: config.channelId,
};
}
if (!hasSendMethod(channel)) {
return {
sent: false,
reason: "channel_not_sendable",
channelId: config.channelId,
};
}
const me = input.guild.members.me;
if (me && "permissionsFor" in channel && typeof channel.permissionsFor === "function") {
const permissions = channel.permissionsFor(me);
if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) {
return {
sent: false,
reason: "missing_permissions",
channelId: config.channelId,
};
}
}
const lang = input.i18n?.resolveLang(input.guild.preferredLocale ?? null) ?? "en";
const payload = await buildMemberMessagePayload(input.i18n, lang, input.kind, config.messageType, input.guild, input.user);
try {
await channel.send(payload);
return {
sent: true,
reason: "sent",
channelId: config.channelId,
};
} catch (error) {
if (hasCode(error) && error.code === 50013) {
return {
sent: false,
reason: "missing_permissions",
channelId: config.channelId,
};
}
return {
sent: false,
reason: "send_failed",
channelId: config.channelId,
};
}
} }
}; }
+374
View File
@@ -0,0 +1,374 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
ModalBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import type { CommandExecutionContext } from "../../types/command.js";
import {
MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
PRESENCE_ACTIVITY_TYPES,
PRESENCE_STATUSES,
isPresenceActivityTypeValue,
isPresenceRotationIntervalSecondsValue,
isPresenceStatusValue,
sanitizeActivityTexts,
} from "../../validators/presence.js";
import { getPresenceTemplateHelpText } from "./templateVariables.js";
import type { PresenceService } from "./service.js";
import type { PresenceCustomIds } from "./types.js";
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
ctx.ct(`ui.status.options.${status}.label`);
const activityLabel = (ctx: CommandExecutionContext, activityType: string): string =>
ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
): string => {
const runtimeState = service.getRuntimeState(ctx.client);
service.normalizeState(state, runtimeState);
const templateText = service.getActiveTemplateText(state, runtimeState);
const activityPreview = service.renderPreview(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type),
activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
});
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
};
const buildContainer = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
customIds: PresenceCustomIds,
disabled = false,
): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.statusSelect)
.setPlaceholder(ctx.ct("ui.status.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_STATUSES.map((status) => ({
label: statusLabel(ctx, status),
description: ctx.ct(`ui.status.options.${status}.description`),
value: status,
default: status === state.status,
})),
);
const activitySelect = new StringSelectMenuBuilder()
.setCustomId(customIds.activitySelect)
.setPlaceholder(ctx.ct("ui.activity.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_ACTIVITY_TYPES.map((activityType) => ({
label: activityLabel(ctx, activityType),
description: ctx.ct(`ui.activity.options.${activityType}.description`),
value: activityType,
default: activityType === state.activity.type,
})),
);
const textButton = new ButtonBuilder()
.setCustomId(customIds.textButton)
.setLabel(ctx.ct("ui.textButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, service, state)),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
);
return container;
};
export const createPresenceCommandExecute = (presenceService: PresenceService) => {
return async (ctx: CommandExecutionContext): Promise<void> => {
const state = await presenceService.loadState(ctx.client);
presenceService.applyState(ctx.client, state);
const customIds = createCustomIds();
const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
const ownerId = ctx.user.id;
const sessionKey = presenceService.panelSessionKey(ctx.client, ownerId);
const disablePanel = async (): Promise<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
await presenceService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isStringSelectMenu()) {
if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.status = nextStatus;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.activity.type = nextType;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
const runtimeState = presenceService.getRuntimeState(ctx.client);
runtimeState.activePresenceTextIndex = 0;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
if (interaction.customId === customIds.intervalButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
const nextSeconds = Number(rawSeconds);
if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
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, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
return;
}
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
}
});
collector.on("end", async () => {
presenceService.deletePanelSessionIfCollectorMatch(sessionKey, collector);
await disablePanel();
});
};
};
+6
View File
@@ -0,0 +1,6 @@
import type { PresenceState } from "../../types/presence.js";
export interface PresenceRepository {
getByBotId(botId: string): Promise<PresenceState>;
upsertByBotId(botId: string, state: PresenceState): Promise<void>;
}
+234
View File
@@ -0,0 +1,234 @@
import { ActivityType, type Client } from "discord.js";
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import type {
PresenceActivityTypeValue,
PresenceState,
PresenceStatusValue,
} from "../../types/presence.js";
import {
createDefaultPresenceState,
sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js";
import {
PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS,
containsPresenceTemplateVariables,
renderPresenceTemplate,
} from "./templateVariables.js";
import type { PresenceRepository } from "./repository.js";
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing,
STREAMING: ActivityType.Streaming,
WATCHING: ActivityType.Watching,
LISTENING: ActivityType.Listening,
COMPETING: ActivityType.Competing,
CUSTOM: ActivityType.Custom,
};
const createRuntimeState = (): PresenceRuntimeState => ({
dynamicPresenceRefreshTimer: null,
presenceRotationTimer: null,
activePresenceTextIndex: 0,
});
const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => {
if (runtimeState.dynamicPresenceRefreshTimer) {
clearInterval(runtimeState.dynamicPresenceRefreshTimer);
runtimeState.dynamicPresenceRefreshTimer = null;
}
if (runtimeState.presenceRotationTimer) {
clearInterval(runtimeState.presenceRotationTimer);
runtimeState.presenceRotationTimer = null;
}
};
const resolveDiscordStatus = (status: PresenceStatusValue): "online" | "idle" | "dnd" | "invisible" => {
return status === "streaming" ? "online" : status;
};
export class PresenceService {
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
private readonly panelSessions = new ComponentSessionRegistry<PresencePanelSession>();
public constructor(
private readonly repository: PresenceRepository,
private readonly streamUrl: string,
) {}
public resolveBotId(client: Client): string | null {
return client.user?.id ?? null;
}
public getRuntimeState(client: Client): PresenceRuntimeState {
const botId = this.resolveBotId(client) ?? "unbound";
const existing = this.runtimeByBotId.get(botId);
if (existing) {
return existing;
}
const next = createRuntimeState();
this.runtimeByBotId.set(botId, next);
return next;
}
public normalizeState(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 (runtimeState.activePresenceTextIndex >= activityTexts.length) {
runtimeState.activePresenceTextIndex = 0;
}
}
public getActiveTemplateText(state: PresenceState, runtimeState: PresenceRuntimeState): string {
this.normalizeState(state, runtimeState);
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
}
public renderPreview(client: Client, templateText: string): string {
return renderPresenceTemplate(client, templateText);
}
public async loadState(client: Client): Promise<PresenceState> {
const botId = this.resolveBotId(client);
if (!botId) {
return createDefaultPresenceState();
}
return this.repository.getByBotId(botId);
}
public applyState(client: Client, state: PresenceState): void {
const runtimeState = this.getRuntimeState(client);
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public async persistAndApply(client: Client, state: PresenceState): Promise<void> {
this.applyState(client, state);
const botId = this.resolveBotId(client);
if (!botId) {
return;
}
await this.repository.upsertByBotId(botId, state);
}
public async restoreFromStorage(client: Client): Promise<void> {
const state = await this.loadState(client);
const runtimeState = this.getRuntimeState(client);
runtimeState.activePresenceTextIndex = 0;
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public panelSessionKey(client: Client, userId: string): string {
return `${this.resolveBotId(client) ?? "unbound"}:${userId}`;
}
public async replacePanelSession(key: string, session: PresencePanelSession): Promise<void> {
await this.panelSessions.replace(key, session);
}
public deletePanelSessionIfCollectorMatch(key: string, collector: PresencePanelSession["collector"]): void {
this.panelSessions.deleteIfCollectorMatch(key, collector);
}
public async shutdown(): Promise<void> {
for (const runtimeState of this.runtimeByBotId.values()) {
clearRuntimeTimers(runtimeState);
}
this.runtimeByBotId.clear();
await this.panelSessions.stopAll("shutdown");
}
private applyPresenceState(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
if (!client.user) {
return;
}
this.normalizeState(state, runtimeState);
const status = resolveDiscordStatus(state.status);
const templateText = this.getActiveTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Streaming,
name: text,
url: this.streamUrl,
},
],
});
return;
}
if (state.activity.type === "CUSTOM") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Custom,
name: "Custom Status",
state: text,
},
],
});
return;
}
client.user.setPresence({
status,
activities: [
{
type: DISCORD_ACTIVITY_TYPES[state.activity.type],
name: text,
},
],
});
}
private syncDynamicPresenceTimers(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
this.normalizeState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (state.activity.texts.length > 1) {
runtimeState.presenceRotationTimer = setInterval(() => {
this.normalizeState(state, runtimeState);
if (state.activity.texts.length <= 1) {
runtimeState.activePresenceTextIndex = 0;
return;
}
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
this.applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000);
runtimeState.presenceRotationTimer.unref?.();
}
const hasKnownVariable = state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
if (!hasKnownVariable) {
return;
}
runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
this.applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
runtimeState.dynamicPresenceRefreshTimer.unref?.();
}
}
@@ -2,7 +2,7 @@ import type { Client } from "discord.js";
import { env } from "../../config/env.js"; import { env } from "../../config/env.js";
import { hasTemplateVariable, renderTemplate } from "../../utils/templateVariables.js"; import { hasTemplateVariable, renderTemplate } from "../../utils/templateVariables.js";
import { sanitizeActivityText } from "../../types/presenceTypes.js"; import { sanitizeActivityText } from "../../validators/presence.js";
export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000; export const PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS = 60_000;
+20
View File
@@ -0,0 +1,20 @@
import type { ComponentPanelSession } from "../../core/discord/componentSessionRegistry.js";
export interface PresenceCustomIds {
statusSelect: string;
activitySelect: string;
textButton: string;
intervalButton: string;
textModal: string;
textInput: string;
intervalModal: string;
intervalInput: string;
}
export interface PresenceRuntimeState {
dynamicPresenceRefreshTimer: NodeJS.Timeout | null;
presenceRotationTimer: NodeJS.Timeout | null;
activePresenceTextIndex: number;
}
export interface PresencePanelSession extends ComponentPanelSession {}
-20
View File
@@ -45,26 +45,6 @@
"pong": "Pong. Websocket latency: {{latency}}ms." "pong": "Pong. Websocket latency: {{latency}}ms."
} }
}, },
"advanced": {
"name": "advanced",
"description": "Showcase command using every available command parameter.",
"args": {
"text": "Required text value",
"count": "Required integer value",
"ratio": "Optional decimal number",
"enabled": "Optional boolean flag",
"user": "Required target user",
"channel": "Optional channel",
"role": "Optional role"
},
"examples": {
"full": "Complete prefix example with all argument types",
"slash": "Equivalent slash usage"
},
"responses": {
"summary": "Advanced command summary\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}"
}
},
"presence": { "presence": {
"name": "presence", "name": "presence",
"description": "Configure bot presence with an interactive panel.", "description": "Configure bot presence with an interactive panel.",
-20
View File
@@ -45,26 +45,6 @@
"pong": "Pong. Latencia websocket: {{latency}}ms." "pong": "Pong. Latencia websocket: {{latency}}ms."
} }
}, },
"advanced": {
"name": "avanzada",
"description": "Comando de demostracion que usa todos los parametros disponibles.",
"args": {
"text": "Texto obligatorio",
"count": "Entero obligatorio",
"ratio": "Numero decimal opcional",
"enabled": "Bandera booleana opcional",
"user": "Usuario objetivo obligatorio",
"channel": "Canal opcional",
"role": "Rol opcional"
},
"examples": {
"full": "Ejemplo prefix completo con todos los tipos de argumentos",
"slash": "Equivalente en slash"
},
"responses": {
"summary": "Resumen comando avanzada\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}"
}
},
"presence": { "presence": {
"name": "presence", "name": "presence",
"description": "Configurar la presencia del bot con un panel interactivo.", "description": "Configurar la presencia del bot con un panel interactivo.",
-20
View File
@@ -45,26 +45,6 @@
"pong": "Pong. Latence websocket: {{latency}}ms." "pong": "Pong. Latence websocket: {{latency}}ms."
} }
}, },
"advanced": {
"name": "avance",
"description": "Commande de demonstration utilisant tous les parametres disponibles.",
"args": {
"text": "Texte requis",
"count": "Entier requis",
"ratio": "Nombre decimal optionnel",
"enabled": "Booleen optionnel",
"user": "Utilisateur cible requis",
"channel": "Salon optionnel",
"role": "Role optionnel"
},
"examples": {
"full": "Exemple prefix complet avec tous les types d arguments",
"slash": "Equivalent en slash"
},
"responses": {
"summary": "Resume commande avancee\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}"
}
},
"presence": { "presence": {
"name": "presence", "name": "presence",
"description": "Configurer la presence du bot avec un panneau interactif.", "description": "Configurer la presence du bot avec un panneau interactif.",
+2 -144
View File
@@ -1,148 +1,6 @@
/** import { bootstrap } from "./app/bootstrap.js";
* Entrypoint du bot — bootstrap et configuration runtime.
*
* Rôles principaux:
* - initialiser les stores et ressources (presence, member messages)
* - configurer le client Discord (intents)
* - construire i18n, registry et executor
* - attacher les handlers (prefix/slash) et listeners d'événements
* - restaurer la présence et (optionnel) déployer les commandes slash
*/
import { Client, GatewayIntentBits } from "discord.js";
import { commandList } from "./commands/index.js"; bootstrap().catch((error) => {
import { shutdownPresenceRuntime } from "./commands/presence.js";
import { registerEvents } from "./events/index.js";
import { CommandRegistry } from "./core/commands/registry.js";
import { env } from "./config/env.js";
import { CommandExecutor } from "./core/execution/CommandExecutor.js";
import { createPrefixHandler } from "./handlers/prefixHandler.js";
import { createSlashHandler } from "./handlers/slashHandler.js";
import { I18nService } from "./i18n/index.js";
import {
initMemberMessageStore,
shutdownMemberMessageStore,
} from "./database/memberMessages/memberMessageStore.js";
import { initPresenceStore, shutdownPresenceStore } from "./database/presence/presenceStore.js";
/**
* Attache des handlers pour un arrêt gracieux du process.
*
* Actions effectuées lors du shutdown:
* - arrêt des timers/runtime (présence)
* - fermeture des stores (member messages / presence)
* - sortie du process
*/
const bindGracefulShutdown = (): void => {
const shutdown = async (signal: string): Promise<void> => {
console.log(`[shutdown] ${signal}`);
// Stoppe les timers et runtime liés à la présence
shutdownPresenceRuntime();
// Ferme proprement le store des messages de membre
await shutdownMemberMessageStore().catch((error) => {
console.error("[shutdown] member message store close failed", error);
});
// Ferme proprement le store de présence
await shutdownPresenceStore().catch((error) => {
console.error("[shutdown] presence store close failed", error);
});
process.exit(0);
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
/**
* Sequence d'initialisation principale.
*
* Etapes:
* 1. initialiser les stores (presence, member messages)
* 2. attacher shutdown handler
* 3. configurer la gestion d'erreurs process
* 4. créer le client Discord et les services (i18n, registry, executor)
* 5. construire les handlers prefix/slash et attacher les listeners
* 6. enregistrer events métier (member messages)
* 7. sur ready: restaurer la présence et déployer les slash commands si activé
*/
const bootstrap = async (): Promise<void> => {
// Initialisation des stores persistants utilisés par le bot
await initPresenceStore();
await initMemberMessageStore();
// Prépare la gestion d'un arrêt gracieux (SIGINT / SIGTERM)
bindGracefulShutdown();
// Log des erreurs non catchées pour debugging
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
console.error("[process] uncaught exception", error);
});
// Création du client Discord avec les intents nécessaires
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// Services transverses: i18n, registry des commandes et executor
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(commandList, i18n);
const executor = new CommandExecutor();
// Création des handlers : ces factories retournent une fonction utilitaire
// qui prend un Message / Interaction et exécute la logique (parse args, permissions, etc.)
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
const onSlashInteraction = createSlashHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
// Enregistre les événements principaux (séparés par fichier)
// `registerEvents` inclut désormais l'enregistrement du listener `ready`.
registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry);
// Connexion du client au gateway
await client.login(env.DISCORD_TOKEN);
};
// Démarre la séquence d'initialisation et gère les erreurs fatales
bootstrap().catch(async (error) => {
console.error("[boot] fatal error", error); console.error("[boot] fatal error", error);
// Nettoyage partiel en cas d'erreur critique pendant le boot
shutdownPresenceRuntime();
await shutdownMemberMessageStore().catch((closeError) => {
console.error("[boot] failed to close member message store", closeError);
});
await shutdownPresenceStore().catch((closeError) => {
console.error("[boot] failed to close presence store", closeError);
});
process.exit(1); process.exit(1);
}); });
@@ -2,9 +2,7 @@ import type {
MemberMessageConfig, MemberMessageConfig,
MemberMessageKind, MemberMessageKind,
MemberMessageRenderType, MemberMessageRenderType,
} from "./memberMessages.js"; } from "../types/memberMessages.js";
export type { MemberMessageConfig, MemberMessageKind, MemberMessageRenderType } from "./memberMessages.js";
export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"]; export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"];
@@ -2,7 +2,7 @@ import type {
PresenceActivityTypeValue, PresenceActivityTypeValue,
PresenceState, PresenceState,
PresenceStatusValue, PresenceStatusValue,
} from "./presence.js"; } from "../types/presence.js";
export const PRESENCE_STATUSES: readonly PresenceStatusValue[] = ["online", "idle", "dnd", "invisible", "streaming"]; export const PRESENCE_STATUSES: readonly PresenceStatusValue[] = ["online", "idle", "dnd", "invisible", "streaming"];
export const PRESENCE_ACTIVITY_TYPES: readonly PresenceActivityTypeValue[] = [ export const PRESENCE_ACTIVITY_TYPES: readonly PresenceActivityTypeValue[] = [
+1 -1
View File
@@ -8,7 +8,7 @@ import {
parsePresenceState, parsePresenceState,
sanitizeActivityTexts, sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds, sanitizePresenceRotationIntervalSeconds,
} from "../src/types/presenceTypes.js"; } from "../src/validators/presence.js";
test("sanitizeActivityTexts fallback sur le texte par defaut", () => { test("sanitizeActivityTexts fallback sur le texte par defaut", () => {
const texts = sanitizeActivityTexts([" ", ""]); const texts = sanitizeActivityTexts([" ", ""]);