From aa3cec7127d41e308c4fb632d1a2c8b9fee1887e Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Tue, 14 Apr 2026 00:09:12 +0200 Subject: [PATCH] finalisation de la mise au propre du projet --- AGENT.md | 159 +++-- README.md | 24 +- src/app/bootstrap.ts | 134 ++++ src/app/container.ts | 7 + src/commands/goodbye.ts | 8 +- src/commands/index.ts | 16 +- src/commands/presence.ts | 646 +----------------- src/commands/welcome.ts | 8 +- src/core/discord/componentSessionRegistry.ts | 45 ++ src/core/discord/replyMessageResolver.ts | 27 + src/database/dbLifecycle.ts | 26 + .../memberMessageStore.ts | 58 +- .../{presence => stores}/presenceStore.ts | 54 +- src/events/guildDelete.ts | 18 +- src/events/guildMemberAdd.ts | 17 +- src/events/guildMemberRemove.ts | 17 +- src/events/index.ts | 37 +- src/events/interactionCreate.ts | 14 +- src/events/ready.ts | 19 +- .../memberMessages/commandPanel.ts} | 104 +-- .../memberMessages/imageRenderer.ts} | 0 src/features/memberMessages/repository.ts | 10 + .../memberMessages/service.ts} | 155 +++-- src/features/presence/commandPanel.ts | 374 ++++++++++ src/features/presence/repository.ts | 6 + src/features/presence/service.ts | 234 +++++++ .../presence/templateVariables.ts} | 2 +- src/features/presence/types.ts | 20 + src/i18n/en.json | 20 - src/i18n/es.json | 20 - src/i18n/fr.json | 20 - src/index.ts | 146 +--- .../memberMessages.ts} | 4 +- .../presence.ts} | 2 +- tests/presenceTypes.test.ts | 2 +- 35 files changed, 1201 insertions(+), 1252 deletions(-) create mode 100644 src/app/bootstrap.ts create mode 100644 src/app/container.ts create mode 100644 src/core/discord/componentSessionRegistry.ts create mode 100644 src/core/discord/replyMessageResolver.ts create mode 100644 src/database/dbLifecycle.ts rename src/database/{memberMessages => stores}/memberMessageStore.ts (69%) rename src/database/{presence => stores}/presenceStore.ts (79%) rename src/{commands/memberMessagePanel.ts => features/memberMessages/commandPanel.ts} (77%) rename src/{services/memberMessages/memberMessageImage.ts => features/memberMessages/imageRenderer.ts} (100%) create mode 100644 src/features/memberMessages/repository.ts rename src/{services/memberMessages/memberMessageSender.ts => features/memberMessages/service.ts} (63%) create mode 100644 src/features/presence/commandPanel.ts create mode 100644 src/features/presence/repository.ts create mode 100644 src/features/presence/service.ts rename src/{services/presence/presenceTemplateVariables.ts => features/presence/templateVariables.ts} (97%) create mode 100644 src/features/presence/types.ts rename src/{types/memberMessageTypes.ts => validators/memberMessages.ts} (86%) rename src/{types/presenceTypes.ts => validators/presence.ts} (99%) diff --git a/AGENT.md b/AGENT.md index 3bda19f..903a4be 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,86 +1,101 @@ # AGENT.md - Guide du projet template_discordjs -Version: 1.0 +Version: 2.0 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 ------------------------ -- Tech stack: TypeScript + Discord.js (bot template). Le code source est dans `src/` et la sortie build dans `build/` (à ignorer). -- Localisation: `src/i18n/` contient `en.json`, `fr.json`, `es.json`. +Vue d'ensemble +-------------- +- Stack: TypeScript + Discord.js 14. +- 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: - - `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml` — scripts et infra. -- `src/` (code TypeScript): - - `index.ts` — point d'entrée, boot du bot. - - `commands/` — définitions de commandes (chaque fichier expose une commande). - - `events/` — un fichier par événement Discord (ex: `guildMemberAdd.ts`). `src/events/index.ts` centralise l'enregistrement. - - `framework/` — bibliothèque interne: helpers commandes, `i18n/`, `memberMessages/`, `presence/`, `execution/`, `handlers/`, `config/`, `types/`. - - `utils/` — helpers génériques (ex: `templateVariables.ts`). -- `tests/` — tests unitaires ciblant managers, stores et utilitaires. + - `package.json`, `tsconfig.json`, `Dockerfile`, `docker-compose.yml`. +- `src/app/` + - `bootstrap.ts`: wiring des dependances, init DB, handlers, shutdown. + - `container.ts`: contrats de services injectes. +- `src/commands/` + - Wrappers de commandes uniquement. + - Chaque fichier retourne une commande via `defineCommand(...)`. + - 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 -------------------------------- -- 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. -- 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. -- 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`. -- Typage: utiliser TypeScript strict, signatures fortement typées pour les handlers (`onPrefixMessage(message: Message)` etc.). -- Tests: privilégier les tests unitaires pour managers/stores/transformations. Mockez les adaptateurs Discord. -- i18n: utiliser `I18nService` pour traductions. Ajouter toutes les clés dans `src/i18n/*.json`. -- Nommage: fichiers en lowerCamelCase (ex: `welcome.ts`, `memberMessagePanel.ts`). Exports nommés préférés pour faciliter le mocking. -- Sécurité: ne pas committer de secrets (`.env*` doit être dans `.gitignore`). +Principes d'architecture +------------------------ +- Commands UI only: + - Une commande ne fait que declarer `meta/args/examples` et deleguer `execute`. +- Features own business logic: + - Toute logique metier testable va dans `src/features/*`. +- Core is shared infra: + - Helpers communs Discord, execution pipeline, parser, registry. +- Validators isolate rules: + - 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 ----------------------------------------------------- -1. Créer le fichier dans `src/commands/myCommand.ts`. -2. Utiliser `defineCommand({ meta, args, examples, execute })` pour déclarer la commande. -3. Implémenter `execute()` de façon minimale: valider les args et appeler un service dans `src/framework/` si la logique est non triviale. -4. Ajouter les clés de traduction dans `src/i18n/en.json`, `src/i18n/fr.json`, `src/i18n/es.json` (ex: `commands.myCommand.success`). -5. Écrire des tests unitaires dans `tests/` pour le service/manager; si la commande est juste un wrapper, testez le service. -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. +Conventions de code +------------------- +- TypeScript strict obligatoire. +- Exports nommes preferes. +- Fichiers en lowerCamelCase. +- Commentaires courts uniquement pour clarifier un bloc non trivial. +- Eviter tout couplage direct d'une feature vers une autre sans passer par contrats explicites. -Procédure standard pour ajouter un nouvel événement --------------------------------------------------- -1. Créer `src/events/.ts` et y exporter `register(client, i18n)`. -2. Respecter la signature projet (voir `src/events/index.ts` pour l'exemple d'enregistrement). -3. Mettre la logique testable dans un manager/service et tester cette logique séparément. -4. Ajouter l'import et l'appel d'enregistrement dans `src/events/index.ts`. +Procedure: ajouter une commande +-------------------------------- +1. Creer un wrapper dans `src/commands/`. +2. Declarer la commande avec `defineCommand({ ... })`. +3. Deleguer `execute` vers un module `src/features/...`. +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` --------------------------------------------------------- -1. Créer un dossier `src/framework//` contenant `manager.ts`, `store.ts` (si nécessaire), `types.ts` et tests. -2. Interfacez le manager pour qu'il accepte des adaptateurs (ex: `DiscordAdapter`) au lieu d'utiliser directement `client`. -3. Documenter les comportements et écrire des tests unitaires couvrant les cas critiques. +Procedure: ajouter une feature +------------------------------ +1. Creer `src/features//`. +2. Definir les contrats repository dans la feature. +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 ------------------------ -- Utiliser des clés structurées: `commands..` ou `presence.`. -- Garder `en.json` comme référence complète; synchroniser les autres langues. +Tests et verification +--------------------- +- Verification minimale avant PR: + - `npm run typecheck` + - `npm test` +- En cas de refactor de structure: + - verifier README + AGENT.md + i18n + imports. -Tests et CI ------------ -- Prioriser les tests sur managers, stores et utilitaires. -- Mocks: extraire les dépendances externes (Discord API) derrière des adaptateurs pour pouvoir les mocker. -- Scripts: utiliser `npm test` et `npm run build` depuis la racine (vérifier `package.json`). - -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. +Securite +-------- +- Ne jamais committer de secrets. +- `.env*` (sauf `.env.example`) doit rester ignore. +- Eviter d'exposer des credentials dans logs/scripts de debug. diff --git a/README.md b/README.md index 910c5b2..15373c4 100644 --- a/README.md +++ b/README.md @@ -79,23 +79,25 @@ An example with two bot services is available in `docker-compose.multi-bot.examp ## Architecture -- `src/types/command.ts`: strict command schema -- `src/core/commands/defineCommand.ts`: default command completion -- `src/core/commands/registry.ts`: trigger/name mapping generated from i18n dictionaries -- `src/core/commands/argParser.ts`: prefix/slash args parsing from schema -- `src/core/execution/CommandExecutor.ts`: unified pipeline (permissions/cooldown/execute) -- `src/handlers/prefixHandler.ts`: prefix entrypoint -- `src/handlers/slashHandler.ts`: slash entrypoint -- `src/database/presence/presenceStore.ts`: PostgreSQL presence storage -- `src/types/presenceTypes.ts`: shared presence types/validation +- `src/app/bootstrap.ts`: runtime bootstrap, dependency wiring, graceful shutdown +- `src/commands/*`: thin command wrappers (`defineCommand` + delegated execute) +- `src/features/presence/*`: presence runtime, panel orchestration, repository contract +- `src/features/memberMessages/*`: member-message dispatch, panel orchestration, image rendering, repository contract +- `src/core/commands/*`: registry, parsing, slash payload, usage helpers +- `src/core/execution/CommandExecutor.ts`: unified execution pipeline (permissions/cooldown/error handling) +- `src/core/discord/*`: shared Discord helpers (reply message resolver, panel session registry) +- `src/database/stores/*`: PostgreSQL store implementations +- `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/commands/*`: business commands only (`execute`, optional `cooldown`) ## Included Commands - `kiss` (`fun`) with required `user` arg - `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 - `help` (`core`) with auto category and usage generation diff --git a/src/app/bootstrap.ts b/src/app/bootstrap.ts new file mode 100644 index 0000000..50ada40 --- /dev/null +++ b/src/app/bootstrap.ts @@ -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 => { + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + + process.once("SIGTERM", () => { + void shutdown("SIGTERM"); + }); +}; + +export const bootstrap = async (): Promise => { + 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 => { + 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; + } +}; diff --git a/src/app/container.ts b/src/app/container.ts new file mode 100644 index 0000000..f22cd8f --- /dev/null +++ b/src/app/container.ts @@ -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; +} diff --git a/src/commands/goodbye.ts b/src/commands/goodbye.ts index e8b81ff..dcf9ba9 100644 --- a/src/commands/goodbye.ts +++ b/src/commands/goodbye.ts @@ -7,10 +7,12 @@ import { PermissionFlagsBits } from "discord.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'. */ -export const goodbyeCommand = defineCommand({ +export const createGoodbyeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ meta: { name: "goodbye", category: "utility", @@ -22,5 +24,5 @@ export const goodbyeCommand = defineCommand({ descriptionKey: "examples.slash", }, ], - execute: createMemberMessageExecute("goodbye"), + execute: createMemberMessagePanelExecute("goodbye", memberMessageService, i18n), }); diff --git a/src/commands/index.ts b/src/commands/index.ts index 5372092..12aaeb6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,19 +6,21 @@ */ import { helpCommand } from "./help.js"; import { kissCommand } from "./kiss.js"; -import { goodbyeCommand } from "./goodbye.js"; -import { presenceCommand } from "./presence.js"; +import { createGoodbyeCommand } from "./goodbye.js"; +import { createPresenceCommand } from "./presence.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"; /** CommandList: tableau ordonné des commandes disponibles. */ -export const commandList: BotCommand[] = [ +export const createCommandList = (services: AppFeatureServices, i18n: I18nService): BotCommand[] => [ kissCommand, pingCommand, - welcomeCommand, - goodbyeCommand, - presenceCommand, + createWelcomeCommand(services.memberMessageService, i18n), + createGoodbyeCommand(services.memberMessageService, i18n), + createPresenceCommand(services.presenceService), helpCommand, ]; diff --git a/src/commands/presence.ts b/src/commands/presence.ts index ad6531f..6c2ad21 100644 --- a/src/commands/presence.ts +++ b/src/commands/presence.ts @@ -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 { env } from "../config/env.js"; -import type { - 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"; +import { createPresenceCommandExecute } from "../features/presence/commandPanel.js"; +import type { PresenceService } from "../features/presence/service.js"; -const presenceRuntimeByBotId = new Map(); - -const createPresenceRuntimeState = (): PresenceRuntimeState => ({ - dynamicPresenceRefreshTimer: null, - presenceRotationTimer: null, - activePresenceTextIndex: 0, - activePanelsByUserId: new Map(), -}); - -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 = { - 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 => { - const botId = resolveBotId(client); - if (!botId) { - return createDefaultPresenceState(); - } - - return getPresenceStore().getByBotId(botId); -}; - -const savePresenceState = async (client: Client, state: PresenceState): Promise => { - 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().addComponents(statusSelect), - ); - container.addActionRowComponents( - new ActionRowBuilder().addComponents(activitySelect), - ); - container.addActionRowComponents( - new ActionRowBuilder().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 => { - 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 => { - 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({ +export const createPresenceCommand = (presenceService: PresenceService) => defineCommand({ meta: { name: "presence", category: "utility", @@ -412,244 +13,5 @@ export const presenceCommand = defineCommand({ descriptionKey: "examples.slash", }, ], - execute: async (ctx) => { - 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 => { - 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().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().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(); - }); - }, + execute: createPresenceCommandExecute(presenceService), }); diff --git a/src/commands/welcome.ts b/src/commands/welcome.ts index be3b559..beed0de 100644 --- a/src/commands/welcome.ts +++ b/src/commands/welcome.ts @@ -7,10 +7,12 @@ import { PermissionFlagsBits } from "discord.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'. */ -export const welcomeCommand = defineCommand({ +export const createWelcomeCommand = (memberMessageService: MemberMessageService, i18n: I18nService) => defineCommand({ meta: { name: "welcome", category: "utility", @@ -22,5 +24,5 @@ export const welcomeCommand = defineCommand({ descriptionKey: "examples.slash", }, ], - execute: createMemberMessageExecute("welcome"), + execute: createMemberMessagePanelExecute("welcome", memberMessageService, i18n), }); diff --git a/src/core/discord/componentSessionRegistry.ts b/src/core/discord/componentSessionRegistry.ts new file mode 100644 index 0000000..0284d06 --- /dev/null +++ b/src/core/discord/componentSessionRegistry.ts @@ -0,0 +1,45 @@ +export interface ComponentPanelSession { + collector: { + stop: (reason?: string) => void; + }; + disable: () => Promise; +} + +export class ComponentSessionRegistry { + private readonly sessions = new Map(); + + public get(key: string): T | undefined { + return this.sessions.get(key); + } + + public async replace(key: string, next: T): Promise { + 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 { + const entries = [...this.sessions.values()]; + this.sessions.clear(); + + for (const session of entries) { + session.collector.stop(reason); + await session.disable().catch(() => undefined); + } + } +} diff --git a/src/core/discord/replyMessageResolver.ts b/src/core/discord/replyMessageResolver.ts new file mode 100644 index 0000000..82684f8 --- /dev/null +++ b/src/core/discord/replyMessageResolver.ts @@ -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; +}; diff --git a/src/database/dbLifecycle.ts b/src/database/dbLifecycle.ts new file mode 100644 index 0000000..1423826 --- /dev/null +++ b/src/database/dbLifecycle.ts @@ -0,0 +1,26 @@ +export interface DatabaseInitializable { + name: string; + init: () => Promise; +} + +export class DatabaseLifecycle { + public constructor( + private readonly initializables: DatabaseInitializable[], + private readonly shutdownFn: () => Promise, + ) {} + + public async init(): Promise { + 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 { + await this.shutdownFn(); + } +} diff --git a/src/database/memberMessages/memberMessageStore.ts b/src/database/stores/memberMessageStore.ts similarity index 69% rename from src/database/memberMessages/memberMessageStore.ts rename to src/database/stores/memberMessageStore.ts index f97676a..1f1c99d 100644 --- a/src/database/memberMessages/memberMessageStore.ts +++ b/src/database/stores/memberMessageStore.ts @@ -1,6 +1,5 @@ -import { Pool } from "pg"; +import type { Pool } from "pg"; -import { env } from "../../config/env.js"; import type { MemberMessageConfig, MemberMessageKind, @@ -9,7 +8,8 @@ import type { import { createDefaultMemberMessageConfig, isMemberMessageRenderTypeValue, -} from "../../types/memberMessageTypes.js"; +} from "../../validators/memberMessages.js"; +import type { MemberMessageRepository } from "../../features/memberMessages/repository.js"; const tableSql = ` 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 async init(): Promise { @@ -101,50 +101,10 @@ class MemberMessageStore { ); } - public async close(): Promise { - await this.pool.end(); + public async deleteByBotGuild(botId: string, guildId: string): Promise { + 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 => { - 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 => { - if (!store) { - return; - } - - await store.close(); - store = null; -}; diff --git a/src/database/presence/presenceStore.ts b/src/database/stores/presenceStore.ts similarity index 79% rename from src/database/presence/presenceStore.ts rename to src/database/stores/presenceStore.ts index 9f039f4..73d26a3 100644 --- a/src/database/presence/presenceStore.ts +++ b/src/database/stores/presenceStore.ts @@ -1,6 +1,5 @@ -import { Pool } from "pg"; +import type { Pool } from "pg"; -import { env } from "../../config/env.js"; import type { PresenceActivityTypeValue, PresenceRow, @@ -15,7 +14,8 @@ import { sanitizeActivityText, sanitizeActivityTexts, sanitizePresenceRotationIntervalSeconds, -} from "../../types/presenceTypes.js"; +} from "../../validators/presence.js"; +import type { PresenceRepository } from "../../features/presence/repository.js"; const tableSql = ` 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 async init(): Promise { @@ -147,50 +147,4 @@ class PresenceStore { ], ); } - - public async close(): Promise { - await this.pool.end(); - } } - -let store: PresenceStore | null = null; - -export const initPresenceStore = async (): Promise => { - 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 => { - if (!store) { - return; - } - - await store.close(); - store = null; -}; diff --git a/src/events/guildDelete.ts b/src/events/guildDelete.ts index 125c1f6..481d78a 100644 --- a/src/events/guildDelete.ts +++ b/src/events/guildDelete.ts @@ -1,12 +1,18 @@ import { Events, type Client } from "discord.js"; -/** - * Enregistre le listener `guildDelete` (bot retiré d'un serveur). - * - * Action minimale: log pour monitoring ; peut être étendu (cleanup, etc.). - */ -export const registerGuildDelete = (client: Client): void => { +import type { MemberMessageService } from "../features/memberMessages/service.js"; + +export const registerGuildDelete = (client: Client, memberMessageService: MemberMessageService): void => { client.on(Events.GuildDelete, (guild) => { 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); + }); }); }; diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 9b6213c..4c2dbfc 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -1,14 +1,15 @@ import { Events, type Client } from "discord.js"; -import type { I18nService } from "../i18n/index.js"; -import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js"; -/** - * Enregistre le listener `guildMemberAdd` et déclenche l'envoi d'un message - * de bienvenue via `dispatchMemberMessage`. - */ -export const registerGuildMemberAdd = (client: Client, i18n: I18nService): void => { +import type { MemberMessageService } from "../features/memberMessages/service.js"; +import type { I18nService } from "../i18n/index.js"; + +export const registerGuildMemberAdd = ( + client: Client, + i18n: I18nService, + memberMessageService: MemberMessageService, +): void => { client.on(Events.GuildMemberAdd, (member) => { - void dispatchMemberMessage({ + void memberMessageService.dispatch({ client, i18n, guild: member.guild, diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 735ae2b..dfb3ae3 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -1,14 +1,15 @@ import { Events, type Client } from "discord.js"; -import type { I18nService } from "../i18n/index.js"; -import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js"; -/** - * Enregistre le listener `guildMemberRemove` et déclenche l'envoi d'un message - * d'au revoir via `dispatchMemberMessage`. - */ -export const registerGuildMemberRemove = (client: Client, i18n: I18nService): void => { +import type { MemberMessageService } from "../features/memberMessages/service.js"; +import type { I18nService } from "../i18n/index.js"; + +export const registerGuildMemberRemove = ( + client: Client, + i18n: I18nService, + memberMessageService: MemberMessageService, +): void => { client.on(Events.GuildMemberRemove, (member) => { - void dispatchMemberMessage({ + void memberMessageService.dispatch({ client, i18n, guild: member.guild, diff --git a/src/events/index.ts b/src/events/index.ts index 6e8c294..92b530c 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,37 +1,34 @@ -import type { Client, Message, ChatInputCommandInteraction } from "discord.js"; -import type { I18nService } from "../i18n/index.js"; +import type { ChatInputCommandInteraction, Client, Message } from "discord.js"; -import { registerMessageCreate } from "./messageCreate.js"; -import { registerInteractionCreate } from "./interactionCreate.js"; -import { registerGuildMemberAdd } from "./guildMemberAdd.js"; -import { registerGuildMemberRemove } from "./guildMemberRemove.js"; +import type { AppFeatureServices } from "../app/container.js"; +import type { CommandRegistry } from "../core/commands/registry.js"; +import type { I18nService } from "../i18n/index.js"; import { registerGuildCreate } from "./guildCreate.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 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 = ( client: Client, i18n: I18nService, - handlers: { onPrefixMessage: (m: Message) => Promise; onSlashInteraction: (i: ChatInputCommandInteraction) => Promise }, + handlers: { + onPrefixMessage: (m: Message) => Promise; + onSlashInteraction: (i: ChatInputCommandInteraction) => Promise; + }, registry: CommandRegistry, + services: AppFeatureServices, ): void => { registerMessageCreate(client, handlers.onPrefixMessage); registerInteractionCreate(client, handlers.onSlashInteraction); - registerGuildMemberAdd(client, i18n); - registerGuildMemberRemove(client, i18n); + registerGuildMemberAdd(client, i18n, services.memberMessageService); + registerGuildMemberRemove(client, i18n, services.memberMessageService); registerGuildCreate(client); - registerGuildDelete(client); + registerGuildDelete(client, services.memberMessageService); - // Ready: tâches à exécuter au démarrage du client - registerClientReady(client, registry, i18n); + registerClientReady(client, registry, i18n, services.presenceService); }; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index dd65f52..1c395a9 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -11,21 +11,11 @@ export const registerInteractionCreate = ( onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise, ): void => { client.on(Events.InteractionCreate, (interaction) => { - // On ne traite que les `ChatInputCommand` (slash) - if (!interaction || typeof (interaction as any).isChatInputCommand !== "function") { + if (!interaction.isChatInputCommand()) { return; } - try { - if (!(interaction as any).isChatInputCommand()) { - return; - } - } catch { - return; - } - - // Cast sécurisé par le test `isChatInputCommand()` effectué ci‑dessus - void onSlashInteraction(interaction as ChatInputCommandInteraction).catch((error) => { + void onSlashInteraction(interaction).catch((error) => { console.error("[event:interactionCreate] handler failed", error); }); }); diff --git a/src/events/ready.ts b/src/events/ready.ts index 6b9e1e4..537e994 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,20 +1,21 @@ import { Events, type Client } from "discord.js"; -import { deployApplicationCommands } from "../core/commands/deploy.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 { PresenceService } from "../features/presence/service.js"; import type { I18nService } from "../i18n/index.js"; -/** - * Attache le listener `ready` et exécute les tâches post-démarrage: - * - restauration de la présence - * - (optionnel) déploiement des commandes slash - */ -export const registerClientReady = (client: Client, registry: CommandRegistry, i18n: I18nService): void => { +export const registerClientReady = ( + client: Client, + registry: CommandRegistry, + i18n: I18nService, + presenceService: PresenceService, +): void => { client.once(Events.ClientReady, async () => { console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); try { - await restorePresenceFromStorage(client); + await presenceService.restoreFromStorage(client); } catch (error) { console.error("[ready] failed to restore bot presence", error); } diff --git a/src/commands/memberMessagePanel.ts b/src/features/memberMessages/commandPanel.ts similarity index 77% rename from src/commands/memberMessagePanel.ts rename to src/features/memberMessages/commandPanel.ts index 13dcf2c..e221b7b 100644 --- a/src/commands/memberMessagePanel.ts +++ b/src/features/memberMessages/commandPanel.ts @@ -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 { ActionRowBuilder, ButtonBuilder, @@ -19,18 +8,12 @@ import { MessageFlags, StringSelectMenuBuilder, TextDisplayBuilder, - type Message, } from "discord.js"; -import { env } from "../config/env.js"; -import { I18nService } from "../i18n/index.js"; -import { dispatchMemberMessage } from "../services/memberMessages/memberMessageSender.js"; -import { getMemberMessageStore } from "../database/memberMessages/memberMessageStore.js"; -import { - MEMBER_MESSAGE_RENDER_TYPES, - isMemberMessageRenderTypeValue, -} from "../types/memberMessageTypes.js"; -import type { CommandExecutionContext } from "../types/command.js"; +import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js"; +import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js"; +import type { I18nService } from "../../i18n/index.js"; +import type { CommandExecutionContext } from "../../types/command.js"; import type { MemberMessageConfig, MemberMessageCustomIds, @@ -38,14 +21,17 @@ import type { MemberMessagePanelSession, MemberMessagePanelUiState, 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(); -const activePanelsByUser = new Map(); - -const panelSessionKey = (kind: MemberMessageKind, guildId: string, userId: string): string => { - return `${kind}:${guildId}:${userId}`; +const panelSessionKey = (kind: MemberMessageKind, botId: string, guildId: string, userId: string): string => { + return `${kind}:${botId}:${guildId}:${userId}`; }; 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 => { return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled"); }; @@ -201,13 +161,11 @@ const testFeedbackKey = (reason: string): string => { } }; -/** - * Factory qui crée l'exécuteur de commande pour un `MemberMessageKind` donné. - * - * Le handler retourné gère l'affichage du panneau, la collecte d'interactions - * et la persistance de la configuration via `memberMessageStore`. - */ -export const createMemberMessageExecute = (kind: MemberMessageKind) => { +export const createMemberMessagePanelExecute = ( + kind: MemberMessageKind, + memberMessageService: MemberMessageService, + panelI18n: I18nService, +) => { return async (ctx: CommandExecutionContext): Promise => { if (!ctx.guild) { await ctx.reply(ctx.ct("responses.guildOnly")); @@ -215,13 +173,13 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => { } const guild = ctx.guild; - const botId = ctx.client.user?.id; + const botId = memberMessageService.resolveBotId(ctx.client); if (!botId) { await ctx.reply(ctx.t("errors.execution")); return; } - const config = await getMemberMessageStore().getByBotGuildKind(botId, guild.id, kind); + const config = await memberMessageService.getConfig(botId, guild.id, kind); const customIds = createCustomIds(kind); const uiState: MemberMessagePanelUiState = { channelPickerOpen: false, @@ -239,16 +197,10 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => { } const ownerId = ctx.user.id; - const sessionKey = panelSessionKey(kind, guild.id, ownerId); - const existingPanel = activePanelsByUser.get(sessionKey); - if (existingPanel) { - existingPanel.collector.stop("replaced"); - await existingPanel.disable().catch(() => undefined); - activePanelsByUser.delete(sessionKey); - } + const sessionKey = panelSessionKey(kind, botId, guild.id, ownerId); const saveConfig = async (): Promise => { - await getMemberMessageStore().upsertByBotGuildKind(botId, guild.id, kind, config); + await memberMessageService.saveConfig(botId, guild.id, kind, config); }; const disablePanel = async (): Promise => { @@ -261,7 +213,7 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => { }; const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 }); - activePanelsByUser.set(sessionKey, { + await panelSessions.replace(sessionKey, { collector, disable: disablePanel, }); @@ -307,9 +259,9 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => { if (interaction.customId === customIds.testButton) { await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - const testResult = await dispatchMemberMessage({ + const testResult = await memberMessageService.dispatch({ client: ctx.client, - i18n: memberMessageI18n, + i18n: panelI18n, guild, user: ctx.user, kind, @@ -400,11 +352,7 @@ export const createMemberMessageExecute = (kind: MemberMessageKind) => { }); collector.on("end", async () => { - const currentPanel = activePanelsByUser.get(sessionKey); - if (currentPanel?.collector === collector) { - activePanelsByUser.delete(sessionKey); - } - + panelSessions.deleteIfCollectorMatch(sessionKey, collector); await disablePanel(); }); }; diff --git a/src/services/memberMessages/memberMessageImage.ts b/src/features/memberMessages/imageRenderer.ts similarity index 100% rename from src/services/memberMessages/memberMessageImage.ts rename to src/features/memberMessages/imageRenderer.ts diff --git a/src/features/memberMessages/repository.ts b/src/features/memberMessages/repository.ts new file mode 100644 index 0000000..88f8dcb --- /dev/null +++ b/src/features/memberMessages/repository.ts @@ -0,0 +1,10 @@ +import type { + MemberMessageConfig, + MemberMessageKind, +} from "../../types/memberMessages.js"; + +export interface MemberMessageRepository { + getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise; + upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise; + deleteByBotGuild(botId: string, guildId: string): Promise; +} diff --git a/src/services/memberMessages/memberMessageSender.ts b/src/features/memberMessages/service.ts similarity index 63% rename from src/services/memberMessages/memberMessageSender.ts rename to src/features/memberMessages/service.ts index c26ae6c..e39d634 100644 --- a/src/services/memberMessages/memberMessageSender.ts +++ b/src/features/memberMessages/service.ts @@ -15,13 +15,15 @@ import type { SupportedLang } from "../../types/command.js"; import type { DispatchMemberMessageInput, DispatchMemberMessageResult, + MemberMessageConfig, MemberMessageKind, MemberMessageRenderType, SendableChannel, TemplateSuffix, } from "../../types/memberMessages.js"; -import { renderMemberMessageImage } from "./memberMessageImage.js"; -import { getMemberMessageStore } from "../../database/memberMessages/memberMessageStore.js"; +import { renderMemberMessageImage } from "./imageRenderer.js"; +import type { MemberMessageRepository } from "./repository.js"; + export type { DispatchMemberMessageFailureReason, DispatchMemberMessageResult, @@ -180,86 +182,111 @@ const buildMemberMessagePayload = async ( }; }; -export const dispatchMemberMessage = async (input: DispatchMemberMessageInput): Promise => { - const botId = input.client.user?.id; - if (!botId) { - return { - sent: false, - reason: "bot_not_ready", - channelId: null, - }; +export class MemberMessageService { + public constructor(private readonly repository: MemberMessageRepository) {} + + public resolveBotId(client: DispatchMemberMessageInput["client"]): string | null { + return client.user?.id ?? null; } - const config = await getMemberMessageStore().getByBotGuildKind(botId, input.guild.id, input.kind); - - if (!input.ignoreEnabled && !config.enabled) { - return { - sent: false, - reason: "disabled", - channelId: config.channelId, - }; + public async getConfig(botId: string, guildId: string, kind: MemberMessageKind) { + return this.repository.getByBotGuildKind(botId, guildId, kind); } - if (!config.channelId) { - return { - sent: false, - reason: "missing_channel", - channelId: null, - }; + public async saveConfig( + botId: string, + guildId: string, + kind: MemberMessageKind, + config: MemberMessageConfig, + ): Promise { + await this.repository.upsertByBotGuildKind(botId, guildId, kind, config); } - const channel = await input.guild.channels.fetch(config.channelId).catch(() => null); - if (!channel) { - return { - sent: false, - reason: "channel_not_found", - channelId: config.channelId, - }; + public async cleanupGuild(botId: string, guildId: string): Promise { + await this.repository.deleteByBotGuild(botId, guildId); } - 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)) { + public async dispatch(input: DispatchMemberMessageInput): Promise { + const botId = this.resolveBotId(input.client); + if (!botId) { return { sent: false, - reason: "missing_permissions", - channelId: config.channelId, + reason: "bot_not_ready", + channelId: null, }; } - } - 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); + const config = await this.repository.getByBotGuildKind(botId, input.guild.id, input.kind); - try { - await channel.send(payload); - return { - sent: true, - reason: "sent", - channelId: config.channelId, - }; - } catch (error) { - if (hasCode(error) && error.code === 50013) { + if (!input.ignoreEnabled && !config.enabled) { return { sent: false, - reason: "missing_permissions", + reason: "disabled", channelId: config.channelId, }; } - return { - sent: false, - reason: "send_failed", - channelId: config.channelId, - }; + if (!config.channelId) { + return { + sent: false, + 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, + }; + } } -}; +} diff --git a/src/features/presence/commandPanel.ts b/src/features/presence/commandPanel.ts new file mode 100644 index 0000000..6d84540 --- /dev/null +++ b/src/features/presence/commandPanel.ts @@ -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>, +): 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>, + 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().addComponents(statusSelect), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(activitySelect), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(textButton, intervalButton), + ); + + return container; +}; + +export const createPresenceCommandExecute = (presenceService: PresenceService) => { + return async (ctx: CommandExecutionContext): Promise => { + 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 => { + 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().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().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(); + }); + }; +}; diff --git a/src/features/presence/repository.ts b/src/features/presence/repository.ts new file mode 100644 index 0000000..0649d01 --- /dev/null +++ b/src/features/presence/repository.ts @@ -0,0 +1,6 @@ +import type { PresenceState } from "../../types/presence.js"; + +export interface PresenceRepository { + getByBotId(botId: string): Promise; + upsertByBotId(botId: string, state: PresenceState): Promise; +} diff --git a/src/features/presence/service.ts b/src/features/presence/service.ts new file mode 100644 index 0000000..9c57d99 --- /dev/null +++ b/src/features/presence/service.ts @@ -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 = { + 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(); + private readonly panelSessions = new ComponentSessionRegistry(); + + 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 { + 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 { + this.applyState(client, state); + + const botId = this.resolveBotId(client); + if (!botId) { + return; + } + + await this.repository.upsertByBotId(botId, state); + } + + public async restoreFromStorage(client: Client): Promise { + 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 { + await this.panelSessions.replace(key, session); + } + + public deletePanelSessionIfCollectorMatch(key: string, collector: PresencePanelSession["collector"]): void { + this.panelSessions.deleteIfCollectorMatch(key, collector); + } + + public async shutdown(): Promise { + 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?.(); + } +} diff --git a/src/services/presence/presenceTemplateVariables.ts b/src/features/presence/templateVariables.ts similarity index 97% rename from src/services/presence/presenceTemplateVariables.ts rename to src/features/presence/templateVariables.ts index cf730c2..0fad004 100644 --- a/src/services/presence/presenceTemplateVariables.ts +++ b/src/features/presence/templateVariables.ts @@ -2,7 +2,7 @@ import type { Client } from "discord.js"; import { env } from "../../config/env.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; diff --git a/src/features/presence/types.ts b/src/features/presence/types.ts new file mode 100644 index 0000000..4f5d979 --- /dev/null +++ b/src/features/presence/types.ts @@ -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 {} diff --git a/src/i18n/en.json b/src/i18n/en.json index 7d975d5..4a98804 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -45,26 +45,6 @@ "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": { "name": "presence", "description": "Configure bot presence with an interactive panel.", diff --git a/src/i18n/es.json b/src/i18n/es.json index c42ca2b..2b7ff25 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -45,26 +45,6 @@ "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": { "name": "presence", "description": "Configurar la presencia del bot con un panel interactivo.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 7f540ba..bb4ea56 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -45,26 +45,6 @@ "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": { "name": "presence", "description": "Configurer la presence du bot avec un panneau interactif.", diff --git a/src/index.ts b/src/index.ts index 0921f7b..1ca935c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,148 +1,6 @@ -/** - * 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 { bootstrap } from "./app/bootstrap.js"; -import { commandList } from "./commands/index.js"; -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 => { - 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 => { - // 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) => { +bootstrap().catch((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); }); diff --git a/src/types/memberMessageTypes.ts b/src/validators/memberMessages.ts similarity index 86% rename from src/types/memberMessageTypes.ts rename to src/validators/memberMessages.ts index 72f8c2d..18844e2 100644 --- a/src/types/memberMessageTypes.ts +++ b/src/validators/memberMessages.ts @@ -2,9 +2,7 @@ import type { MemberMessageConfig, MemberMessageKind, MemberMessageRenderType, -} from "./memberMessages.js"; - -export type { MemberMessageConfig, MemberMessageKind, MemberMessageRenderType } from "./memberMessages.js"; +} from "../types/memberMessages.js"; export const MEMBER_MESSAGE_KINDS: readonly MemberMessageKind[] = ["welcome", "goodbye"]; diff --git a/src/types/presenceTypes.ts b/src/validators/presence.ts similarity index 99% rename from src/types/presenceTypes.ts rename to src/validators/presence.ts index e619116..2dc28b2 100644 --- a/src/types/presenceTypes.ts +++ b/src/validators/presence.ts @@ -2,7 +2,7 @@ import type { PresenceActivityTypeValue, PresenceState, PresenceStatusValue, -} from "./presence.js"; +} from "../types/presence.js"; export const PRESENCE_STATUSES: readonly PresenceStatusValue[] = ["online", "idle", "dnd", "invisible", "streaming"]; export const PRESENCE_ACTIVITY_TYPES: readonly PresenceActivityTypeValue[] = [ diff --git a/tests/presenceTypes.test.ts b/tests/presenceTypes.test.ts index 1b891ff..07917ac 100644 --- a/tests/presenceTypes.test.ts +++ b/tests/presenceTypes.test.ts @@ -8,7 +8,7 @@ import { parsePresenceState, sanitizeActivityTexts, sanitizePresenceRotationIntervalSeconds, -} from "../src/types/presenceTypes.js"; +} from "../src/validators/presence.js"; test("sanitizeActivityTexts fallback sur le texte par defaut", () => { const texts = sanitizeActivityTexts([" ", ""]);