Use Cases (Aktualisiert: 21.7.2026)

discord.js Discord Bot: Slash Commands sicher mit Claude Code bauen

Baue einen sicheren Discord Bot mit discord.js, Slash Commands, drei Workflows und lokal geprüftem Starter.

discord.js Discord Bot: Slash Commands sicher mit Claude Code bauen

Eine Supportfrage landet im allgemeinen Chat, zwei Moderatoren antworten gleichzeitig und beim Schichtwechsel fehlt der Kontext. Genau an dieser Stelle scheitern viele erste Bots: Sie können Text senden, lösen aber keinen verlässlichen Arbeitsablauf aus.

Dieser Guide richtet sich an Einsteiger, die einen kleinen Support-Bot wirklich betreiben möchten. Du baust drei Slash Commands, begrenzt Rechte und Mentions und trennst Code von Geheimnissen. Der Starter ist bewusst überschaubar; Datenbank, KI-Antworten und CRM-Anbindung kommen erst hinzu, wenn der Grundablauf stabil ist.

Das nimmst du mit:

  • /support sammelt Problem, Dringlichkeit und Kontext strukturiert ein.
  • /faq beantwortet häufige Fragen privat, ohne einen Kanal zu überladen.
  • /handoff erstellt nur für Moderatoren eine Übergabenotiz.
  • Langsame Arbeit antwortet zuerst mit deferReply() und später mit editReply().
  • Claude Code schreibt und prüft den Entwurf; Menschen bestimmen Rechte, Inhalte und Freigabe.

discord.js Discord Bot mit Slash Commands: das Betriebsmodell

Ein Slash Command ist ein Befehl wie /support, den Discord direkt in seiner Oberfläche anzeigt. Eine Interaction ist das Ereignis, das der Bot beim Absenden erhält. Für den Anfang reicht der Gateway-Betrieb: Der Prozess hält eine Verbindung zu Discord und verarbeitet Events.InteractionCreate.

Die aktuelle Dokumentation unterscheidet Guild Commands für einen einzelnen Testserver und Global Commands für die Veröffentlichung. Details stehen bei Discord Application Commands. Antworttypen, Follow-ups und ephemere, also nur für den Absender sichtbare Antworten, erklärt Receiving and Responding. Die verwendete API ist in der discord.js-Dokumentation 14.27.0 nachzulesen.

In diesem Artikel ist discord.js auf 14.27.0 festgesetzt. Das Paket deklariert Node.js >=18; für die folgenden Befehle und die lokale Prüfung wird Node.js 24 LTS angenommen. So bleibt die Mindestanforderung der Bibliothek von der empfohlenen Laufzeit für dieses Beispiel getrennt.

EntscheidungStartwertWer entscheidet?
LaufzeitNode.js 24 LTSBetreiber
Bot-RechteView Channels, Send MessagesServer-Administrator
ModerationsrechtManage Messages für /handoffCommunity-Verantwortliche
Registrierungzuerst Guild CommandEntwickler und Betreiber
Aufbewahrungkeine Datenbank im StarterDatenschutzverantwortliche

Was Claude Code übernimmt und was beim Menschen bleibt

Claude Code kann Dateien anlegen, Abhängigkeiten fixieren, Fehlerpfade ergänzen, Tests ausführen und eine Änderung als Diff zeigen. Ein guter Auftrag lautet: „Erstelle mit Node 24, discord.js 14.27.0 und dotenv 17.2.3 einen Support-Bot mit drei Slash Commands. Verwende minimale Intents, ephemere Antworten, deaktivierte Mentions und keine echte Secret-Datei im Commit.“

Nicht delegiert werden die Wahl des Supportkanals, die zulässigen Moderatorrollen, der Text der FAQ, die Aufbewahrungsdauer und die Freigabe für den Produktivserver. Claude Code kennt weder deine internen Regeln noch die Folgen eines falsch gesetzten Rechts. Vor jedem Deploy prüft deshalb ein Mensch den Diff und die Einladung des Bots.

Projekt mit festen Versionen einrichten

Lege das Projekt mit Node.js 24 LTS an. Die Versionen sind absichtlich festgesetzt, damit ein späteres Installieren nicht unbemerkt eine andere Haupt- oder Nebenversion einführt.

mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install [email protected] [email protected]
mkdir src

Ersetze package.json durch diese minimale Fassung. Die Engine zeigt die Paketuntergrenze; getestet wird das Beispiel mit 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"
  }
}

Echte Werte gehören nur in .env. Committe stattdessen eine .env.example mit Platzhaltern und nimm .env in .gitignore auf.

# .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/

Vollständiger Starter für /support, /faq und /handoff

Speichere den folgenden Code als src/bot.js. Der Client fordert nur den Guilds-Intent an. Nutzereingaben können weder @everyone noch Rollen- oder Nutzernennungen auslösen. /handoff wird bereits bei der Command-Definition und zusätzlich zur Laufzeit auf Manage Messages begrenzt.

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 und /handoff greifen auf einen Kanal zu und können später zusätzlich eine API aufrufen. Deshalb bestätigen sie die Interaction sofort mit deferReply() und ersetzen die private Wartemeldung nach der Arbeit mit editReply(). Ohne dieses Muster kann Discord eine langsame Ausführung als fehlgeschlagen anzeigen. /faq antwortet dagegen direkt, weil nur ein lokales Objekt gelesen wird.

