Use Cases (Mis à jour: 21/07/2026)

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.

Bot Discord discord.js : créer des slash commands sûres avec Claude Code

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 :

  • /support collecte le problème, son urgence et le contexte.
  • /faq donne une réponse privée à une question fréquente.
  • /handoff prépare une note de relais réservée aux modérateurs.
  • Une tâche lente utilise deferReply() puis editReply().
  • 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écisionValeur de départResponsable
EnvironnementNode.js 24 LTSExploitant
Droits du botView Channels, Send MessagesAdministrateur du serveur
Droit de modérationManage Messages pour /handoffResponsable de communauté
EnregistrementGuild Command en premierDéveloppeur et exploitant
Conservationaucune base dans le starterResponsable 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.

#Claude Code #Discord Bot #discord.js #chatbot #communauté
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.