finalisation de la mise au propre du projet

This commit is contained in:
Puechberty Arthur
2026-04-14 00:09:12 +02:00
parent e47cb68bc7
commit aa3cec7127
35 changed files with 1201 additions and 1252 deletions
+87 -72
View File
@@ -1,86 +1,101 @@
# AGENT.md - Guide du projet template_discordjs
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/<eventName>.ts` et y exporter `register<EventName>(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/<feature>/` 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/<feature>/`.
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.<name>.<key>` ou `presence.<action>`.
- 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.
+13 -11
View File
@@ -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
+134
View File
@@ -0,0 +1,134 @@
import { Client, GatewayIntentBits } from "discord.js";
import { Pool } from "pg";
import type { AppFeatureServices } from "./container.js";
import { createCommandList } from "../commands/index.js";
import { env } from "../config/env.js";
import { CommandRegistry } from "../core/commands/registry.js";
import { CommandExecutor } from "../core/execution/CommandExecutor.js";
import { PostgresMemberMessageStore } from "../database/stores/memberMessageStore.js";
import { PostgresPresenceStore } from "../database/stores/presenceStore.js";
import { DatabaseLifecycle } from "../database/dbLifecycle.js";
import { registerEvents } from "../events/index.js";
import { createPrefixHandler } from "../handlers/prefixHandler.js";
import { createSlashHandler } from "../handlers/slashHandler.js";
import { I18nService } from "../i18n/index.js";
import { MemberMessageService } from "../features/memberMessages/service.js";
import { PresenceService } from "../features/presence/service.js";
const bindGracefulShutdown = (shutdown: (signal: string) => Promise<void>): void => {
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
export const bootstrap = async (): Promise<void> => {
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const presenceStore = new PostgresPresenceStore(pool);
const memberMessageStore = new PostgresMemberMessageStore(pool);
const dbLifecycle = new DatabaseLifecycle(
[
{ name: "presenceStore", init: () => presenceStore.init() },
{ name: "memberMessageStore", init: () => memberMessageStore.init() },
],
async () => {
await pool.end();
},
);
const services: AppFeatureServices = {
presenceService: new PresenceService(presenceStore, env.PRESENCE_STREAM_URL),
memberMessageService: new MemberMessageService(memberMessageStore),
};
let shuttingDown = false;
let client: Client | null = null;
const shutdown = async (signal: string, exitCode = 0): Promise<void> => {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`[shutdown] ${signal}`);
if (client) {
client.destroy();
}
await services.presenceService.shutdown().catch((error) => {
console.error("[shutdown] presence service close failed", error);
});
await dbLifecycle.shutdown().catch((error) => {
console.error("[shutdown] database shutdown failed", error);
});
process.exit(exitCode);
};
try {
await dbLifecycle.init();
bindGracefulShutdown((signal) => shutdown(signal));
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
console.error("[process] uncaught exception", error);
});
client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(createCommandList(services, i18n), i18n);
const executor = new CommandExecutor();
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
const onSlashInteraction = createSlashHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry, services);
await client.login(env.DISCORD_TOKEN);
} catch (error) {
await services.presenceService.shutdown().catch((closeError) => {
console.error("[boot] failed to close presence service", closeError);
});
await dbLifecycle.shutdown().catch((closeError) => {
console.error("[boot] failed to shutdown database", closeError);
});
throw error;
}
};
+7
View File
@@ -0,0 +1,7 @@
import type { MemberMessageService } from "../features/memberMessages/service.js";
import type { PresenceService } from "../features/presence/service.js";
export interface AppFeatureServices {
presenceService: PresenceService;
memberMessageService: MemberMessageService;
}
+5 -3
View File
@@ -7,10 +7,12 @@
import { PermissionFlagsBits } from "discord.js";
import { 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),
});
+9 -7
View File
@@ -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,
];
+4 -642
View File
@@ -1,407 +1,8 @@
/**
* Module `presence` — gestion des présences du bot
*
* Contient la logique de timers, rotation d'activités, rendu de templates
* et l'UI panel d'administration. Le module expose des helpers pour restaurer
* l'état (`restorePresenceFromStorage`), arrêter les timers (`shutdownPresenceRuntime`)
* et la commande `presence` qui affiche le panneau.
*/
import {
ActivityType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
ModalBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
TextInputBuilder,
TextInputStyle,
type Client,
type Message,
} from "discord.js";
import { defineCommand } from "../core/commands/defineCommand.js";
import { 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<string, PresenceRuntimeState>();
const createPresenceRuntimeState = (): PresenceRuntimeState => ({
dynamicPresenceRefreshTimer: null,
presenceRotationTimer: null,
activePresenceTextIndex: 0,
activePanelsByUserId: new Map<string, PresencePanelSession>(),
});
const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => {
if (runtimeState.dynamicPresenceRefreshTimer) {
clearInterval(runtimeState.dynamicPresenceRefreshTimer);
runtimeState.dynamicPresenceRefreshTimer = null;
}
if (runtimeState.presenceRotationTimer) {
clearInterval(runtimeState.presenceRotationTimer);
runtimeState.presenceRotationTimer = null;
}
};
const resolveRuntimeState = (client: Client): PresenceRuntimeState => {
const botId = resolveBotId(client) ?? "unbound";
const existing = presenceRuntimeByBotId.get(botId);
if (existing) {
return existing;
}
const next = createPresenceRuntimeState();
presenceRuntimeByBotId.set(botId, next);
return next;
};
/**
* Stoppe toutes les tâches runtime liées aux présences et libère les sessions.
*/
export const shutdownPresenceRuntime = (): void => {
for (const runtimeState of presenceRuntimeByBotId.values()) {
clearRuntimeTimers(runtimeState);
for (const panelSession of runtimeState.activePanelsByUserId.values()) {
panelSession.collector.stop("shutdown");
}
runtimeState.activePanelsByUserId.clear();
}
presenceRuntimeByBotId.clear();
};
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing,
STREAMING: ActivityType.Streaming,
WATCHING: ActivityType.Watching,
LISTENING: ActivityType.Listening,
COMPETING: ActivityType.Competing,
CUSTOM: ActivityType.Custom,
};
const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatus =>
status === "streaming" ? "online" : status;
const resolveBotId = (client: Client): string | null => client.user?.id ?? null;
const normalizePresenceActivityState = (state: PresenceState, runtimeState: PresenceRuntimeState): void => {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
state.activity.texts = activityTexts;
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
runtimeState.activePresenceTextIndex = 0;
}
};
const getActivePresenceTemplateText = (state: PresenceState, runtimeState: PresenceRuntimeState): string => {
normalizePresenceActivityState(state, runtimeState);
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
};
const hasTemplateVariablesInPresenceState = (state: PresenceState, runtimeState: PresenceRuntimeState): boolean => {
normalizePresenceActivityState(state, runtimeState);
return state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
};
const syncDynamicPresenceTimers = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
normalizePresenceActivityState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (state.activity.texts.length > 1) {
runtimeState.presenceRotationTimer = setInterval(() => {
normalizePresenceActivityState(state, runtimeState);
if (state.activity.texts.length <= 1) {
runtimeState.activePresenceTextIndex = 0;
return;
}
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000);
runtimeState.presenceRotationTimer.unref?.();
}
if (!hasTemplateVariablesInPresenceState(state, runtimeState)) {
return;
}
runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
runtimeState.dynamicPresenceRefreshTimer.unref?.();
};
const loadPresenceState = async (client: Client): Promise<PresenceState> => {
const botId = resolveBotId(client);
if (!botId) {
return createDefaultPresenceState();
}
return getPresenceStore().getByBotId(botId);
};
const savePresenceState = async (client: Client, state: PresenceState): Promise<void> => {
const botId = resolveBotId(client);
if (!botId) {
return;
}
await getPresenceStore().upsertByBotId(botId, state);
};
const applyPresenceState = (client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void => {
if (!client.user) {
return;
}
normalizePresenceActivityState(state, runtimeState);
const status = resolveDiscordStatus(state.status);
const templateText = getActivePresenceTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Streaming,
name: text,
url: env.PRESENCE_STREAM_URL,
},
],
});
return;
}
if (state.activity.type === "CUSTOM") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Custom,
name: "Custom Status",
state: text,
},
],
});
return;
}
client.user.setPresence({
status,
activities: [
{
type: DISCORD_ACTIVITY_TYPES[state.activity.type],
name: text,
},
],
});
};
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, status: PresenceStatusValue): string =>
ctx.ct(`ui.status.options.${status}.label`);
const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string =>
ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (
ctx: CommandExecutionContext,
state: PresenceState,
runtimeState: PresenceRuntimeState,
): string => {
normalizePresenceActivityState(state, runtimeState);
const templateText = getActivePresenceTemplateText(state, runtimeState);
const activityPreview = renderPresenceTemplate(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type),
activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
});
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
};
const buildContainer = (
ctx: CommandExecutionContext,
state: PresenceState,
customIds: PresenceCustomIds,
runtimeState: PresenceRuntimeState,
disabled = false,
): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.statusSelect)
.setPlaceholder(ctx.ct("ui.status.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_STATUSES.map((status) => ({
label: statusLabel(ctx, status),
description: ctx.ct(`ui.status.options.${status}.description`),
value: status,
default: status === state.status,
})),
);
const activitySelect = new StringSelectMenuBuilder()
.setCustomId(customIds.activitySelect)
.setPlaceholder(ctx.ct("ui.activity.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_ACTIVITY_TYPES.map((activityType) => ({
label: activityLabel(ctx, activityType),
description: ctx.ct(`ui.activity.options.${activityType}.description`),
value: activityType,
default: activityType === state.activity.type,
})),
);
const textButton = new ButtonBuilder()
.setCustomId(customIds.textButton)
.setLabel(ctx.ct("ui.textButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, state, runtimeState)),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
);
return container;
};
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
const persistAndApplyPresence = async (
ctx: CommandExecutionContext,
state: PresenceState,
runtimeState: PresenceRuntimeState,
): Promise<void> => {
applyPresenceState(ctx.client, state, runtimeState);
syncDynamicPresenceTimers(ctx.client, state, runtimeState);
await savePresenceState(ctx.client, state);
};
/**
* Charge et applique l'état de présence depuis le stockage pour un client.
*/
export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
const state = await loadPresenceState(client);
const runtimeState = resolveRuntimeState(client);
runtimeState.activePresenceTextIndex = 0;
applyPresenceState(client, state, runtimeState);
syncDynamicPresenceTimers(client, state, runtimeState);
};
/**
* Commande `presence` — ouvre le panneau de gestion de la présence pour le bot.
*/
export const presenceCommand = defineCommand({
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<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
runtimeState.activePanelsByUserId.set(ownerId, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isStringSelectMenu()) {
if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.status = nextStatus;
await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
return;
}
if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.activity.type = nextType;
await persistAndApplyPresence(ctx, state, runtimeState);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) {
normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
runtimeState.activePresenceTextIndex = 0;
await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
if (interaction.customId === customIds.intervalButton) {
normalizePresenceActivityState(state, runtimeState);
const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
const nextSeconds = Number(rawSeconds);
if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await persistAndApplyPresence(ctx, state, runtimeState);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, state, customIds, runtimeState)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) {
console.error("[command:presence] interaction failed", error);
const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
return;
}
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
}
});
collector.on("end", async () => {
const currentPanel = runtimeState.activePanelsByUserId.get(ownerId);
if (currentPanel?.collector === collector) {
runtimeState.activePanelsByUserId.delete(ownerId);
}
await disablePanel();
});
},
execute: createPresenceCommandExecute(presenceService),
});
+5 -3
View File
@@ -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),
});
@@ -0,0 +1,45 @@
export interface ComponentPanelSession {
collector: {
stop: (reason?: string) => void;
};
disable: () => Promise<void>;
}
export class ComponentSessionRegistry<T extends ComponentPanelSession> {
private readonly sessions = new Map<string, T>();
public get(key: string): T | undefined {
return this.sessions.get(key);
}
public async replace(key: string, next: T): Promise<void> {
const existing = this.sessions.get(key);
if (existing) {
existing.collector.stop("replaced");
await existing.disable().catch(() => undefined);
}
this.sessions.set(key, next);
}
public deleteIfCollectorMatch(key: string, collector: T["collector"]): void {
const existing = this.sessions.get(key);
if (!existing) {
return;
}
if (existing.collector === collector) {
this.sessions.delete(key);
}
}
public async stopAll(reason = "shutdown"): Promise<void> {
const entries = [...this.sessions.values()];
this.sessions.clear();
for (const session of entries) {
session.collector.stop(reason);
await session.disable().catch(() => undefined);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Message } from "discord.js";
const isMessageResult = (value: unknown): value is Message => {
if (!value || typeof value !== "object") {
return false;
}
return "createMessageComponentCollector" in value && "edit" in value;
};
export const resolveReplyMessage = (value: unknown): Message | null => {
if (isMessageResult(value)) {
return value;
}
if (!value || typeof value !== "object" || !("resource" in value)) {
return null;
}
const resource = (value as { resource?: unknown }).resource;
if (!resource || typeof resource !== "object" || !("message" in resource)) {
return null;
}
const message = (resource as { message?: unknown }).message;
return isMessageResult(message) ? message : null;
};
+26
View File
@@ -0,0 +1,26 @@
export interface DatabaseInitializable {
name: string;
init: () => Promise<void>;
}
export class DatabaseLifecycle {
public constructor(
private readonly initializables: DatabaseInitializable[],
private readonly shutdownFn: () => Promise<void>,
) {}
public async init(): Promise<void> {
for (const resource of this.initializables) {
try {
await resource.init();
} catch (error) {
await this.shutdownFn().catch(() => undefined);
throw new Error(`[db:init] ${resource.name} failed`, { cause: error });
}
}
}
public async shutdown(): Promise<void> {
await this.shutdownFn();
}
}
@@ -1,6 +1,5 @@
import { Pool } from "pg";
import type { Pool } from "pg";
import { env } from "../../config/env.js";
import type {
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<void> {
@@ -101,50 +101,10 @@ class MemberMessageStore {
);
}
public async close(): Promise<void> {
await this.pool.end();
public async deleteByBotGuild(botId: string, guildId: string): Promise<void> {
await this.pool.query(
"DELETE FROM bot_member_message_configs WHERE bot_id = $1 AND guild_id = $2",
[botId, guildId],
);
}
}
let store: MemberMessageStore | null = null;
export const initMemberMessageStore = async (): Promise<MemberMessageStore> => {
if (store) {
return store;
}
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const nextStore = new MemberMessageStore(pool);
try {
await nextStore.init();
store = nextStore;
return nextStore;
} catch (error) {
await pool.end().catch(() => {
// Ignore close errors; the original init error should be preserved.
});
throw error;
}
};
export const getMemberMessageStore = (): MemberMessageStore => {
if (!store) {
throw new Error("MemberMessageStore is not initialized. Call initMemberMessageStore() during bootstrap.");
}
return store;
};
export const shutdownMemberMessageStore = async (): Promise<void> => {
if (!store) {
return;
}
await store.close();
store = null;
};
@@ -1,6 +1,5 @@
import { Pool } from "pg";
import type { Pool } from "pg";
import { env } from "../../config/env.js";
import type {
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<void> {
@@ -147,50 +147,4 @@ class PresenceStore {
],
);
}
public async close(): Promise<void> {
await this.pool.end();
}
}
let store: PresenceStore | null = null;
export const initPresenceStore = async (): Promise<PresenceStore> => {
if (store) {
return store;
}
const pool = new Pool({
connectionString: env.DATABASE_URL,
ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined,
});
const nextStore = new PresenceStore(pool);
try {
await nextStore.init();
store = nextStore;
return nextStore;
} catch (error) {
await pool.end().catch(() => {
// Ignore close errors; the original init error is the one we want to surface.
});
throw error;
}
};
export const getPresenceStore = (): PresenceStore => {
if (!store) {
throw new Error("PresenceStore is not initialized. Call initPresenceStore() during bootstrap.");
}
return store;
};
export const shutdownPresenceStore = async (): Promise<void> => {
if (!store) {
return;
}
await store.close();
store = null;
};
+12 -6
View File
@@ -1,12 +1,18 @@
import { Events, type Client } from "discord.js";
/**
* 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);
});
});
};
+9 -8
View File
@@ -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,
+9 -8
View File
@@ -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,
+17 -20
View File
@@ -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<void>; onSlashInteraction: (i: ChatInputCommandInteraction) => Promise<void> },
handlers: {
onPrefixMessage: (m: Message) => Promise<void>;
onSlashInteraction: (i: ChatInputCommandInteraction) => Promise<void>;
},
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);
};
+2 -12
View File
@@ -11,21 +11,11 @@ export const registerInteractionCreate = (
onSlashInteraction: (interaction: ChatInputCommandInteraction) => Promise<void>,
): 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é cidessus
void onSlashInteraction(interaction as ChatInputCommandInteraction).catch((error) => {
void onSlashInteraction(interaction).catch((error) => {
console.error("[event:interactionCreate] handler failed", error);
});
});
+10 -9
View File
@@ -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);
}
@@ -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<MemberMessagePanelSession>();
const activePanelsByUser = new Map<string, MemberMessagePanelSession>();
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<void> => {
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<void> => {
await getMemberMessageStore().upsertByBotGuildKind(botId, guild.id, kind, config);
await memberMessageService.saveConfig(botId, guild.id, kind, config);
};
const disablePanel = async (): Promise<void> => {
@@ -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();
});
};
+10
View File
@@ -0,0 +1,10 @@
import type {
MemberMessageConfig,
MemberMessageKind,
} from "../../types/memberMessages.js";
export interface MemberMessageRepository {
getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig>;
upsertByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind, config: MemberMessageConfig): Promise<void>;
deleteByBotGuild(botId: string, guildId: string): Promise<void>;
}
@@ -15,13 +15,15 @@ import type { SupportedLang } from "../../types/command.js";
import type {
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<DispatchMemberMessageResult> => {
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<void> {
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<void> {
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<DispatchMemberMessageResult> {
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,
};
}
}
};
}
+374
View File
@@ -0,0 +1,374 @@
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ContainerBuilder,
MessageFlags,
ModalBuilder,
StringSelectMenuBuilder,
TextDisplayBuilder,
TextInputBuilder,
TextInputStyle,
} from "discord.js";
import { resolveReplyMessage } from "../../core/discord/replyMessageResolver.js";
import type { CommandExecutionContext } from "../../types/command.js";
import {
MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
PRESENCE_ACTIVITY_TYPES,
PRESENCE_STATUSES,
isPresenceActivityTypeValue,
isPresenceRotationIntervalSecondsValue,
isPresenceStatusValue,
sanitizeActivityTexts,
} from "../../validators/presence.js";
import { getPresenceTemplateHelpText } from "./templateVariables.js";
import type { PresenceService } from "./service.js";
import type { PresenceCustomIds } from "./types.js";
const createCustomIds = (): PresenceCustomIds => {
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
return {
statusSelect: `presence:status:${nonce}`,
activitySelect: `presence:activity:${nonce}`,
textButton: `presence:text:${nonce}`,
intervalButton: `presence:interval:${nonce}`,
textModal: `presence:modal:${nonce}`,
textInput: `presence:text-input:${nonce}`,
intervalModal: `presence:interval-modal:${nonce}`,
intervalInput: `presence:interval-input:${nonce}`,
};
};
const statusLabel = (ctx: CommandExecutionContext, status: string): string =>
ctx.ct(`ui.status.options.${status}.label`);
const activityLabel = (ctx: CommandExecutionContext, activityType: string): string =>
ctx.ct(`ui.activity.options.${activityType}.label`);
const panelContent = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
): string => {
const runtimeState = service.getRuntimeState(ctx.client);
service.normalizeState(state, runtimeState);
const templateText = service.getActiveTemplateText(state, runtimeState);
const activityPreview = service.renderPreview(ctx.client, templateText);
const activityTexts = state.activity.texts.map((text, index) => `${index + 1}. ${text}`).join(" | ");
const currentIndex = Math.min(runtimeState.activePresenceTextIndex + 1, state.activity.texts.length);
const summary = ctx.ct("responses.panel", {
status: statusLabel(ctx, state.status),
activityType: activityLabel(ctx, state.activity.type),
activityText: templateText,
activityPreview,
activityTexts,
textCount: state.activity.texts.length,
currentTextIndex: currentIndex,
rotationIntervalSeconds: state.activity.rotationIntervalSeconds,
doubleBracesHint: "{{var}}",
variables: getPresenceTemplateHelpText(),
});
return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`;
};
const buildContainer = (
ctx: CommandExecutionContext,
service: PresenceService,
state: Awaited<ReturnType<PresenceService["loadState"]>>,
customIds: PresenceCustomIds,
disabled = false,
): ContainerBuilder => {
const statusSelect = new StringSelectMenuBuilder()
.setCustomId(customIds.statusSelect)
.setPlaceholder(ctx.ct("ui.status.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_STATUSES.map((status) => ({
label: statusLabel(ctx, status),
description: ctx.ct(`ui.status.options.${status}.description`),
value: status,
default: status === state.status,
})),
);
const activitySelect = new StringSelectMenuBuilder()
.setCustomId(customIds.activitySelect)
.setPlaceholder(ctx.ct("ui.activity.placeholder"))
.setMinValues(1)
.setMaxValues(1)
.setDisabled(disabled)
.setOptions(
PRESENCE_ACTIVITY_TYPES.map((activityType) => ({
label: activityLabel(ctx, activityType),
description: ctx.ct(`ui.activity.options.${activityType}.description`),
value: activityType,
default: activityType === state.activity.type,
})),
);
const textButton = new ButtonBuilder()
.setCustomId(customIds.textButton)
.setLabel(ctx.ct("ui.textButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const intervalButton = new ButtonBuilder()
.setCustomId(customIds.intervalButton)
.setLabel(ctx.ct("ui.intervalButton"))
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled);
const container = new ContainerBuilder();
container.addTextDisplayComponents(
new TextDisplayBuilder().setContent(panelContent(ctx, service, state)),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(statusSelect),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
);
container.addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(textButton, intervalButton),
);
return container;
};
export const createPresenceCommandExecute = (presenceService: PresenceService) => {
return async (ctx: CommandExecutionContext): Promise<void> => {
const state = await presenceService.loadState(ctx.client);
presenceService.applyState(ctx.client, state);
const customIds = createCustomIds();
const replyResult = await ctx.reply({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
withResponse: true,
});
const replyMessage = resolveReplyMessage(replyResult);
if (!replyMessage) {
return;
}
const ownerId = ctx.user.id;
const sessionKey = presenceService.panelSessionKey(ctx.client, ownerId);
const disablePanel = async (): Promise<void> => {
await replyMessage
.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds, true)],
})
.catch(() => undefined);
};
const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 });
await presenceService.replacePanelSession(sessionKey, { collector, disable: disablePanel });
collector.on("collect", async (interaction) => {
if (interaction.user.id !== ownerId) {
await interaction.reply({
content: ctx.ct("responses.notOwner"),
flags: [MessageFlags.Ephemeral],
});
return;
}
try {
if (interaction.isStringSelectMenu()) {
if (interaction.customId === customIds.statusSelect) {
const nextStatus = interaction.values[0];
if (!nextStatus || !isPresenceStatusValue(nextStatus)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.status = nextStatus;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
if (interaction.customId === customIds.activitySelect) {
const nextType = interaction.values[0];
if (!nextType || !isPresenceActivityTypeValue(nextType)) {
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
state.activity.type = nextType;
await presenceService.persistAndApply(ctx.client, state);
await interaction.update({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
if (interaction.isButton()) {
if (interaction.customId === customIds.textButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.textModal)
.setTitle(ctx.ct("ui.modal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.textInput)
.setLabel(ctx.ct("ui.modal.label"))
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder(ctx.ct("ui.modal.placeholder"))
.setRequired(true)
.setMaxLength(1_800)
.setValue(state.activity.texts.join("\n")),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.textModal
&& modalInteraction.user.id === ownerId,
});
const nextTexts = sanitizeActivityTexts(
submitted.fields.getTextInputValue(customIds.textInput).split(/\r?\n/g),
);
state.activity.texts = nextTexts;
state.activity.text = nextTexts[0] ?? state.activity.text;
const runtimeState = presenceService.getRuntimeState(ctx.client);
runtimeState.activePresenceTextIndex = 0;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
if (interaction.customId === customIds.intervalButton) {
const modal = new ModalBuilder()
.setCustomId(customIds.intervalModal)
.setTitle(ctx.ct("ui.intervalModal.title"))
.addComponents(
new ActionRowBuilder<TextInputBuilder>().addComponents(
new TextInputBuilder()
.setCustomId(customIds.intervalInput)
.setLabel(ctx.ct("ui.intervalModal.label"))
.setStyle(TextInputStyle.Short)
.setPlaceholder(ctx.ct("ui.intervalModal.placeholder"))
.setRequired(true)
.setMaxLength(4)
.setValue(String(state.activity.rotationIntervalSeconds)),
),
);
await interaction.showModal(modal);
try {
const submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (modalInteraction) =>
modalInteraction.customId === customIds.intervalModal
&& modalInteraction.user.id === ownerId,
});
const rawSeconds = submitted.fields.getTextInputValue(customIds.intervalInput).trim();
if (!/^\d+$/.test(rawSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
const nextSeconds = Number(rawSeconds);
if (!isPresenceRotationIntervalSecondsValue(nextSeconds)) {
await submitted.reply({
content: ctx.ct("responses.invalidInterval", {
minSeconds: MIN_ACTIVITY_ROTATION_INTERVAL_SECONDS,
maxSeconds: MAX_ACTIVITY_ROTATION_INTERVAL_SECONDS,
}),
flags: [MessageFlags.Ephemeral],
});
return;
}
state.activity.rotationIntervalSeconds = nextSeconds;
await presenceService.persistAndApply(ctx.client, state);
await submitted.deferUpdate();
await replyMessage.edit({
flags: MessageFlags.IsComponentsV2,
components: [buildContainer(ctx, presenceService, state, customIds)],
});
} catch {
await interaction.followUp({
content: ctx.ct("responses.modalTimeout"),
flags: [MessageFlags.Ephemeral],
}).catch(() => undefined);
}
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
return;
}
await interaction.reply({ content: ctx.ct("responses.invalidSelection"), flags: [MessageFlags.Ephemeral] });
} catch (error) {
console.error("[command:presence] interaction failed", error);
const fallback = ctx.t("errors.execution");
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
return;
}
await interaction.followUp({ content: fallback, flags: [MessageFlags.Ephemeral] }).catch(() => undefined);
}
});
collector.on("end", async () => {
presenceService.deletePanelSessionIfCollectorMatch(sessionKey, collector);
await disablePanel();
});
};
};
+6
View File
@@ -0,0 +1,6 @@
import type { PresenceState } from "../../types/presence.js";
export interface PresenceRepository {
getByBotId(botId: string): Promise<PresenceState>;
upsertByBotId(botId: string, state: PresenceState): Promise<void>;
}
+234
View File
@@ -0,0 +1,234 @@
import { ActivityType, type Client } from "discord.js";
import { ComponentSessionRegistry } from "../../core/discord/componentSessionRegistry.js";
import type {
PresenceActivityTypeValue,
PresenceState,
PresenceStatusValue,
} from "../../types/presence.js";
import {
createDefaultPresenceState,
sanitizeActivityText,
sanitizeActivityTexts,
sanitizePresenceRotationIntervalSeconds,
} from "../../validators/presence.js";
import {
PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS,
containsPresenceTemplateVariables,
renderPresenceTemplate,
} from "./templateVariables.js";
import type { PresenceRepository } from "./repository.js";
import type { PresencePanelSession, PresenceRuntimeState } from "./types.js";
const DISCORD_ACTIVITY_TYPES: Record<PresenceActivityTypeValue, ActivityType> = {
PLAYING: ActivityType.Playing,
STREAMING: ActivityType.Streaming,
WATCHING: ActivityType.Watching,
LISTENING: ActivityType.Listening,
COMPETING: ActivityType.Competing,
CUSTOM: ActivityType.Custom,
};
const createRuntimeState = (): PresenceRuntimeState => ({
dynamicPresenceRefreshTimer: null,
presenceRotationTimer: null,
activePresenceTextIndex: 0,
});
const clearRuntimeTimers = (runtimeState: PresenceRuntimeState): void => {
if (runtimeState.dynamicPresenceRefreshTimer) {
clearInterval(runtimeState.dynamicPresenceRefreshTimer);
runtimeState.dynamicPresenceRefreshTimer = null;
}
if (runtimeState.presenceRotationTimer) {
clearInterval(runtimeState.presenceRotationTimer);
runtimeState.presenceRotationTimer = null;
}
};
const resolveDiscordStatus = (status: PresenceStatusValue): "online" | "idle" | "dnd" | "invisible" => {
return status === "streaming" ? "online" : status;
};
export class PresenceService {
private readonly runtimeByBotId = new Map<string, PresenceRuntimeState>();
private readonly panelSessions = new ComponentSessionRegistry<PresencePanelSession>();
public constructor(
private readonly repository: PresenceRepository,
private readonly streamUrl: string,
) {}
public resolveBotId(client: Client): string | null {
return client.user?.id ?? null;
}
public getRuntimeState(client: Client): PresenceRuntimeState {
const botId = this.resolveBotId(client) ?? "unbound";
const existing = this.runtimeByBotId.get(botId);
if (existing) {
return existing;
}
const next = createRuntimeState();
this.runtimeByBotId.set(botId, next);
return next;
}
public normalizeState(state: PresenceState, runtimeState: PresenceRuntimeState): void {
const activityTexts = sanitizeActivityTexts(state.activity.texts);
state.activity.texts = activityTexts;
state.activity.text = activityTexts[0] ?? sanitizeActivityText(state.activity.text);
state.activity.rotationIntervalSeconds = sanitizePresenceRotationIntervalSeconds(state.activity.rotationIntervalSeconds);
if (runtimeState.activePresenceTextIndex >= activityTexts.length) {
runtimeState.activePresenceTextIndex = 0;
}
}
public getActiveTemplateText(state: PresenceState, runtimeState: PresenceRuntimeState): string {
this.normalizeState(state, runtimeState);
return state.activity.texts[runtimeState.activePresenceTextIndex] ?? state.activity.text;
}
public renderPreview(client: Client, templateText: string): string {
return renderPresenceTemplate(client, templateText);
}
public async loadState(client: Client): Promise<PresenceState> {
const botId = this.resolveBotId(client);
if (!botId) {
return createDefaultPresenceState();
}
return this.repository.getByBotId(botId);
}
public applyState(client: Client, state: PresenceState): void {
const runtimeState = this.getRuntimeState(client);
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public async persistAndApply(client: Client, state: PresenceState): Promise<void> {
this.applyState(client, state);
const botId = this.resolveBotId(client);
if (!botId) {
return;
}
await this.repository.upsertByBotId(botId, state);
}
public async restoreFromStorage(client: Client): Promise<void> {
const state = await this.loadState(client);
const runtimeState = this.getRuntimeState(client);
runtimeState.activePresenceTextIndex = 0;
this.applyPresenceState(client, state, runtimeState);
this.syncDynamicPresenceTimers(client, state, runtimeState);
}
public panelSessionKey(client: Client, userId: string): string {
return `${this.resolveBotId(client) ?? "unbound"}:${userId}`;
}
public async replacePanelSession(key: string, session: PresencePanelSession): Promise<void> {
await this.panelSessions.replace(key, session);
}
public deletePanelSessionIfCollectorMatch(key: string, collector: PresencePanelSession["collector"]): void {
this.panelSessions.deleteIfCollectorMatch(key, collector);
}
public async shutdown(): Promise<void> {
for (const runtimeState of this.runtimeByBotId.values()) {
clearRuntimeTimers(runtimeState);
}
this.runtimeByBotId.clear();
await this.panelSessions.stopAll("shutdown");
}
private applyPresenceState(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
if (!client.user) {
return;
}
this.normalizeState(state, runtimeState);
const status = resolveDiscordStatus(state.status);
const templateText = this.getActiveTemplateText(state, runtimeState);
const text = renderPresenceTemplate(client, templateText);
if (state.status === "streaming" || state.activity.type === "STREAMING") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Streaming,
name: text,
url: this.streamUrl,
},
],
});
return;
}
if (state.activity.type === "CUSTOM") {
client.user.setPresence({
status,
activities: [
{
type: ActivityType.Custom,
name: "Custom Status",
state: text,
},
],
});
return;
}
client.user.setPresence({
status,
activities: [
{
type: DISCORD_ACTIVITY_TYPES[state.activity.type],
name: text,
},
],
});
}
private syncDynamicPresenceTimers(client: Client, state: PresenceState, runtimeState: PresenceRuntimeState): void {
this.normalizeState(state, runtimeState);
clearRuntimeTimers(runtimeState);
if (state.activity.texts.length > 1) {
runtimeState.presenceRotationTimer = setInterval(() => {
this.normalizeState(state, runtimeState);
if (state.activity.texts.length <= 1) {
runtimeState.activePresenceTextIndex = 0;
return;
}
runtimeState.activePresenceTextIndex = (runtimeState.activePresenceTextIndex + 1) % state.activity.texts.length;
this.applyPresenceState(client, state, runtimeState);
}, state.activity.rotationIntervalSeconds * 1_000);
runtimeState.presenceRotationTimer.unref?.();
}
const hasKnownVariable = state.activity.texts.some((templateText) => containsPresenceTemplateVariables(templateText));
if (!hasKnownVariable) {
return;
}
runtimeState.dynamicPresenceRefreshTimer = setInterval(() => {
this.applyPresenceState(client, state, runtimeState);
}, PRESENCE_TEMPLATE_REFRESH_INTERVAL_MS);
runtimeState.dynamicPresenceRefreshTimer.unref?.();
}
}
@@ -2,7 +2,7 @@ import type { Client } from "discord.js";
import { env } from "../../config/env.js";
import { 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;
+20
View File
@@ -0,0 +1,20 @@
import type { ComponentPanelSession } from "../../core/discord/componentSessionRegistry.js";
export interface PresenceCustomIds {
statusSelect: string;
activitySelect: string;
textButton: string;
intervalButton: string;
textModal: string;
textInput: string;
intervalModal: string;
intervalInput: string;
}
export interface PresenceRuntimeState {
dynamicPresenceRefreshTimer: NodeJS.Timeout | null;
presenceRotationTimer: NodeJS.Timeout | null;
activePresenceTextIndex: number;
}
export interface PresencePanelSession extends ComponentPanelSession {}
-20
View File
@@ -45,26 +45,6 @@
"pong": "Pong. Websocket latency: {{latency}}ms."
}
},
"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.",
-20
View File
@@ -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.",
-20
View File
@@ -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.",
+2 -144
View File
@@ -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<void> => {
console.log(`[shutdown] ${signal}`);
// Stoppe les timers et runtime liés à la présence
shutdownPresenceRuntime();
// Ferme proprement le store des messages de membre
await shutdownMemberMessageStore().catch((error) => {
console.error("[shutdown] member message store close failed", error);
});
// Ferme proprement le store de présence
await shutdownPresenceStore().catch((error) => {
console.error("[shutdown] presence store close failed", error);
});
process.exit(0);
};
process.once("SIGINT", () => {
void shutdown("SIGINT");
});
process.once("SIGTERM", () => {
void shutdown("SIGTERM");
});
};
/**
* Sequence d'initialisation principale.
*
* Etapes:
* 1. initialiser les stores (presence, member messages)
* 2. attacher shutdown handler
* 3. configurer la gestion d'erreurs process
* 4. créer le client Discord et les services (i18n, registry, executor)
* 5. construire les handlers prefix/slash et attacher les listeners
* 6. enregistrer events métier (member messages)
* 7. sur ready: restaurer la présence et déployer les slash commands si activé
*/
const bootstrap = async (): Promise<void> => {
// Initialisation des stores persistants utilisés par le bot
await initPresenceStore();
await initMemberMessageStore();
// Prépare la gestion d'un arrêt gracieux (SIGINT / SIGTERM)
bindGracefulShutdown();
// Log des erreurs non catchées pour debugging
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
console.error("[process] uncaught exception", error);
});
// Création du client Discord avec les intents nécessaires
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// Services transverses: i18n, registry des commandes et executor
const i18n = new I18nService(env.DEFAULT_LANG);
const registry = new CommandRegistry(commandList, i18n);
const executor = new CommandExecutor();
// Création des handlers : ces factories retournent une fonction utilitaire
// qui prend un Message / Interaction et exécute la logique (parse args, permissions, etc.)
const onPrefixMessage = createPrefixHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
const onSlashInteraction = createSlashHandler({
registry,
i18n,
executor,
prefix: env.PREFIX,
defaultLang: env.DEFAULT_LANG,
});
// Enregistre les événements principaux (séparés par fichier)
// `registerEvents` inclut désormais l'enregistrement du listener `ready`.
registerEvents(client, i18n, { onPrefixMessage, onSlashInteraction }, registry);
// Connexion du client au gateway
await client.login(env.DISCORD_TOKEN);
};
// Démarre la séquence d'initialisation et gère les erreurs fatales
bootstrap().catch(async (error) => {
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);
});
@@ -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"];
@@ -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[] = [
+1 -1
View File
@@ -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([" ", ""]);