Drei nummerierte Use Cases

1. Supportanfrage vollständig aufnehmen

Eingabe: Eine Nutzerin führt /support summary:Login schlägt fehl severity:high context:Fehler 401 nach dem Passwortwechsel aus. Bot-Ausgabe: Der Supportkanal erhält Absender, Dringlichkeit, Zusammenfassung und Kontext; die Nutzerin sieht nur die ephemere Bestätigung. Menschliche Entscheidung: Das Team prüft Identität und Logs und entscheidet über Priorität und Eskalation. Der Bot darf weder Konten entsperren noch Sicherheitsentscheidungen treffen.

2. Wiederkehrende Einsteigerfrage routen

Eingabe: /faq topic:permissions. Bot-Ausgabe: Eine kurze private Antwort nennt die minimalen Rechte. Menschliche Entscheidung: Die Community-Verantwortlichen pflegen Wortlaut und Zielseiten. Bei rechtlichen, zahlungsbezogenen oder kontospezifischen Fragen verweist der Bot an einen Menschen, statt eine scheinbar sichere Antwort zu erfinden.

3. Fall an die nächste Moderation übergeben

Eingabe: Ein Moderator nutzt /handoff target:@name note:Zahlung geprüft; Rückerstattung noch nicht zugesagt. Bot-Ausgabe: Der interne Kanal erhält eine bereinigte Notiz ohne aktive Mentions. Normale Mitglieder können den Command nicht ausführen. Menschliche Entscheidung: Die nächste Person prüft den Vorgang und entscheidet, welche Zusage gemacht werden darf.

Vorher, nachher und eine einfache ROI-Schätzung

Vorher landen freie Nachrichten in mehreren Kanälen; das Team fragt oft erst nach Dringlichkeit und Kontext. Nachher erzwingt /support drei Felder und legt die Information an einem Ort ab. Das ist kein Nachweis für eine tatsächliche Zeitersparnis, sondern eine prüfbare Arbeitshypothese.

Beispielrechnung mit offengelegten Annahmen: Bei 20 Anfragen pro Woche und angenommenen zwei eingesparten Minuten für die erste Datensammlung entstehen 20 × 2 = 40 Minuten pro Woche. Bei internen Kosten von 40 Euro pro Stunde entspricht das rund 0,67 × 40 = 26,80 Euro pro Woche. Miss vier Wochen lang Anfragezahl und Bearbeitungszeit. Liegt die Ersparnis unter Wartungs- und Hostingaufwand, bleibt das Formular oder ein vorhandenes Ticketsystem die bessere Lösung.

Pitfalls und Sicherheitskontrollen

Token im Repository: Sobald ein Bot-Token in Git, einem Screenshot oder CI-Log auftaucht, wird er im Developer Portal rotiert. Ein späteres Löschen aus der aktuellen Datei reicht nicht, weil Git-Historie und Logs Kopien enthalten können.

Zu viele Rechte: Administrator ist kein bequemer Startwert. Dieser Bot benötigt für den Grundablauf nur Kanalansicht und Nachrichtenversand; /handoff prüft Manage Messages. Jede zusätzliche Berechtigung braucht einen konkreten Use Case.

Ungültige oder störende Mentions: allowedMentions: { parse: [] } verhindert die Auflösung von Mentions beim Senden. neutralizeMentions() macht den gespeicherten Text zusätzlich lesbar. Entferne keine der beiden Schutzschichten, wenn Nutzereingaben weitergeleitet werden.

Langsame Interaction ohne Bestätigung: Externe APIs, Datenbanken oder KI-Aufrufe gehören hinter deferReply(). Danach folgt genau ein editReply(); Fehlerpfade müssen ebenfalls antworten.

Global Commands zu früh: Registriere zunächst für DISCORD_GUILD_ID. Schalte erst nach Rechteprüfung, Rollback-Test und Textfreigabe auf Global Commands um. DEPLOY_COMMANDS bleibt im normalen Produktionsstart deaktiviert.

Vor der Freigabe prüfst du außerdem .gitignore, Secret-Speicher des Hosters, Log-Redaktion, Token-Rotation, Neustart und Rollback. Eine ausführlichere Grundlage liefert der Artikel zu Umgebungsvariablen; für Änderungen eignet sich die Code-Review-Checkliste.

Für wiederverwendbare Checklisten und Vorlagen ist der nächste Schritt die Produktübersicht von ClaudeCodeLab.

Was tatsächlich getestet wurde

Geprüft wurde nur lokal, ohne Bot-Token und ohne Verbindung zu einem echten Discord-Server. Der JavaScript-Starter wurde mit node --check auf Syntax geprüft. Zusätzlich wurden die festen Paketversionen, die drei Command-Namen, der deferReply()-zu-editReply()-Pfad und die Bereinigung von @everyone, @here, Nutzer- und Rollenmentions kontrolliert. Nicht geprüft wurden Discord-Berechtigungen auf einem realen Server, Command-Propagation, Hosting, Latenz oder eine messbare Zeitersparnis. Diese Punkte gehören in einen getrennten Testserver-Abnahmetest.

#Claude Code #Discord Bot #discord.js #Chatbot #Community
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.