Discord Bot con discord.js: slash commands seguros con Claude Code
Crea un Discord Bot con discord.js, slash commands, tres casos prácticos y un starter verificado en local.
Una solicitud de ayuda aparece en el canal general, dos moderadores responden a la vez y el siguiente turno no sabe qué se prometió. Muchos primeros bots fallan justo ahí: envían mensajes, pero no crean un flujo de soporte fiable.
Esta guía está pensada para principiantes que quieren operar un bot pequeño, no limitarse a una demo. Vas a crear tres slash commands, restringir permisos y mentions, y separar el código de los secretos. La primera versión no necesita base de datos, respuestas con IA ni conexión a un CRM. Esas piezas solo tienen sentido cuando el recorrido básico ya es estable.
Qué vas a conseguir:
/supportrecoge problema, urgencia y contexto con un formato fijo./faqresponde en privado a preguntas repetidas./handoffdeja una nota de relevo solo para moderadores.- El trabajo lento usa
deferReply()antes de terminar coneditReply(). - Claude Code prepara y revisa el código; una persona decide permisos, contenido y publicación.
Discord Bot con discord.js y slash commands: el modelo básico
Un slash command es un comando como /support que Discord muestra de forma nativa. Una interaction es el evento que recibe el bot cuando alguien lo envía. Para empezar basta el modo Gateway: el proceso mantiene una conexión con Discord y escucha Events.InteractionCreate.
La documentación distingue los Guild Commands, limitados a un servidor de prueba, de los Global Commands usados al publicar. Las reglas de nombres y registro están en Discord Application Commands. Las respuestas iniciales, los follow-ups y las respuestas ephemeral, visibles solo para quien ejecuta el comando, se explican en Receiving and Responding. La API empleada se puede consultar en la documentación de discord.js 14.27.0.
El artículo fija discord.js en 14.27.0. El paquete declara Node.js >=18, pero los comandos y las comprobaciones de este ejemplo se plantean para Node.js 24 LTS. Así no se confunde el mínimo aceptado por la biblioteca con el entorno recomendado para reproducir la guía.
| Decisión | Valor inicial | Quién decide |
|---|---|---|
| Entorno | Node.js 24 LTS | Operador |
| Permisos del bot | View Channels, Send Messages | Administrador del servidor |
| Permiso de moderación | Manage Messages para /handoff | Responsable de comunidad |
| Registro | primero Guild Command | Desarrollo y operaciones |
| Conservación | sin base de datos en el starter | Responsable de datos |
Qué puede hacer Claude Code y qué decide una persona
Claude Code puede crear archivos, fijar dependencias, completar rutas de error, ejecutar comprobaciones y mostrar el diff. Un encargo preciso sería: «Crea con Node 24, discord.js 14.27.0 y dotenv 17.2.3 un bot de soporte con tres slash commands. Usa intents mínimos, respuestas ephemeral, mentions desactivadas y no incluyas secretos reales en el commit».
La elección del canal de soporte, los roles autorizados, el contenido de las FAQ, el tiempo de conservación y la aprobación para producción siguen siendo decisiones humanas. Claude Code no conoce las reglas internas ni el impacto de conceder un permiso excesivo. Antes de desplegar, una persona debe revisar el diff y la invitación del bot.
Preparar el proyecto con versiones fijas
Crea el proyecto con Node.js 24 LTS. Las versiones quedan fijadas para que una instalación futura no cambie una dependencia sin que nadie lo haya decidido.
mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install [email protected] [email protected]
mkdir src
Sustituye package.json por esta versión mínima. engines refleja el mínimo del paquete; el ejemplo se comprueba con 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"
}
}
Los valores reales pertenecen a .env. Solo se debe versionar un .env.example con marcadores y añadir .env a .gitignore.
# .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 completo para /support, /faq y /handoff
Guarda el código siguiente en src/bot.js. El cliente solo solicita el intent Guilds. El texto enviado por usuarios no puede activar @everyone, mentions de rol ni mentions de usuario. /handoff exige Manage Messages tanto en la definición del comando como durante la ejecución.
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 y /handoff consultan un canal y podrían llamar a una API en el futuro. Por eso acusan recibo de inmediato con deferReply() y sustituyen el mensaje de espera privado mediante editReply(). Sin ese patrón, Discord puede mostrar como fallida una ejecución lenta. /faq responde directamente porque solo lee un objeto en memoria.
Tres Use cases numerados
1. Recoger una solicitud de soporte completa
Entrada: una usuaria ejecuta /support summary:No puedo iniciar sesión severity:high context:Error 401 tras cambiar la contraseña. Salida del bot: el canal interno recibe autor, urgencia, resumen y contexto; la usuaria solo ve la confirmación ephemeral. Decisión humana: el equipo revisa la identidad y los logs, y decide prioridad y escalación. El bot no debe desbloquear cuentas ni tomar decisiones de seguridad.
2. Orientar una pregunta frecuente de principiante
Entrada: /faq topic:permissions. Salida del bot: una respuesta privada recuerda los permisos mínimos. Decisión humana: los responsables mantienen el texto y los enlaces. Las preguntas legales, de pagos o específicas de una cuenta se derivan a una persona en lugar de recibir una respuesta automática dudosa.
3. Entregar un caso al siguiente moderador
Entrada: un moderador usa /handoff target:@nombre note:Pago comprobado; reembolso aún no prometido. Salida del bot: el canal interno recibe una nota neutralizada, sin mentions activas. Un miembro normal no puede ejecutar el comando. Decisión humana: la siguiente persona revisa el expediente y decide qué compromiso puede comunicar.
Antes, después y cálculo sencillo de ROI
Antes, los mensajes libres quedan repartidos entre canales y el equipo pregunta después por urgencia y contexto. Después, /support exige tres campos y concentra la información. Esto es una hipótesis de trabajo que se debe medir, no una prueba de ahorro ya conseguido.
Ejemplo con supuestos explícitos: con 20 solicitudes semanales y un ahorro supuesto de dos minutos en la recogida inicial, el ahorro teórico es 20 × 2 = 40 minutos por semana. Con un coste interno de 40 euros por hora, equivale a unos 0,67 × 40 = 26,80 euros semanales. Mide durante cuatro semanas el volumen y el tiempo de atención. Si el beneficio es menor que el mantenimiento y el alojamiento, conviene conservar un formulario o el sistema de tickets existente.
Pitfalls y controles de seguridad
Token dentro del repositorio: si aparece en Git, una captura o un log de CI, rótalo en el Developer Portal. Borrarlo del archivo actual no elimina necesariamente las copias del historial y de los registros.
Permisos excesivos: Administrator no es un punto de partida razonable. El flujo básico solo necesita ver el canal y enviar mensajes; /handoff comprueba Manage Messages. Cada permiso adicional debe tener un motivo documentado.
Mentions no deseadas: allowedMentions: { parse: [] } impide que Discord las resuelva al enviar. neutralizeMentions() también vuelve inocuo y legible el texto almacenado. Mantén las dos defensas al reenviar datos de usuarios.
Interaction lenta sin acuse: una API, una base de datos o una llamada de IA debe ir después de deferReply() y terminar con editReply(). La ruta de error también tiene que responder.
Global Commands demasiado pronto: empieza con DISCORD_GUILD_ID. Publica globalmente tras revisar permisos, probar el rollback y aprobar los textos. DEPLOY_COMMANDS debe permanecer desactivado durante el arranque normal de producción.
Antes de publicar, revisa también .gitignore, el almacén de secretos del proveedor, la eliminación de datos sensibles en logs, la rotación del token, el reinicio y el rollback. Puedes ampliar esta parte con la guía de variables de entorno y el checklist de code review.
Para reutilizar checklists y plantillas, el siguiente paso es el catálogo de productos de ClaudeCodeLab.
Resultado de la prueba realizada
Las comprobaciones se hicieron solo en local, sin token y sin conectar con un servidor real de Discord. El starter JavaScript pasó una revisión de sintaxis con node --check. También se comprobaron las versiones fijas, los tres nombres de comando, el recorrido de deferReply() a editReply() y la neutralización de @everyone, @here, usuarios y roles. No se probaron permisos en un servidor real, propagación de comandos, alojamiento, latencia ni ahorro medible. Esos puntos requieren una aceptación separada en un servidor de pruebas.
Artículos relacionados
Crear un bot de Slack con Claude Code: triaje, incidentes y reportes diarios
Guía práctica con Bolt JS, Socket Mode, comandos slash, seguridad, pruebas y checklist de producción.
Elegir una biblioteca de gráficos con Claude Code
Usa Claude Code para elegir Recharts, Chart.js o D3 y crear dashboards robustos con datos reales.
Claude Code para Vue 3: guía práctica con TypeScript y Pinia
Flujo práctico para usar Claude Code con Vue 3, TypeScript, formularios, Pinia, composables y Vitest.
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.