Skip to content

Créer un module ​

Un module dans OmniBot est une unité fonctionnelle autonome qui peut être installée et désinstallée par serveur Discord. Chaque module peut contenir des commandes, des écouteurs d'événements, des gestionnaires d'interactions, des services, une configuration et des modèles de base de données.

Générer un module ​

Ne partez pas de zéro. Générez un squelette fonctionnel (définition, schéma config, une commande, un listener, i18n/en+fr.json, un modèle Prisma commenté, et un test) :

bash
pnpm new-module mon-module "Mon Module"

Le module généré compile et son test passe sans retouche. Remplissez la description TODO, puis activez-le via /modules sur votre serveur dev. Aucune étape d'enregistrement : les modules sont auto-découverts depuis src/modules/ au démarrage.

Pour les tests, utilisez les helpers partagés de #lib/testing.js (makeTestConfig, fakeGuild, fakeMessage, initTestI18n) plutôt que des mocks maison. Notez que vi.mock("#core/runtime.js") et vi.mock("#lib/database.js") doivent toujours être déclarés par fichier de test — Vitest les hisse, aucun helper ne peut les masquer.

src/modules/mon-module/
├── mon-module.module.ts          # Définition principale du module
├── i18n/                         # Fichiers de traduction
│   ├── en.json
│   └── fr.json
├── commands/                     # Commandes slash
│   └── saluer.command.ts
├── listeners/                    # Écouteurs d'événements
│   └── message-create.listener.ts
├── interactions/                 # Gestionnaires de boutons, modales, selects
│   ├── confirmer.button.ts
│   └── retour.modal.ts
├── services/                     # Logique métier
│   └── mon-service.service.ts
└── models/                       # Schémas Prisma (optionnel)
    └── mon-modele.prisma

Guide pas à pas : Créer un module « Salut » ​

TIP

Le name et description du module doivent être en anglais par défaut — ils servent de valeur de repli quand aucun fichier de traduction ne correspond à la locale du serveur. Ajoutez les traductions dans les fichiers i18n/ pour les autres locales.

1. Créer le dossier et le fichier principal ​

typescript
// src/modules/salut/salut.module.ts

import logger from "#lib/logger.js";
import { defineModule } from "#lib/module.js";

export default defineModule({
  id: "salut",
  name: "Salut",
  description: "Accueille les nouveaux membres et dit bonjour.",
  version: "1.0.0",
  author: "Vous",

  onLoad(_client, registry) {
    logger.info("Module Salut chargé");
  },

  onInstall(_client, guild) {
    logger.info(`Salut installé sur ${guild.name}`);
  },

  onUninstall(_client, guild) {
    logger.info(`Salut désinstallé de ${guild.name}`);
  },
});

2. Ajouter une commande slash ​

typescript
// src/modules/salut/commands/bonjour.command.ts

import { SlashCommandBuilder } from "discord.js";
import { declareCommand } from "#lib/command.js";

export default declareCommand({
  data: new SlashCommandBuilder()
    .setName("bonjour")
    .setDescription("Dit bonjour !"),

  async execute(interaction) {
    await interaction.reply(`Bonjour, ${interaction.user.username} !`);
  },
});

3. Enregistrer la commande dans le module ​

typescript
// src/modules/salut/salut.module.ts
import bonjourCommand from "./commands/bonjour.command.js";

export default defineModule({
  // ... id, nom, etc.

  onLoad(_client, registry) {
    registry.register(bonjourCommand);
    logger.info("Module Salut chargé");
  },
  // ...
});

4. Lancer et tester ​

Démarrez le bot (voir Pour commencer), installez le module Salut via /modules, puis exécutez /bonjour.

API ModuleDeclaration ​

typescript
interface ModuleDeclaration {
  id: string; // Identifiant unique en kebab-case
  name: string; // Nom affiché dans l'interface
  description: string; // Description affichée dans l'interface
  version: Version; // Semver "x.y.z"
  author?: string; // Nom de l'auteur (optionnel)
  intents?: GatewayIntentBits[]; // Intentions Discord Gateway
  partials?: Partials[]; // Partials discord.js, ex. événements sur des messages hors cache
  devOnly?: boolean; // Chargé seulement en mode dev
  config?: ConfigSchema; // Schéma de configuration

  onLoad: (client: Client, registry: Registry) => void;
  onInstall: (client: Client, guild: Guild, registry: Registry) => void;
  onUninstall: (client: Client, guild: Guild, registry: Registry) => void;
  maintenanceTasks?: () => ScheduledTask[];
  deleteMemberData?: (guildId: string, userId: string) => Promise<void>;
}

Cycles de vie ​

HookQuandUtilisation typique
onLoadDémarrage du botEnregistrer commandes, écouteurs, interactions via registry.register()
onInstallModule activé sur un serveurCréer des rôles, envoyer des messages de bienvenue, initialiser des données
onUninstallModule désactivé sur un serveurNettoyer les données, supprimer les rôles, effacer les configurations
deleteMemberDataUn membre lance /privacy delete sur un serveurSupprimer tout ce que le module garde sur ce membre sur ce serveur

Un module qui enregistre quoi que ce soit lié à l'ID d'un membre doit implémenter deleteMemberData, et décrire ce qu'il enregistre dans la politique de confidentialité (docs/site/*/legal/privacy.md). Il est appelé que le module soit activé ou non sur le serveur, et aussi pour un module écarté parce que Discord n'a pas accordé son intention privilégiée.

Les purges de données stockées vont dans maintenanceTasks, pas dans des tâches enregistrées depuis onLoad : elles sont planifiées pour chaque module découvert, y compris un module écarté parce que Discord n'a pas accordé son intention privilégiée, pour que les durées de conservation de la politique de confidentialité tiennent dans tous les cas.

