Cómo crear un bot de Discord con TypeScript y discord.js v14
Guía paso a paso para crear un bot de Discord con TypeScript y discord.js v14: token, invitación, comandos slash, intents, errores y despliegue con Docker.
Si quieres crear un bot de Discord, en esta guía tienes el camino completo: crear la aplicación, guardar el token, montar el proyecto con TypeScript y discord.js v14, programar dos comandos slash, registrarlos y desplegar el bot con Docker. Es el mismo orden que sigo cuando empiezo un bot desde cero, y el código está pensado para que lo copies y funcione.
Llevo desarrollando CactusFire desde 2019, así que al final también te cuento qué cambia cuando el bot deja de estar en tres servidores y empieza a crecer.
Qué necesitas antes de empezar
- Una cuenta de Discord y un servidor de pruebas donde tengas permisos de administración.
- Node.js en una versión LTS. discord.js v14 pide Node 18 o superior; en los ejemplos uso Node 24.
- Un editor de código y la terminal.
- Nociones básicas de TypeScript. No hace falta nada avanzado.
Paso 1: crear la aplicación en el Developer Portal
Entra en el Discord Developer Portal y pulsa New Application. Ponle un nombre; lo podrás cambiar después.
Dentro de la aplicación verás varias secciones. Las que te interesan ahora:
- General Information: aquí está el Application ID. Lo vas a necesitar para registrar comandos.
- Bot: aquí generas el token y activas los intents privilegiados si los necesitas.
- Installation u OAuth2: aquí construyes el enlace de invitación.
Cómo guardar el token del bot
En la sección Bot, pulsa Reset Token y copia el valor. Ese token es la contraseña de tu bot: quien lo tenga puede controlarlo.
Guárdalo en un archivo .env en la raíz del proyecto:
DISCORD_TOKEN=tu_token_aqui
DISCORD_CLIENT_ID=id_de_la_aplicacion
DISCORD_GUILD_ID=id_de_tu_servidor_de_pruebas
Y añade .env al .gitignore antes del primer commit:
node_modules
dist
.env
Si alguna vez subes el token a un repositorio, aunque sea privado, regéneralo en el portal. No intentes arreglarlo borrando el commit: considéralo comprometido.
Paso 2: invitar el bot a tu servidor
Para que el bot entre en un servidor necesitas un enlace OAuth2 con dos scopes:
bot: añade el usuario del bot al servidor.applications.commands: permite que la aplicación cree comandos slash en ese servidor.
El enlace tiene esta forma:
https://discord.com/oauth2/authorize?client_id=TU_CLIENT_ID&scope=bot+applications.commands&permissions=0
El parámetro permissions es un número que representa los permisos que pide el bot. Puedes generarlo desde el propio portal marcando casillas. Pide solo lo que vayas a usar: un bot que pide Administrador para responder a /ping genera desconfianza, con razón.
Paso 3: preparar el proyecto con TypeScript y discord.js v14
Crea la carpeta e instala las dependencias:
mkdir mi-bot && cd mi-bot
npm init -y
npm install discord.js
npm install -D typescript @types/node tsx
npx tsc --init
En package.json activa los módulos ES y añade los scripts:
{
"type": "module",
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsc",
"start": "node --env-file=.env dist/index.js",
"deploy:commands": "node --env-file=.env dist/deploy-commands.js"
}
}
Node ya lee archivos .env con --env-file, así que no necesitas dotenv.
Un tsconfig.json mínimo que funciona con ESM:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
Con NodeNext, los imports relativos llevan la extensión .js aunque el archivo sea .ts. Es lo que más confunde al principio.
Estructura de carpetas para los comandos
src/
commands/
index.ts
ping.ts
avatar.ts
types.ts
deploy-commands.ts
index.ts
Un archivo por comando y un índice que los exporta todos. Así, para añadir un comando nuevo solo creas el archivo y lo añades a la lista.
Paso 4: tu primer comando slash
Primero, el tipo que comparten todos los comandos (src/types.ts):
import type {
ChatInputCommandInteraction,
SlashCommandOptionsOnlyBuilder,
} from 'discord.js';
export interface Command {
data: SlashCommandOptionsOnlyBuilder;
execute: (interaction: ChatInputCommandInteraction) => Promise<void>;
}
El clásico /ping (src/commands/ping.ts), que además enseña la latencia:
import { SlashCommandBuilder } from 'discord.js';
import type { Command } from '../types.js';
export const ping: Command = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Comprueba si el bot responde'),
async execute(interaction) {
await interaction.reply(
`Pong. Latencia del gateway: ${interaction.client.ws.ping} ms`,
);
},
};
Un comando útil con opciones: /avatar
src/commands/avatar.ts devuelve el avatar de un usuario, o el tuyo si no eliges a nadie:
import { SlashCommandBuilder } from 'discord.js';
import type { Command } from '../types.js';
export const avatar: Command = {
data: new SlashCommandBuilder()
.setName('avatar')
.setDescription('Muestra el avatar de un usuario')
.addUserOption((option) =>
option
.setName('usuario')
.setDescription('Usuario del que quieres ver el avatar')
.setRequired(false),
),
async execute(interaction) {
const user = interaction.options.getUser('usuario') ?? interaction.user;
const url = user.displayAvatarURL({ size: 512 });
await interaction.reply({ content: `Avatar de ${user.username}: ${url}` });
},
};
Y el índice (src/commands/index.ts):
import { ping } from './ping.js';
import { avatar } from './avatar.js';
import type { Command } from '../types.js';
export const commands: Command[] = [ping, avatar];
Paso 5: registrar los comandos (servidor vs global)
Discord no descubre tus comandos solo. Tienes que registrarlos con una llamada a la API REST. Lo hago en un script aparte (src/deploy-commands.ts) que ejecuto cuando cambio la definición de un comando, no cada vez que arranca el bot:
import { REST, Routes } from 'discord.js';
import { commands } from './commands/index.js';
const token = process.env.DISCORD_TOKEN!;
const clientId = process.env.DISCORD_CLIENT_ID!;
const guildId = process.env.DISCORD_GUILD_ID;
const body = commands.map((command) => command.data.toJSON());
const rest = new REST().setToken(token);
const route = guildId
? Routes.applicationGuildCommands(clientId, guildId)
: Routes.applicationCommands(clientId);
await rest.put(route, { body });
console.log(`Registrados ${body.length} comandos (${guildId ? 'servidor' : 'global'})`);
| Comandos de servidor | Comandos globales | |
|---|---|---|
| Dónde aparecen | Solo en el servidor indicado | En todos los servidores del bot |
| Ruta | applicationGuildCommands |
applicationCommands |
| Cuándo usarlos | Desarrollo y pruebas | Producción |
rest.put sustituye la lista completa de comandos, así que si quitas un comando del array y vuelves a ejecutar el script, desaparece. Si registras los mismos comandos en tu servidor de pruebas y también de forma global, los verás duplicados en ese servidor: borra los de servidor cuando pases a global.
Paso 6: el archivo principal y los gateway intents
src/index.ts crea el cliente, escucha las interacciones y ejecuta el comando que toca:
import { Client, Collection, Events, GatewayIntentBits, MessageFlags } from 'discord.js';
import { commands } from './commands/index.js';
import type { Command } from './types.js';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const commandMap = new Collection<string, Command>();
for (const command of commands) commandMap.set(command.data.name, command);
client.once(Events.ClientReady, (readyClient) => {
console.log(`Conectado como ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = commandMap.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error en /${interaction.commandName}:`, error);
const content = 'Algo ha fallado al ejecutar el comando.';
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content, flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content, flags: MessageFlags.Ephemeral });
}
}
});
client.login(process.env.DISCORD_TOKEN);
Qué intents activar
Los intents le dicen a Discord qué eventos quieres recibir. Cada intent que añades es más tráfico y más memoria, así que empieza con el mínimo. Para un bot que solo usa comandos slash, Guilds basta.
Hay tres intents privilegiados: GuildMembers, GuildPresences y MessageContent. Para usarlos tienes que activarlos en la sección Bot del portal además de pedirlos en el código. Cuando el bot crece, Discord exige verificarlo (a partir de 100 servidores) y justificar el uso de estos intents. Si puedes resolver algo con comandos slash en vez de leer mensajes, hazlo así.
Paso 7: manejar errores sin que el bot se caiga
El try/catch del ejemplo cubre los fallos dentro de un comando. Añade dos cosas más:
client.on(Events.Error, (error) => console.error('Error del cliente:', error));
process.on('unhandledRejection', (reason) => {
console.error('Promesa rechazada sin capturar:', reason);
});
Y recuerda una regla de Discord: una interacción tiene que recibir respuesta en unos 3 segundos. Si tu comando consulta una base de datos o una API lenta, llama primero a interaction.deferReply() y después responde con interaction.editReply().
Paso 8: desplegar el bot con Docker
Un Dockerfile en dos etapas: una compila TypeScript y la otra solo lleva lo necesario para ejecutar.
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
Construye y arranca el contenedor pasando las variables desde fuera, sin copiar el .env en la imagen:
docker build -t mi-bot .
docker run -d --name mi-bot --restart unless-stopped --env-file .env mi-bot
Añade también un .dockerignore con node_modules, dist y .env.
Cómo mantener el bot encendido
--restart unless-stopped hace que Docker reinicie el contenedor si el proceso muere y cuando la máquina arranca. Con eso y los logs (docker logs -f mi-bot) tienes lo básico. El siguiente paso razonable es automatizar el build con un pipeline de CI, por ejemplo GitHub Actions, para que cada cambio en la rama principal genere una imagen nueva.
Qué hacer cuando el bot crece
Hasta aquí tienes un bot que funciona en un proceso. Eso aguanta bastante, pero hay un límite claro: cuando el bot llega a 2.500 servidores, Discord obliga a usar sharding, es decir, a repartir los servidores entre varias conexiones al gateway.
En CactusFire eso quedó atrás hace tiempo: tiene más de 10 millones de usuarios, está en más de 295.000 servidores y funciona con 304 shards. A esa escala lo importante deja de ser el código del comando y pasa a ser la memoria, la caché, el estado compartido y cómo despliegas sin cortar el servicio. Lo explico en cómo escalar un bot de Discord con sharding.
Mi consejo para empezar: diseña desde el primer día como si el bot fuera a correr en varios procesos. Nada de guardar datos importantes en variables en memoria; usa una base de datos aunque sea pequeña. Te ahorrará una migración incómoda.
Si estás montando un bot para tu comunidad o tu producto y quieres una segunda opinión técnica, puedes escribirme.
Preguntas frecuentes
¿Necesito saber programar para crear un bot de Discord?
Para seguir esta guía necesitas nociones básicas de JavaScript o TypeScript y saber usar la terminal. Si no programas, hay bots ya hechos que puedes añadir a tu servidor sin escribir código.
¿Qué diferencia hay entre registrar comandos en un servidor y de forma global?
Los comandos registrados en un servidor concreto están disponibles al momento y sirven para desarrollar. Los globales funcionan en todos los servidores donde está el bot y son los que usas en producción.
¿Por qué mi bot no lee el contenido de los mensajes?
Porque MessageContent es un intent privilegiado. Tienes que activarlo en el Developer Portal y pedirlo en el cliente. Si solo usas comandos slash, normalmente no lo necesitas.
¿Dónde puedo alojar un bot de Discord?
En cualquier máquina que pueda ejecutar Node.js de forma continua: un VPS, un servidor propio o un servicio de contenedores. Con Docker y una política de reinicio automático el bot se mantiene encendido aunque falle.