À mesure qu'un bot Discord grandit, garder toutes les commandes dans un seul énorme index.js tourne vite au cauchemar. Un command handler Discord bien conçu garde chaque commande et chaque event dans son propre fichier, puis parcourt ces dossiers au démarrage et les charge automatiquement. Le gain : ajouter une fonctionnalité revient à créer un seul fichier — tu ne touches plus jamais au point d'entrée. Dans cet article, nous allons construire une structure modulaire, basée sur des fichiers et réellement évolutive avec discord.js v14.
Pourquoi un handler basé sur des fichiers ?
Pour un petit bot, une suite de if (command === 'ping') peut sembler suffisante. Mais une fois arrivé à 30-40 commandes, cette approche s'effondre : le fichier gonfle à des centaines de lignes, les conflits s'accumulent et le travail en équipe devient impossible. Un handler modulaire apporte des avantages clairs :
- Séparation des responsabilités : chaque commande fait une seule chose et vit dans son propre fichier.
- Découverte automatique : dépose un nouveau fichier de commande et le handler le trouve tout seul.
- Testabilité : les commandes se comportent comme des fonctions quasi pures, ce qui facilite les tests isolés.
- Adapté au travail d'équipe : deux personnes peuvent travailler sur des commandes différentes en même temps, sans conflit.
Structure du projet
Une arborescence propre, c'est déjà la moitié du handler. Répartir les commandes dans des sous-dossiers par catégorie garde tout en ordre et facilite la montée en charge :
src/
├─ index.js # point d'entrée, démarre le client
├─ handlers/
│ ├─ commands.js # charge les commandes
│ └─ events.js # charge les events
├─ commands/
│ ├─ utility/
│ │ └─ ping.js
│ └─ moderation/
│ └─ ban.js
└─ events/
├─ ready.js
└─ interactionCreate.js
L'anatomie d'une commande
Chaque fichier de commande suit le même contrat : il exporte un data (la définition de la commande Slash) et une fonction execute. Cette cohérence permet au handler de traiter toutes les commandes de manière uniforme.
// commands/utility/ping.js
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Mesure la latence du bot'),
async execute(interaction) {
await interaction.reply(`Pong! ${interaction.client.ws.ping}ms`);
},
};
Charger les commandes automatiquement
Le handler parcourt le dossier commands/ et ses sous-dossiers, fait un require de chaque fichier et stocke les valides dans une Collection. Les modules natifs fs et path de Node.js suffisent largement.
// handlers/commands.js
const { Collection } = require('discord.js');
const fs = require('node:fs');
const path = require('node:path');
module.exports = (client) => {
client.commands = new Collection();
const root = path.join(__dirname, '..', 'commands');
for (const folder of fs.readdirSync(root)) {
const dir = path.join(root, folder);
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
for (const file of files) {
const command = require(path.join(dir, file));
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.warn(`[ALERTE] ${file} n'a pas de "data" ou "execute".`);
}
}
}
};
Le détail important, c'est ce garde-fou if ('data' in command && 'execute' in command) : un fichier mal formé produit un avertissement au lieu de faire planter tout le bot.
Charger les events avec la même logique
Le motif construit pour les commandes s'applique tel quel aux events. Chaque fichier d'event exporte un name, un drapeau optionnel once et une fonction execute. Le handler les branche avec client.on ou client.once.
// events/ready.js
const { Events } = require('discord.js');
module.exports = {
name: Events.ClientReady,
once: true,
execute(client) {
console.log(`Connecté en tant que ${client.user.tag}`);
},
};
// handlers/events.js
const fs = require('node:fs');
const path = require('node:path');
module.exports = (client) => {
const dir = path.join(__dirname, '..', 'events');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
for (const file of files) {
const event = require(path.join(dir, file));
if (event.once) client.once(event.name, (...a) => event.execute(...a));
else client.on(event.name, (...a) => event.execute(...a));
}
};
Le pont qui exécute les commandes : interactionCreate
Les commandes Slash sont en réalité des interactions. Un unique event interactionCreate reçoit l'interaction entrante, retrouve la commande correspondante dans client.commands et appelle son execute. Capturer les erreurs de façon centralisée ici est essentiel.
// events/interactionCreate.js
const { Events } = require('discord.js');
module.exports = {
name: Events.InteractionCreate,
async execute(interaction) {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
const msg = { content: "Une erreur s'est produite lors de l'exécution.", ephemeral: true };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(msg);
} else {
await interaction.reply(msg);
}
}
},
};
Enregistrer les commandes Slash auprès de Discord
Une distinction importante : charger les fichiers de commande rend le bot conscient de leur existence, mais pour qu'elles apparaissent dans l'interface Discord, les commandes doivent être enregistrées (déployées) séparément auprès de l'API Discord. On le fait généralement avec un script dédié deploy-commands.js. En développement, enregistrer sur un seul serveur (guild) est instantané ; l'enregistrement global peut mettre jusqu'à une heure à se propager.
const { REST, Routes } = require('discord.js');
// parcourir le dossier commands et collecter chaque command.data.toJSON() dans un tableau
const rest = new REST().setToken(process.env.TOKEN);
await rest.put(
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
{ body: commands },
);
Tout assembler dans index.js
// index.js
require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
require('./handlers/commands')(client);
require('./handlers/events')(client);
client.login(process.env.TOKEN);
Comme tu le vois, le fichier d'entrée est désormais mince et stable. Besoin d'une nouvelle commande ? Ajoute un fichier sous commands/. Un nouvel event ? Ajoute un fichier sous events/. C'est précisément la beauté de cette architecture : le cœur reste fixe tandis que les fonctionnalités grandissent sans limite.
Questions fréquentes
Faut-il utiliser des commandes par message ou des commandes Slash ?
Pour les nouveaux bots, privilégie les commandes Slash (commandes d'application) : elles sont découvrables, offrent des paramètres validés et n'exigent pas l'intent privilégié de contenu de message. Le même motif de handler peut gérer les deux, mais se concentrer sur les commandes Slash est le choix le plus sain pour un nouveau projet.
Peut-on recharger une commande à chaud pendant que le bot tourne ?
Oui. En vidant le cache require de Node.js et en re-requérant le fichier, tu peux écrire une commande « reload ». C'est très pratique en développement, mais pour des changements structurels, un redémarrage complet du bot reste la voie la plus sûre.
Pourquoi les fichiers de commande doivent-ils suivre un contrat commun ?
Que chaque fichier exporte la même forme data et execute permet au handler de traiter toutes les commandes de manière uniforme. Cette cohérence est le contrat de base qui rend possibles le chargement automatique, la validation et la gestion des erreurs.
Ton bot commence à partir dans tous les sens en grandissant ? Avec un command handler modulaire, une architecture d'events propre et un flux de déploiement solide, nous pouvons poser ton bot sur des bases professionnelles. Contacte-moi et rendons ton projet évolutif ensemble.