Discord.js + TypeScript — Cap.1: Tu Primer Bot
Video próximamente
Fecha esperada:
¿Por Qué TypeScript?
Discord.js está escrito en TypeScript y trae definiciones de tipos de primera clase. Esto significa que tu editor conoce la forma exacta de cada evento, cada respuesta del API y cada método de los builders. Cuando escribes client.on(", el autocompletado te muestra los 200+ eventos. Cuando escribes mal uno, el compilador lo detecta antes de que ejecutes el código.
Esto importa porque los bugs en bots de Discord tienden a ser silenciosos. Un nombre de evento mal escrito no lanza un error — el handler simplemente nunca se ejecuta. Con TypeScript, eso es un error en tiempo de compilación en lugar de una sesión de debugging a las 2 AM.
Setup del Proyecto
Crea un nuevo directorio e inicializa el proyecto:
mkdir discord-bot && cd discord-bot
npm init -y
npm install discord.js
npm install -D typescript tsx @types/nodeTres dependencias, cada una con una razón:
- discord.js — la librería en sí. Maneja conexiones WebSocket, caché, rate limiting y toda la superficie del API de Discord.
- typescript — el compilador. Lo necesitamos para la verificación de tipos, aunque usaremos
tsxpara ejecutar el código. - tsx — un ejecutor de TypeScript sin configuración. Te permite ejecutar archivos
.tsdirectamente sin un paso de build separado. Durante desarrollo,tsx watch src/index.tste da hot reload.
Configuración de TypeScript
Crea tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src"]
}Dos configuraciones merecen explicación:
module: "Node16" — Le dice a TypeScript que use la resolución de módulos nativa de Node, que requiere extensiones de archivo en los imports (./commands/ping.js). Se siente inusual, pero coincide con cómo Node realmente resuelve módulos en runtime. Usar "NodeNext" también funciona — es un alias para el comportamiento más reciente de módulos de Node.
strict: true — Habilita todas las verificaciones estrictas de tipos de una vez. La más impactante para bots de Discord es strictNullChecks: te obliga a manejar los casos donde un miembro del servidor podría ser null, un canal podría no existir, o una interacción podría no tener ciertas propiedades. Estos son exactamente los edge cases que hacen crashear bots en producción.
Estructura de Carpetas
discord-bot/
├── src/
│ ├── index.ts # Punto de entrada — crea client, carga eventos
│ ├── commands/
│ │ └── ping.ts # Cada comando = un archivo
│ └── events/
│ ├── ready.ts # Se ejecuta una vez al conectar
│ └── interactionCreate.ts # Enruta slash commands
├── tsconfig.json
└── package.jsonEsto no es arbitrario. Cada comando vive en su propio archivo porque los comandos son la unidad de cambio en un bot de Discord — agregas, modificas y eliminas comandos de forma independiente. Ponerlos en archivos separados significa que puedes trabajar en un comando sin tocar ningún otro código.
Cómo Tu Bot Se Conecta a Discord
Antes de escribir código, necesitas un modelo mental de lo que pasa cuando tu bot arranca. La mayoría de tutoriales se saltan esto, lo que genera confusión sobre por qué existen cosas como los Intents y el evento ready.
sequenceDiagram
participant Bot as Tu Bot
participant GW as Gateway de Discord
participant API as Discord REST API
Bot->>GW: Conexión WebSocket
GW-->>Bot: HELLO (intervalo de heartbeat)
Bot->>GW: IDENTIFY (token + intents)
GW-->>Bot: READY (usuario bot, servidores)
loop Cada ~41.25s
GW-->>Bot: Solicitud HEARTBEAT
Bot->>GW: HEARTBEAT ACK
end
GW-->>Bot: Eventos (mensajes, interacciones, ...)
Note over Bot,API: Los slash commands usan REST API por separado
Bot->>API: Registrar slash commands
API-->>Bot: Definiciones de comandos almacenadas
Tu bot mantiene una conexión WebSocket persistente con el Gateway de Discord. Esto no es HTTP request-response — es un canal bidireccional de larga duración. Discord empuja eventos a tu bot en tiempo real a través de esta conexión. El bot nunca hace polling por actualizaciones.
El paso de IDENTIFY es donde los Intents importan. Le estás diciendo a Discord: “Este soy yo, y estos son los datos que necesito.”
El Client y los Intents
Crea src/index.ts:
import { Client, GatewayIntentBits, Collection } from "discord.js";
const client = new Client({
intents: [
// Guilds es el intent mínimo — habilita slash commands e info básica del servidor
GatewayIntentBits.Guilds,
],
});
// Lee el token de las variables de entorno — nunca lo escribas directo en el código
client.login(process.env.DISCORD_TOKEN);El Client es la conexión de tu bot a Discord. El array de intents es la decisión de configuración más importante que vas a tomar.
¿Qué Son los Intents?
Los Intents son filtros en la conexión del Gateway. Controlan qué eventos Discord envía a tu bot. Si no solicitas el intent GuildMessages, tu bot nunca recibirá eventos de mensajes — Discord simplemente no los envía.
Este sistema existe por dos razones:
Privacidad. Antes de abril de 2022, cada bot recibía cada evento de cada servidor en el que estaba. Un bot que solo necesitaba responder a slash commands igual recibía el contenido completo de cada mensaje en cada canal. Los Intents permiten a Discord restringir el flujo de datos a lo que cada bot realmente necesita.
Rendimiento. Discord sirve millones de bots. Enviar cada evento a cada bot desperdicia ancho de banda en ambos lados. Con intents, un bot que solo usa slash commands (que llegan a través del intent Guilds) genera una fracción del tráfico del Gateway comparado con un bot que lee todos los mensajes.
Intents Comunes y Qué Habilitan
| Intent | Eventos que recibes | Cuándo lo necesitas |
|---|---|---|
Guilds | Creación/actualización/eliminación de servidores, cambios en canales y roles | Casi siempre — requerido para funcionalidad básica del bot |
GuildMessages | Creación/actualización/eliminación de mensajes en servidores | Solo si lees contenido de mensajes |
GuildMembers ⚠️ | Ingreso/salida/actualización de miembros | Tracking de usuarios, mensajes de bienvenida |
MessageContent ⚠️ | Contenido real de texto de los mensajes | Prefix commands, análisis de contenido |
DirectMessages | Eventos de mensajes directos | Si tu bot maneja DMs |
Los intents con ⚠️ son privilegiados. Debes habilitarlos manualmente en el Discord Developer Portal en la configuración de Bot de tu aplicación. Discord revisa los bots que solicitan intents privilegiados cuando alcanzan 100+ servidores — este es un punto de fricción intencional para proteger la privacidad de los usuarios.
Para este capítulo, solo necesitamos Guilds. Los slash commands no requieren contenido de mensajes ni datos de miembros.
¿Qué son los Gateway Intents en Discord.js?
El Evento Ready
Crea src/events/ready.ts:
import { Client, Events } from "discord.js";
export default {
// Usa el enum Events en vez de strings para seguridad en compilación
name: Events.ClientReady,
// once: true significa que este handler se ejecuta solo en la primera conexión, no en reconexiones
once: true,
// Client<true> garantiza que client.user no es null (el bot está logueado)
execute(client: Client<true>) {
console.log(`Logged in as ${client.user.tag}`);
},
};Tres cosas a notar:
Events.ClientReady en lugar del string "ready". El enum Events te da autocompletado y validación en tiempo de compilación. Si Discord.js alguna vez renombra un evento, TypeScript marcará cada uso.
once: true significa que este handler se ejecuta exactamente una vez. El evento ready se dispara cuando el bot se conecta por primera vez y ha recibido los datos iniciales de sus servidores. No quieres ejecutar lógica de setup cada vez que el bot se reconecta después de una interrupción de red.
Client<true> es un genérico de TypeScript. Client<true> significa “un client que definitivamente está logueado” — así que client.user está garantizado como no-null. Sin el genérico, necesitarías client.user! o una verificación de null en todas partes.
Creando un Slash Command
Crea src/commands/ping.ts:
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
} from "discord.js";
export default {
// SlashCommandBuilder crea la definición JSON que Discord necesita para mostrar el comando
data: new SlashCommandBuilder()
.setName("ping")
.setDescription("Verifica la latencia del bot"),
async execute(interaction: ChatInputCommandInteraction) {
// fetchReply: true retorna el mensaje enviado para medir el tiempo de ida y vuelta
const sent = await interaction.reply({
content: "Verificando...",
fetchReply: true,
});
// Diferencia entre cuándo Discord recibió la interacción y cuándo llegó nuestra respuesta
const latency = sent.createdTimestamp - interaction.createdTimestamp;
// ws.ping es la latencia del heartbeat al Gateway, medida por discord.js
await interaction.editReply(
`Pong! Latencia: ${latency}ms | API: ${interaction.client.ws.ping}ms`
);
},
};SlashCommandBuilder construye la definición del comando que Discord necesita. Cuando configuras .setName("ping"), Discord lo almacena y lo muestra a los usuarios con autocompletado, descripciones y validación de parámetros — todo manejado del lado del servidor antes de que tu bot siquiera vea la interacción.
La función execute recibe un ChatInputCommandInteraction. Este tipo es específico para slash commands (a diferencia de clicks en botones o envíos de modales, que son tipos de interacción diferentes). TypeScript asegura que no intentes accidentalmente acceder a propiedades que no existen en este tipo de interacción.
¿Por Qué Slash Commands Sobre Prefix Commands?
Antes de 2021, los bots usaban prefix commands: !ping, !ban @usuario, etc. Discord deprecó este patrón a favor de los slash commands por varias razones:
- Descubribilidad — Los usuarios pueden escribir
/y ver cada comando disponible con descripciones. No más adivinar prefijos ni leer textos de ayuda. - Validación — Discord valida los parámetros del comando antes de que tu bot los reciba. Un parámetro
userrequerido obligará al usuario a seleccionar un usuario válido, eliminando toda una categoría de bugs de parsing. - Sin intent MessageContent — Los prefix commands requieren leer cada mensaje para verificar el prefijo. Los slash commands llegan a través del evento
interactionCreate, que está disponible con solo el intentGuilds. Esto significa menos datos fluyendo por el Gateway y sin necesidad de un intent privilegiado.
¿Por qué Discord deprecó los prefix commands a favor de los slash commands?
Conectando Todo
Ahora actualiza src/index.ts para cargar comandos y eventos dinámicamente:
import { Client, GatewayIntentBits, Collection, REST, Routes } from "discord.js";
import { readdirSync } from "node:fs";
import { join } from "node:path";
// Augmentación de módulo — agrega una propiedad "commands" al tipo Client
declare module "discord.js" {
interface Client {
commands: Collection<string, any>;
}
}
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.commands = new Collection();
// Carga comandos dinámicamente — el sistema de archivos es el registro
const commandsPath = join(import.meta.dirname, "commands");
const commandFiles = readdirSync(commandsPath).filter(
(file) => file.endsWith(".ts") || file.endsWith(".js")
);
for (const file of commandFiles) {
// Import dinámico — cada archivo de comando exporta un objeto default con data + execute
const command = (await import(join(commandsPath, file))).default;
client.commands.set(command.data.name, command);
}
// Cargar eventos
const eventsPath = join(import.meta.dirname, "events");
const eventFiles = readdirSync(eventsPath).filter(
(file) => file.endsWith(".ts") || file.endsWith(".js")
);
for (const file of eventFiles) {
const event = (await import(join(eventsPath, file))).default;
// Los eventos "once" se disparan solo la primera vez; "on" se disparan siempre
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}
// Registra slash commands via REST API (separado de la conexión del Gateway)
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
const commands = client.commands.map((cmd) => cmd.data.toJSON());
// Los guild commands se actualizan al instante; los globales tardan hasta 1 hora
await rest.put(
Routes.applicationGuildCommands(
process.env.CLIENT_ID!,
process.env.GUILD_ID!
),
{ body: commands }
);
console.log(`Registrados ${commands.length} slash commands`);
client.login(process.env.DISCORD_TOKEN);Dos decisiones importantes aquí:
Carga dinámica con readdirSync + import(). Esto significa que agregar un nuevo comando es simplemente crear un nuevo archivo en commands/. Nunca editas index.ts para agregar un comando — el sistema de archivos es el registro. Este patrón escala desde 1 comando hasta 100.
Guild commands para desarrollo. Los slash commands se pueden registrar globalmente (todos los servidores, tarda hasta 1 hora en propagarse) o por guild (un servidor, instantáneo). Durante desarrollo, siempre usa guild commands. Cambia a global cuando despliegues a producción.
El Router de Interacciones
Crea src/events/interactionCreate.ts:
import { Events, Interaction } from "discord.js";
export default {
name: Events.InteractionCreate,
once: false,
async execute(interaction: Interaction) {
// Type guard — estrecha Interaction a ChatInputCommandInteraction
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(
interaction.commandName
);
if (!command) {
console.error(`Comando ${interaction.commandName} no encontrado`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error ejecutando ${interaction.commandName}:`, error);
// ephemeral: true hace que el error solo sea visible para quien ejecutó el comando
const reply = {
content: "Algo salió mal ejecutando este comando.",
ephemeral: true,
};
// No se puede responder dos veces — usa followUp si ya se envió reply o deferReply
if (interaction.replied || interaction.deferred) {
await interaction.followUp(reply);
} else {
await interaction.reply(reply);
}
}
},
};La verificación interaction.isChatInputCommand() es un type guard. Después de esta verificación, TypeScript estrecha el tipo del amplio Interaction a ChatInputCommandInteraction, dándote acceso a propiedades como commandName que solo existen en interacciones de slash commands. Sin esta verificación, obtendrías un error de TypeScript al intentar acceder a interaction.commandName.
El handler de errores verifica interaction.replied || interaction.deferred porque solo puedes responder a una interacción una vez. Si el comando ya envió una respuesta antes de crashear, usamos followUp para enviar el mensaje de error en su lugar.
graph TD
A["Gateway de Discord"] -->|"evento interactionCreate"| B["interactionCreate.ts"]
B -->|"¿isChatInputCommand?"| C{"Type Guard"}
C -->|"Sí"| D["Buscar en client.commands"]
C -->|"No (botón, modal, etc.)"| E["Return — no manejado aún"]
D -->|"Encontrado"| F["command.execute(interaction)"]
D -->|"No encontrado"| G["Log del error"]
F -->|"Éxito"| H["Usuario ve la respuesta"]
F -->|"Error"| I["Catch → responder con mensaje de error"]
El Deadline de 3 Segundos
Hay una restricción crítica de runtime que necesitas internalizar: Discord le da a tu bot exactamente 3 segundos para responder a una interacción. Si tu bot no llama a interaction.reply() o interaction.deferReply() dentro de 3 segundos, el token de la interacción expira y el usuario ve “Esta interacción falló.”
Nuestro comando /ping es seguro porque responde inmediatamente. Pero cualquier comando que consulte una base de datos, llame a un API externo o haga computación necesita considerar este deadline:
async execute(interaction: ChatInputCommandInteraction) {
// Comprar tiempo — muestra "El bot está pensando..." al usuario
await interaction.deferReply();
// Ahora tienes 15 minutos para llamar a editReply()
const data = await someSlowOperation();
await interaction.editReply(`Resultado: ${data}`);
}deferReply() le dice a Discord “recibí esto, estoy trabajando en ello.” Muestra un indicador de carga al usuario y extiende tu ventana de respuesta de 3 segundos a 15 minutos. El costo es UX — el usuario ve “pensando” en lugar de una respuesta instantánea. Úsalo solo cuando sea necesario.
¿Qué pasa si tu bot tarda más de 3 segundos en responder a una interacción?
Ejecutando Tu Bot
Antes de ejecutar el bot necesitas tres valores del Discord Developer Portal:
- Token del Bot — En Bot → Token. Esta es la contraseña de tu bot. Nunca lo subas al control de versiones.
- Client ID — En General Information → Application ID.
- Guild ID — Click derecho en tu servidor de prueba en Discord (con el Modo Desarrollador habilitado en configuración) → Copiar ID del Servidor.
Crea un archivo .env (agrégalo a .gitignore inmediatamente):
DISCORD_TOKEN=tu-token-de-bot-aqui
CLIENT_ID=tu-application-id
GUILD_ID=tu-id-de-servidor-de-pruebaInstala dotenv y agrega el import al inicio de src/index.ts:
npm install dotenvimport "dotenv/config";Agrega un script de inicio a package.json:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts"
}
}Invita al bot a tu servidor de prueba usando este patrón de URL (reemplaza TU_CLIENT_ID):
https://discord.com/oauth2/authorize?client_id=TU_CLIENT_ID&permissions=0&integration_type=0&scope=bot+applications.commandsEjecuta el bot:
npm run devDeberías ver Logged in as TuBot#1234 y Registrados 1 slash commands. Escribe /ping en tu servidor — el bot debería responder con su latencia.
Lo Que Sigue
En el próximo capítulo agregaremos estructura: un sistema de eventos modular con interfaces tipadas, rich embeds con EmbedBuilder, y patrones de error handling que mantienen tu bot vivo en producción. La base que construimos aquí — TypeScript, Intents, slash commands y el router de interacciones — es el esqueleto que todo bot de producción necesita.
Pon a Prueba tus Conocimientos
Intenta este crucigrama con los conceptos clave de este capítulo:
Conclusiones
- TypeScript atrapa bugs silenciosos de bots de Discord en tiempo de compilación — eventos mal escritos, propiedades faltantes y accesos null que de otra forma fallarían silenciosamente en runtime
- Los Gateway Intents son filtros de privacidad y rendimiento, no permisos — controlan qué eventos Discord envía a tu bot, y los intents privilegiados requieren aprobación manual en el Developer Portal
- Tu bot mantiene una conexión WebSocket persistente con el Gateway de Discord — es push-based, no request-response
- Los slash commands reemplazaron a los prefix commands como estándar — proporcionan descubribilidad, validación integrada y no requieren el intent privilegiado MessageContent
- Discord impone un deadline duro de 3 segundos para respuestas de interacciones — usa
deferReply()para extender a 15 minutos para operaciones lentas - La carga dinámica de comandos desde el sistema de archivos significa que agregar un comando nunca requiere editar
index.ts— un archivo por comando, el directorio es el registro