Gestion des intentions ​

Déclarez uniquement les intentions nécessaires à votre module. Les intentions requises doivent également être activées dans le Discord Developer Portal sous Bot > Privileged Gateway Intents.

Au démarrage, le bot lit les intentions privilégiées (GuildMembers, GuildPresences, MessageContent) accordées à l'application, en version complète ou limitée. Un module qui en déclare une absente est écarté avec un avertissement, au lieu que Discord refuse la connexion, et /modules le montre comme indisponible en nommant l'intention manquante ; il est rechargé au premier redémarrage qui suit l'octroi de l'intention. Si les flags ne peuvent pas être lus, tous les modules sont chargés, comme avant. Le cœur, lui, ne peut pas être écarté : s'il déclarait un jour une intention privilégiée non accordée, le bot s'arrêterait au démarrage avec une erreur qui la nomme.

typescript
intents: [
  GatewayIntentBits.Guilds,
  GatewayIntentBits.GuildMessages,
  GatewayIntentBits.MessageContent,
],

Comment fonctionne l'auto-découverte ​

Le chargeur de modules (src/core/loaders/module-loader.ts) parcourt src/modules/ au démarrage :

  1. Liste chaque sous-dossier
  2. Trouve un fichier *.module.ts à l'intérieur
  3. L'importe dynamiquement
  4. Vérifie que l'export a type: DeclarationType.Module
  5. Retourne l'objet Module

Modules devOnly (avec devOnly: true) sont ignorés quand NODE_ENV n'est pas "development". Utilisez ceci pour les modules de test ou de débogage.

Internationalisation ​

Les modules peuvent fournir des traductions pour leurs chaînes d'interface (nom, description, champs de configuration et messages du bot) via des fichiers de locale par module.

Ajouter des traductions ​

Créez un dossier i18n/ dans votre module avec un fichier JSON par locale :

src/modules/mon-module/
├── mon-module.module.ts
├── i18n/
│   ├── en.json
│   └── fr.json
└── commands/
    └── ...

Le chargeur de modules découvre automatiquement ces fichiers au démarrage et les enregistre dans i18next.

Format des fichiers de traduction ​

Chaque fichier contient des paires clé-valeur. Le name et la description des champs de configuration sont automatiquement résolus depuis ces fichiers, remplaçant les valeurs par défaut TypeScript quand la locale correspondante est active. Le name et la description du module lui-même se traduisent en revanche dans les fichiers du core, sous modules.<id>.name et modules.<id>.description (voir le guide de localisation) :

json
{
  "config.monChamp.name": "Mon Champ",
  "config.monChamp.description": "Description de mon champ.",
  "salutation": "Bonjour {{name}} !"
}

Utiliser les traductions dans le code ​

Le ConfigProvider injecté dans les commandes, écouteurs et interactions expose une méthode t() :

typescript
async execute(interaction, config) {
  const salutation = config.t("salutation", { name: interaction.user.username });
  await interaction.reply(salutation);
}

Les clés sont d'abord cherchées dans le namespace du module, puis dans celui du cœur. Les libellés communs (config.previous, config.next, modules.enable, etc.) sont fournis par le namespace cœur — pas besoin de les redéfinir dans chaque module.

Sélection de la locale ​

La locale du serveur est configurée via les paramètres du module Cœur (/config core > locale). Quand un fichier de locale n'existe pas pour la langue sélectionnée, le système utilise l'anglais par défaut.

Liste de vérification avant publication ​

Avant que votre module n'atteigne des serveurs de production :

  • Incrémentez version quand vous ajoutez, renommez ou modifiez une commande — la production ne ré-enregistre les commandes que lors d'un changement de version (le dev resynchronise à chaque démarrage, facile à rater en local)
  • Listez les commandes supprimées ou renommées dans retiredCommands (anciens noms) en plus d'incrémenter la version — la mise à jour en production ne supprime que les commandes que le module nomme encore, sinon l'ancienne commande reste sur tous les serveurs
  • Renseignez DEV_GUILD_ID dans .env — sans lui, le mode dev n'enregistre aucune commande
  • Préfixez noms de commandes et customId avec votre id de module — les deux sont résolus premier-arrivé-premier-servi entre tous les modules, et un : dans un argument décale le parsing des customId
  • Gardez if (!config) return dans les listeners — hors serveurs (MP), ils tournent avec une config undefined
  • Mettez requiresAdmin: true sur chaque commande et handler sensible — sinon exposé à tout le monde
  • Jamais d'objet en defaultValue d'entité (USER/ROLE/CHANNEL/CATEGORY restent non renseignés ; les ids sont stockés, les objets sont réhydratés)
  • Déclarez les options des ENUM avec as const pour typer config.get() en union littérale
  • Ordre Prisma : écrire models/*.prisma, puis pnpm prisma:generate, puis pnpm prisma:migrate — et gardez des noms de modèles uniques entre modules
  • Pas de side-effect au top-level — les modules devOnly sont quand même importés en production ; seul leur enregistrement est sauté

Bonnes pratiques ​

  • Gardez onLoad léger — enregistrez les artefacts et loggez, déplacez la logique dans les services
  • Utilisez le kebab-case pour les IDs de module et les noms de dossiers
  • Gérez les erreurs dans les hooks de cycle de vie — utilisez try/catch dans onInstall/onUninstall
  • Organisez les fichiers — un artefact par fichier, utilisez des sous-dossiers
  • Déclarez uniquement les intentions nécessaires — minimisez l'utilisation des intentions privilégiées

Prochaines étapes ​