Bot Discord discord.js : créer des slash commands sûres avec Claude Code
Créez un Bot Discord discord.js avec slash commands, trois cas concrets et un starter vérifié en local.
Une demande d’aide arrive dans le salon général, deux modérateurs répondent en même temps, puis le contexte disparaît au changement d’équipe. Beaucoup de premiers bots échouent ici : ils savent envoyer un message, mais ne créent pas un circuit de support fiable.
Ce guide s’adresse aux débutants qui veulent exploiter un petit bot de support, pas seulement réussir une démo. Vous allez créer trois slash commands, limiter les permissions et les mentions, puis séparer le code des secrets. La base reste volontairement simple : base de données, réponses par IA et connexion au CRM viendront seulement si le flux principal fonctionne.
Ce que vous allez mettre en place :
/supportcollecte le problème, son urgence et le contexte./faqdonne une réponse privée à une question fréquente./handoffprépare une note de relais réservée aux modérateurs.- Une tâche lente utilise
deferReply()puiseditReply(). - Claude Code produit et contrôle le code ; l’équipe décide des droits et de la mise en ligne.
Bot Discord discord.js et slash commands : le modèle à comprendre
Une slash command est une commande telle que /support, affichée nativement dans l’interface de Discord. Une interaction est l’événement reçu par le bot au moment de l’envoi. Pour débuter, le mode Gateway suffit : le processus garde une connexion avec Discord et écoute Events.InteractionCreate.
La documentation distingue les Guild Commands, limités à un serveur de test, et les Global Commands destinés à la publication. Les règles de nommage et d’enregistrement sont décrites dans Discord Application Commands. Les réponses initiales, les follow-ups et les réponses éphémères, visibles uniquement par leur destinataire, sont détaillés dans Receiving and Responding. L’API utilisée se trouve dans la documentation discord.js 14.27.0.
L’article fixe discord.js à 14.27.0. Le paquet déclare Node.js >=18, tandis que les commandes et les vérifications ci-dessous supposent Node.js 24 LTS. On distingue ainsi le minimum accepté par la bibliothèque de l’environnement recommandé pour reproduire cet exemple.
| Décision | Valeur de départ | Responsable |
|---|---|---|
| Environnement | Node.js 24 LTS | Exploitant |
| Droits du bot | View Channels, Send Messages | Administrateur du serveur |
| Droit de modération | Manage Messages pour /handoff | Responsable de communauté |
| Enregistrement | Guild Command en premier | Développeur et exploitant |
| Conservation | aucune base dans le starter | Responsable des données |
Ce que Claude Code peut faire, et ce que l’humain doit décider
Claude Code peut créer les fichiers, fixer les versions, compléter les erreurs, lancer les contrôles et présenter un diff. Un prompt précis serait : « Crée avec Node 24, discord.js 14.27.0 et dotenv 17.2.3 un bot de support doté de trois slash commands. Utilise un minimum d’intents, des réponses éphémères, des mentions désactivées et ne commite aucun secret réel. »
Le choix du salon de support, des rôles autorisés, du contenu des FAQ, de la durée de conservation et du passage en production reste humain. Claude Code ne connaît ni vos règles internes ni l’impact d’un droit trop large. Une personne relit donc le diff et l’invitation du bot avant chaque déploiement.
Installer le projet avec des versions fixes
Créez le projet avec Node.js 24 LTS. Les versions sont fixées pour qu’une nouvelle installation ne change pas silencieusement de dépendance.
mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install [email protected] [email protected]
mkdir src
Remplacez package.json par cette version minimale. Le champ engines reflète le minimum du paquet ; notre exemple est contrôlé avec Node 24 LTS.
{
"name": "discord-support-bot",
"private": true,
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node src/bot.js",
"check": "node --check src/bot.js"
},
"dependencies": {
"discord.js": "14.27.0",
"dotenv": "17.2.3"
}
}
Les vraies valeurs restent dans .env. Commitez uniquement un fichier .env.example contenant des valeurs fictives, et ignorez .env.
# .env.example
DISCORD_TOKEN=replace_with_bot_token
DISCORD_CLIENT_ID=replace_with_application_id
DISCORD_GUILD_ID=replace_with_test_guild_id
SUPPORT_CHANNEL_ID=replace_with_support_channel_id
DEPLOY_COMMANDS=true
.env
node_modules/
Starter complet pour /support, /faq et /handoff
Enregistrez le code suivant dans src/bot.js. Le client ne demande que l’intent Guilds. Les textes transmis par les utilisateurs ne peuvent pas déclencher @everyone, une mention de rôle ou une mention d’utilisateur. /handoff exige Manage Messages dans la définition de la commande et lors de son exécution.
import "dotenv/config";
import {
Client,
Events,
GatewayIntentBits,
MessageFlags,
PermissionFlagsBits,
REST,
Routes,
SlashCommandBuilder,
} from "discord.js";
const env = {
token: process.env.DISCORD_TOKEN,
clientId: process.env.DISCORD_CLIENT_ID,
guildId: process.env.DISCORD_GUILD_ID,
supportChannelId: process.env.SUPPORT_CHANNEL_ID,
};
for (const key of ["token", "clientId", "supportChannelId"]) {
if (!env[key]) throw new Error(`${key} is required`);
}
const commands = [
new SlashCommandBuilder()
.setName("support")
.setDescription("Send a structured support request")
.addStringOption((option) =>
option.setName("summary").setDescription("What happened?")
.setMaxLength(900).setRequired(true),
)
.addStringOption((option) =>
option.setName("severity").setDescription("How urgent is it?")
.setRequired(true).addChoices(
{ name: "low", value: "low" },
{ name: "normal", value: "normal" },
{ name: "high", value: "high" },
),
)
.addStringOption((option) =>
option.setName("context").setDescription("Steps, link, or error")
.setMaxLength(1500),
),
new SlashCommandBuilder()
.setName("faq")
.setDescription("Show a short answer for a common topic")
.addStringOption((option) =>
option.setName("topic").setDescription("FAQ topic").setRequired(true)
.addChoices(
{ name: "setup", value: "setup" },
{ name: "permissions", value: "permissions" },
{ name: "rollout", value: "rollout" },
),
),
new SlashCommandBuilder()
.setName("handoff")
.setDescription("Create a moderator handoff note")
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addUserOption((option) =>
option.setName("target").setDescription("User to hand off")
.setRequired(true),
)
.addStringOption((option) =>
option.setName("note").setDescription("What should the next moderator know?")
.setMaxLength(1500).setRequired(true),
),
].map((command) => command.toJSON());
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, (readyClient) => {
console.log(`Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
try {
if (!interaction.inGuild()) {
await interaction.reply({
content: "Use this command inside the server.",
flags: MessageFlags.Ephemeral,
});
return;
}
if (interaction.commandName === "support") await handleSupport(interaction);
else if (interaction.commandName === "faq") await handleFaq(interaction);
else if (interaction.commandName === "handoff") await handleHandoff(interaction);
else await interaction.reply({
content: "Unknown command.", flags: MessageFlags.Ephemeral,
});
} catch (error) {
console.error("Interaction failed:", error);
await sendError(interaction);
}
});
async function handleSupport(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const summary = interaction.options.getString("summary", true);
const severity = interaction.options.getString("severity", true);
const context = interaction.options.getString("context") ?? "No extra context.";
const channel = await fetchSupportChannel();
await channel.send({
content: [
"**New support request**",
`Reporter: ${interaction.user.tag} (${interaction.user.id})`,
`Severity: ${severity}`,
`Summary: ${neutralizeMentions(summary)}`,
`Context: ${neutralizeMentions(context)}`,
].join("\n"),
allowedMentions: { parse: [] },
});
await interaction.editReply("Request sent to the support team.");
}
async function handleFaq(interaction) {
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Use Node 24 LTS and copy .env.example to .env.",
permissions: "Start with View Channels and Send Messages.",
rollout: "Test guild commands before registering global commands.",
};
await interaction.reply({
content: answers[topic], flags: MessageFlags.Ephemeral,
});
}
async function handleHandoff(interaction) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({
content: "Manage Messages permission is required.",
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const target = interaction.options.getUser("target", true);
const note = interaction.options.getString("note", true);
const channel = await fetchSupportChannel();
await channel.send({
content: [
"**Moderator handoff**",
`Target: ${target.tag} (${target.id})`,
`From: ${interaction.user.tag} (${interaction.user.id})`,
`Note: ${neutralizeMentions(note)}`,
].join("\n"),
allowedMentions: { parse: [] },
});
await interaction.editReply("Handoff note created.");
}
async function fetchSupportChannel() {
const channel = await client.channels.fetch(env.supportChannelId);
if (!channel?.isTextBased() || typeof channel.send !== "function") {
throw new Error("SUPPORT_CHANNEL_ID must be a writable text channel");
}
return channel;
}
function neutralizeMentions(value) {
return value
.replaceAll("@everyone", "@ everyone")
.replaceAll("@here", "@ here")
.replace(/<@!?(\d+)>/g, "user:$1")
.replace(/<@&(\d+)>/g, "role:$1");
}
async function sendError(interaction) {
const content = "The command failed. Contact a moderator.";
if (interaction.deferred) await interaction.editReply(content);
else if (interaction.replied) await interaction.followUp({
content, flags: MessageFlags.Ephemeral,
});
else await interaction.reply({ content, flags: MessageFlags.Ephemeral });
}
async function deployCommands() {
const rest = new REST({ version: "10" }).setToken(env.token);
const route = env.guildId
? Routes.applicationGuildCommands(env.clientId, env.guildId)
: Routes.applicationCommands(env.clientId);
await rest.put(route, { body: commands });
console.log(env.guildId ? "Guild commands deployed." : "Global commands deployed.");
}
if (process.env.DEPLOY_COMMANDS === "true") await deployCommands();
await client.login(env.token);
/support et /handoff contactent un salon et pourront plus tard appeler une API. Ils accusent donc réception immédiatement avec deferReply(), puis remplacent le message d’attente privé avec editReply(). Sans ce mécanisme, Discord peut considérer une exécution lente comme échouée. /faq répond directement, car il ne lit qu’un objet en mémoire.
Trois cas d’usage numérotés
1. Recevoir une demande de support complète
Entrée : une utilisatrice lance /support summary:Connexion impossible severity:high context:Erreur 401 après changement du mot de passe. Sortie du bot : le salon interne reçoit l’auteur, l’urgence, le résumé et le contexte ; l’utilisatrice ne voit que la confirmation éphémère. Décision humaine : l’équipe contrôle l’identité et les journaux, puis choisit la priorité et l’escalade. Le bot ne déverrouille pas un compte et ne prend pas de décision de sécurité.
2. Orienter une question fréquente de débutant
Entrée : /faq topic:permissions. Sortie du bot : une réponse privée rappelle les droits minimaux. Décision humaine : les responsables maintiennent les textes et les liens. Une question juridique, financière ou propre à un compte est transmise à une personne au lieu de recevoir une réponse automatique incertaine.
3. Transmettre un dossier au modérateur suivant
Entrée : un modérateur saisit /handoff target:@nom note:Paiement vérifié; remboursement non promis. Sortie du bot : le salon interne reçoit une note neutralisée, sans mention active. Un membre ordinaire ne peut pas lancer la commande. Décision humaine : la personne suivante vérifie le dossier et décide quelle réponse peut être engagée.
Avant, après et estimation simple du ROI
Avant, les messages libres sont répartis dans plusieurs salons et l’équipe doit demander l’urgence et le contexte. Après, /support impose trois champs et rassemble les informations. Il s’agit d’une hypothèse de travail à mesurer, pas d’une preuve de gain déjà obtenu.
Exemple avec hypothèses explicites : pour 20 demandes par semaine et deux minutes supposées économisées sur la collecte initiale, le gain théorique est 20 × 2 = 40 minutes par semaine. Avec un coût interne de 40 euros par heure, cela représente environ 0,67 × 40 = 26,80 euros par semaine. Mesurez pendant quatre semaines le volume et le temps de traitement. Si le gain reste inférieur à la maintenance et à l’hébergement, un formulaire ou le système de tickets existant est préférable.
Pitfalls et contrôles de sécurité
Token présent dans le dépôt : dès qu’un token apparaît dans Git, une capture ou un log CI, faites-le tourner dans le Developer Portal. Le supprimer du fichier actuel ne retire pas forcément les copies de l’historique et des journaux.
Permissions excessives : Administrator n’est pas un bon réglage de départ. Le flux de base demande seulement l’accès au salon et l’envoi de messages ; /handoff contrôle Manage Messages. Chaque droit supplémentaire doit correspondre à un besoin documenté.
Mentions indésirables : allowedMentions: { parse: [] } empêche leur résolution lors de l’envoi. neutralizeMentions() rend aussi le texte stocké inoffensif et lisible. Conservez ces deux barrières lorsque vous retransmettez une entrée utilisateur.
Interaction lente sans accusé de réception : une API, une base ou un appel d’IA vient après deferReply(), puis se termine par editReply(). Le chemin d’erreur doit lui aussi répondre.
Global Commands trop tôt : commencez avec DISCORD_GUILD_ID. Passez au global après la revue des droits, le test de retour arrière et la validation des textes. Gardez DEPLOY_COMMANDS désactivé lors d’un démarrage normal en production.
Avant la mise en ligne, vérifiez également .gitignore, le coffre à secrets de l’hébergeur, la suppression des données sensibles dans les logs, la rotation du token, le redémarrage et le rollback. Pour aller plus loin, consultez la gestion des variables d’environnement et la checklist de revue de code.
Pour réutiliser des checklists et des modèles, l’étape suivante est le catalogue de produits ClaudeCodeLab.
Ce qui a réellement été testé
Les contrôles ont été effectués uniquement en local, sans token et sans connexion à un vrai serveur Discord. Le starter JavaScript a été soumis à node --check. Les versions fixes, les trois noms de commande, le chemin deferReply() vers editReply() et la neutralisation de @everyone, @here, des utilisateurs et des rôles ont également été contrôlés. Les permissions sur un serveur réel, la propagation des commandes, l’hébergement, la latence et le gain de temps n’ont pas été testés. Ils devront faire l’objet d’une recette séparée sur un serveur de test.
Articles liés
Créer un bot Slack avec Claude Code : triage, incidents et rapports quotidiens
Guide Bolt JS avec Socket Mode, commandes slash, sécurité, tests et checklist de production.
Choisir une bibliothèque de graphiques avec Claude Code
Utilisez Claude Code pour choisir Recharts, Chart.js ou D3 et créer des dashboards fiables.
Claude Code pour Vue 3 : guide pratique TypeScript et Pinia
Utiliser Claude Code avec Vue 3, TypeScript, Pinia, composables, formulaires et tests Vitest en projet réel.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.