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.
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:
/supportsammelt Problem, Dringlichkeit und Kontext strukturiert ein./faqbeantwortet häufige Fragen privat, ohne einen Kanal zu überladen./handofferstellt nur für Moderatoren eine Übergabenotiz.- Langsame Arbeit antwortet zuerst mit
deferReply()und später miteditReply(). - 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.
| Entscheidung | Startwert | Wer entscheidet? |
|---|---|---|
| Laufzeit | Node.js 24 LTS | Betreiber |
| Bot-Rechte | View Channels, Send Messages | Server-Administrator |
| Moderationsrecht | Manage Messages für /handoff | Community-Verantwortliche |
| Registrierung | zuerst Guild Command | Entwickler und Betreiber |
| Aufbewahrung | keine Datenbank im Starter | Datenschutzverantwortliche |
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.
Ähnliche Artikel
Slack Bot mit Claude Code bauen: Triage, Incident-Ersthilfe und Tagesberichte
Praxisguide mit Bolt JS, Socket Mode, Slash Commands, Sicherheit, Tests und Produktionscheckliste.
Chart-Bibliotheken mit Claude Code auswählen
Mit Claude Code Recharts, Chart.js oder D3 wählen und robuste Dashboards für echte Daten bauen.
Claude Code für Vue 3: Praxisguide mit TypeScript und Pinia
So nutzt du Claude Code für Vue 3, TypeScript, Formulare, Pinia, Composables und Vitest im echten Projekt.
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.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.