Crear un Discord Bot con Claude Code: guía práctica con discord.js
Crea un Discord Bot de soporte con Claude Code: slash commands, permisos, env vars y checklist de despliegue.
Un Discord Bot debe ser una ruta operativa, no solo una demo
Un Discord Bot es una aplicación que entra en un servidor de Discord y responde a acciones de los usuarios. Dicho de forma simple, funciona como una recepción automática dentro de la comunidad. Puede recibir una solicitud de soporte, responder una FAQ breve, avisar a moderadores, preparar una nota de handoff o enviar una incidencia a un canal interno antes de que el problema se pierda.
Eso es más útil que un bot que solo conversa. Una comunidad sana necesita caminos claros para “estoy bloqueado”, “dónde está la guía”, “quién toma este caso” y “esto requiere escalación”. Si todo queda en un canal general, la información se mezcla con la charla diaria. Si pasa por application commands, el flujo queda visible, repetible y fácil de revisar.
Claude Code ayuda cuando lo usas para algo más que generar un fragmento con client.login(). Un bot listo para producción necesita registro de comandos, variables de entorno, límites de permisos, respuestas de interaction, manejo de errores, protección del token y una pequeña guía de despliegue. Si pides “hazme un Discord Bot” sin esos requisitos, es probable que recibas una demo que solo funciona en el caso feliz.
Esta guía implementa un bot de soporte con discord.js. Incluye /support, /faq y /handoff, usa slash commands, no depende del intent de message content, reduce el riesgo de mentions peligrosas y empieza con comandos de servidor de prueba. El ejemplo asume Node.js 22.12.0 o superior, de acuerdo con la documentación actual de discord.js.
flowchart LR
A["User runs /support"] --> B["Discord interaction"]
B --> C["discord.js bot"]
C --> D["Ephemeral user reply"]
C --> E["Support channel message"]
E --> F["Moderator handoff"]
La decisión más importante es mantener la primera versión aburrida y fiable. Base de datos, cola, resumen con LLM, CRM o comprobación de pagos pueden llegar después. Primero hay que asegurar que el comando llega, el usuario recibe respuesta privada y el equipo ve una nota útil.
Application commands e interactions sin jerga
Los Discord application commands son comandos nativos que aparecen en el cliente de Discord. El caso más común es el slash command, como /support. Son mejores para soporte que los comandos antiguos tipo !help, porque Discord muestra nombre, descripción, opciones, choices y comportamiento de permisos antes de que el usuario envíe nada.
Las interactions son los eventos que recibe tu aplicación cuando alguien ejecuta un comando, pulsa un botón, usa un menú o envía un modal. Con discord.js sobre Gateway se manejan normalmente en Events.InteractionCreate. Discord también permite recibir interactions por un endpoint HTTP, pero para un equipo pequeño el Bot con Gateway es más fácil de ejecutar en local y depurar.
La fuente de verdad debe ser oficial. Los tipos de comando, las reglas de nombre, la diferencia entre guild commands y global commands y el registro están en Discord Application Commands. El modelo de primera respuesta, followups y tokens de interaction está en Receiving and Responding to Interactions. Para API de biblioteca y requisitos de Node, usa discord.js documentation y la discord.js guide.
Permisos, variables de entorno y arquitectura mínima
En el Developer Portal crea una Discord application, añade un usuario Bot y genera una URL de invitación con los scopes bot y applications.commands. No empieces con permiso de administrador. Este bot solo necesita ver el canal de soporte y enviar mensajes. El comando /handoff debe quedar limitado a personas con permisos de moderación, por ejemplo Manage Messages.
| Elemento | Valor | Nota de producción |
|---|---|---|
| Node.js | 22.12.0 o superior | Requisito actual de discord.js |
| OAuth2 scopes | bot, applications.commands | Necesarios para bot y slash commands |
| Bot permissions | View Channels, Send Messages | Añade más solo tras revisión |
DISCORD_TOKEN | Bot token | No lo subas, captures ni registres en logs |
DISCORD_CLIENT_ID | Application ID | Se usa al registrar comandos |
DISCORD_GUILD_ID | ID del servidor de prueba | Útil para guild commands |
SUPPORT_CHANNEL_ID | Canal interno de soporte | El bot debe poder escribir allí |
Un prompt útil para Claude Code sería: “Crea un bot de soporte con Node.js 22 y discord.js. Debe tener /support, /faq y /handoff, usar .env, registrar guild commands en desarrollo, aplicar permisos mínimos, responder con ephemeral replies y generar un checklist de despliegue.” Esa precisión evita un scaffold genérico.
Para reforzar la disciplina técnica, conecta este trabajo con gestión de variables de entorno, patrones de manejo de errores y checklist de code review. El bot es pequeño, pero las reglas son las mismas que en un servicio serio.
Starter ejecutable con discord.js
El ejemplo usa JavaScript con ES modules para que puedas pegarlo sin configurar TypeScript. Si DISCORD_GUILD_ID existe, registra comandos en el servidor de prueba. Si lo quitas, registra comandos globales. Mantén DEPLOY_COMMANDS=true durante el setup local; en producción conviene que el registro de comandos sea un paso controlado, no algo que ocurra en cada reinicio.
mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install discord.js dotenv
mkdir src
Añade type y start en package.json.
{
"type": "module",
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "latest",
"dotenv": "latest"
}
}
Crea .env.
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
Crea src/bot.js.
import "dotenv/config";
import {
Client,
Events,
GatewayIntentBits,
MessageFlags,
PermissionFlagsBits,
REST,
Routes,
SlashCommandBuilder,
} from "discord.js";
const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const supportChannelId = process.env.SUPPORT_CHANNEL_ID;
for (const [name, value] of Object.entries({ token, clientId, supportChannelId })) {
if (!value) throw new Error(`${name} is required.`);
}
const commands = [
new SlashCommandBuilder()
.setName("support")
.setDescription("Send a support request to the team")
.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, links, or error messages")
.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: "Please 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 safeReply(interaction, "Unknown command.");
} catch (error) {
console.error("Interaction failed:", error);
await safeReply(interaction, "Something went wrong. Please contact a moderator.");
}
});
async function handleSupport(interaction) {
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}`,
`Channel: <#${interaction.channelId}>`,
`Summary: ${neutralizeMentions(summary)}`,
`Context: ${neutralizeMentions(context)}`,
].join("\n"),
allowedMentions: { parse: [] },
});
await interaction.reply({
content: "Thanks. Your request was sent to the support team.",
flags: MessageFlags.Ephemeral,
});
}
async function handleFaq(interaction) {
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Install Node.js 22.12+, invite the bot with bot and applications.commands scopes, then run npm start.",
permissions: "Start with View Channels and Send Messages. Reserve Manage Messages for moderator-only commands.",
rollout: "Use guild commands for testing. Promote to global commands only after rollback and logging are checked.",
};
await interaction.reply({
content: answers[topic],
flags: MessageFlags.Ephemeral,
});
}
async function handleHandoff(interaction) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({
content: "You need Manage Messages permission to use this command.",
flags: MessageFlags.Ephemeral,
});
return;
}
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.reply({
content: "Handoff note created.",
flags: MessageFlags.Ephemeral,
});
}
async function fetchSupportChannel() {
const channel = await client.channels.fetch(supportChannelId);
if (!channel || !channel.isTextBased() || typeof channel.send !== "function") {
throw new Error("SUPPORT_CHANNEL_ID must be a text channel the bot can send to.");
}
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 safeReply(interaction, content) {
const payload = { content, flags: MessageFlags.Ephemeral };
if (interaction.replied || interaction.deferred) await interaction.followUp(payload);
else await interaction.reply(payload);
}
async function deployCommands() {
const rest = new REST({ version: "10" }).setToken(token);
const route = guildId
? Routes.applicationGuildCommands(clientId, guildId)
: Routes.applicationCommands(clientId);
await rest.put(route, { body: commands });
console.log(guildId ? "Guild commands deployed." : "Global commands deployed.");
}
if (process.env.DEPLOY_COMMANDS === "true") {
await deployCommands();
}
await client.login(token);
En local, ejecuta node --version y confirma 22.12.0 o superior. Después lanza npm start. Prueba primero /support y verifica que el usuario ve una respuesta privada mientras el canal interno recibe el mensaje estructurado. Luego prueba /faq. Por último, ejecuta /handoff con una cuenta moderadora y otra normal para confirmar que la restricción de permisos funciona.
Use case: tres flujos que justifican el bot
El primer Use case es la recepción de soporte. Un buen comando no necesita veinte campos. Con summary, severity y context ya puedes hacer triage, es decir, decidir si se responde, se reproduce, se asigna o se escala. En un servidor de prueba pequeño, este formato redujo las preguntas de seguimiento porque la primera nota ya tenía urgencia, síntoma y pasos aproximados.
El segundo Use case es el enrutamiento de FAQ. No conviene que el bot pegue una guía completa en el chat. Es mejor responder corto y enlazar bien. Una duda de instalación puede llevar a la guía de inicio de Claude Code, una duda de CLI a desarrollo de herramientas CLI y una duda de reglas internas a plantillas CLAUDE.md. Así el bot se convierte en navegación del sitio.
El tercer Use case es el handoff entre moderadores. En comunidades de pago, cohortes de formación, servidores de juegos o soporte de producto, las personas cambian de turno. Un mensaje como “que alguien mire esto” pierde contexto. /handoff deja claro el usuario objetivo, quién escribe y qué debe saber la siguiente persona.
Un cuarto escenario es soporte durante formación. En un taller de Claude Code, muchas personas fallan por versión de Node, variables de entorno o comandos incompletos. Si el bot recoge versión, comando ejecutado y últimas líneas del error, el instructor empieza diagnosticando en lugar de pedir datos básicos. Para llevarlo a un equipo real, la CTA natural es formación y consultoría.
Pitfall: fallos de producción que conviene eliminar pronto
El primer Pitfall es filtrar el token del bot. Si aparece en Git, capturas, logs de CI o documentación, trátalo como comprometido. Rótalo en el Developer Portal y elimina el valor antiguo. Al pedir ayuda a Claude Code, especifica que cree .env.example con placeholders y que nunca imprima secretos reales.
El segundo fallo es registrar global commands durante todo el desarrollo. Los guild commands son rápidos y afectan solo al servidor de prueba. Los global commands son la superficie de producción. Publica globalmente solo después de revisar nombres, descripciones, permisos y rollback. También evita que cada reinicio de producción vuelva a registrar comandos sin control.
El tercer fallo es dejar una rama de interaction sin respuesta. El usuario lo percibe como comando roto. Todas las rutas deben hacer reply, defer o follow up, incluso errores y comandos desconocidos. Si añades una llamada a API externa o un resumen con LLM, usa defer primero y responde después.
El cuarto fallo es el abuso de mentions. Si reenvías texto de usuario tal cual al canal interno, @everyone, @here, mentions de usuario y mentions de rol pueden generar incidentes de notificación. El ejemplo usa allowedMentions: { parse: [] } y limpieza de texto. Mantén ambas defensas.
El quinto fallo es dar permiso de administrador al bot por comodidad. Oculta decisiones de permisos y aumenta el daño si el token se roba. Empieza con View Channels y Send Messages. Añade permisos solo cuando puedas explicarlos en una revisión.
Checklist de seguridad y despliegue
- Hay una persona responsable de rotar el token en Developer Portal
.envno entra en Git y.env.examplesolo contiene placeholders- La URL de invitación empieza con
botyapplications.commands - Los permisos del bot empiezan con View Channels y Send Messages
/handoffqueda limitado a moderadores- Se prueban guild commands antes de promover global commands
- Toda rama de interaction responde, difiere o hace follow up
- El input de usuario queda protegido frente a mentions
- Los logs no contienen token, datos de pago, emails ni soporte privado
- El hosting usa Node.js 22.12.0 o superior
- Hay pasos escritos para reiniciar y hacer rollback
Un bot pequeño puede vivir en Railway, Render, Fly.io, un VPS o un servidor interno. La plataforma importa menos que tres controles: los secretos están separados del código, los logs se pueden leer y el reinicio está documentado. Pide a Claude Code que el README tenga esas secciones explícitas.
Si necesitas plantillas reutilizables para proyectos personales, revisa productos de ClaudeCodeLab. Si tu equipo quiere diseñar soporte en Discord, adopción de Claude Code, permisos, revisión y entrenamiento, empieza por formación y consultoría. Para mantener el bot como una feature real, conéctalo también con automatización de code review.
Resultado al probarlo
Al ejecutar este starter en un servidor pequeño, la mejora principal no fue que el bot fuera inteligente. Fue que la solicitud tenía forma. Los mensajes libres suelen empezar con “no funciona”. /support fuerza summary, severity y context, así que la primera respuesta del moderador se parece más a un diagnóstico que a una petición de datos. Las partes realmente peligrosas fueron token, comandos globales, mentions y permisos. Claude Code resultó más útil cuando le pedí código y, al mismo tiempo, .env.example, tabla de permisos, respuestas de error y checklist de despliegue.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.