Tutorial de bot de películas para Discord
Este tutorial crea un bot de Discord con discord.js v14 y un comando slash /movie. El bot busca en amodb, carga la mejor coincidencia con sus puntuaciones y responde con un embed que incluye el póster, las notas, la duración y la sinopsis. Difiere la respuesta, reintenta ante un 429 y guarda respuestas en caché. Lleva unos 25 minutos.
Qué vas a construir
Vas a construir un bot de Discord con un comando slash, /movie title. Responde con un embed que muestra el póster, el año, las puntuaciones de IMDb y Kinopoisk, la duración, los géneros y una sinopsis breve. Necesitas Node.js 20.6 o superior, discord.js v14, una aplicación de Discord con un token de bot y una clave de amodb. Calcula unos 25 minutos.
Paso 1: obtén tus credenciales
Elige un plan en precios. La velocidad es la única diferencia entre planes, y el más bajo permite 20 solicitudes por minuto. Crea una clave en el panel de tu cuenta.
En el portal de desarrolladores de Discord crea una aplicación, añade un bot y copia el token del bot. Abre la página de OAuth2, usa el generador de URL, marca los ámbitos bot y applications.commands y abre el enlace para añadir el bot a un servidor de pruebas. Copia también el id del servidor. Guarda los valores en un archivo .env y mantenlo fuera de Git:
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEYPaso 2: crea el proyecto
El ajuste type activa la sintaxis import. Consulta autenticación para ver las formas de la clave en el encabezado y en la consulta.
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.jsPaso 3: registra el comando slash
SlashCommandBuilder describe el comando y su opción obligatoria title. El bot lo envía a Discord cuando está listo. Con DISCORD_GUILD_ID definida, el comando aparece de inmediato en ese servidor. Si dejas la variable sin definir, se registra en todas partes.
import { Client, Events, GatewayIntentBits, SlashCommandBuilder } from 'discord.js';
const movieCommand = new SlashCommandBuilder()
.setName('movie')
.setDescription('Look up a movie')
.addStringOption((option) =>
option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async (ready) => {
await ready.application.commands.set([movieCommand.toJSON()], process.env.DISCORD_GUILD_ID);
console.log(`Logged in as ${ready.user.tag}`);
});Paso 4: construye el embed
La llamada de detalles añade append_, así que las puntuaciones llegan junto con la película. Cada fuente es un objeto con value y votes, o null cuando la nota no se conoce. El embed imprime value y dice «not known» para null. El póster es https:/ más poster_path; consulta imágenes para ver otros tamaños. La descripción de un embed admite 4096 caracteres y el código conserva 400. Añade EmbedBuilder a la línea de import y define la constante POSTER, como hace el listado final.
function movieEmbed(movie) {
const score = (source) => (source ? String(source.value) : 'not known');
const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
const embed = new EmbedBuilder()
.setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
.setDescription((movie.overview || 'No overview yet.').slice(0, 400))
.addFields(
{ name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
{ name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
{ name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
{ name: 'Genres', value: genres },
);
if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
return embed;
}Paso 5: gestiona errores y límites de solicitudes
Llama primero a deferReply(). Discord quiere una respuesta en 3 segundos, y una búsqueda más una llamada de detalles, o una espera tras un 429, pueden tardar más. Tras diferir, tienes 15 minutos para llamar a editReply().
| Caso | Causa | Reacción del bot |
|---|---|---|
| 401 | Clave ausente, incorrecta o suspendida | Lo registra, avisa al canal y termina. Todos los comandos fallarían. |
| 429 | Superaste tus solicitudes por minuto | Espera Retry-After segundos, con un tope de 10, reintenta dos veces y luego pide al usuario que lo intente más tarde. |
results vacío | Ningún título coincide | Responde que no se encontró ninguna película. |
| Tiempo agotado tras 8 segundos | Red lenta | Responde que el servicio no contestó. |
El bucle de reintentos del listado final lee Retry-After de los encabezados de la respuesta. Consulta errores y límites de solicitudes para ver todos los códigos.
function describeError(error) {
if (error instanceof AmodbError && error.status === 429) {
return 'Too many lookups right now. Try again in a minute.';
}
return 'The movie service did not answer. Try again later.';
}Paso 6: guarda las respuestas en caché
La comprobación del límite de solicitudes se ejecuta antes del caché perimetral, así que solo un caché dentro de tu bot ahorra solicitudes por minuto. Este bot usa una pequeña clase TtlCache: 10 minutos para los resultados de búsqueda y 6 horas para los detalles de películas, las mismas duraciones que el caché perimetral. La clase está en el listado final. Así, dos personas que piden el mismo título en 10 minutos cuestan dos solicitudes en total, no cuatro.
Paso 7: el bot completo
Guárdalo como bot.js.
import {
Client,
EmbedBuilder,
Events,
GatewayIntentBits,
SlashCommandBuilder,
} from 'discord.js';
const { DISCORD_TOKEN, DISCORD_GUILD_ID, AMODB_KEY } = process.env;
if (!DISCORD_TOKEN || !AMODB_KEY) {
console.error('Set DISCORD_TOKEN and AMODB_KEY first.');
process.exit(1);
}
const API = 'https://api.amodb.dev/3';
const POSTER = 'https://img.amodb.dev/t/p/w342';
class AmodbError extends Error {
constructor(status) {
super(`amodb returned ${status}`);
this.status = status;
}
}
class TtlCache {
#items = new Map();
get(key) {
const item = this.#items.get(key);
if (!item) return undefined;
if (item.expires < Date.now()) {
this.#items.delete(key);
return undefined;
}
return item.value;
}
set(key, value, ttlMs) {
this.#items.set(key, { value, expires: Date.now() + ttlMs });
}
}
const cache = new TtlCache();
async function amodb(path, params, ttlMs) {
const url = new URL(API + path);
url.search = new URLSearchParams(params).toString();
const cached = cache.get(url.href);
if (cached) return cached;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${AMODB_KEY}` },
signal: AbortSignal.timeout(8_000),
});
if (res.status === 429 && attempt < 2) {
const wait = Math.min(Number(res.headers.get('retry-after')) || 3, 10);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
if (!res.ok) throw new AmodbError(res.status);
const body = await res.json();
cache.set(url.href, body, ttlMs);
return body;
}
}
function movieEmbed(movie) {
const score = (source) => (source ? String(source.value) : 'not known');
const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
const embed = new EmbedBuilder()
.setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
.setDescription((movie.overview || 'No overview yet.').slice(0, 400))
.addFields(
{ name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
{ name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
{ name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
{ name: 'Genres', value: genres },
);
if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
return embed;
}
function describeError(error) {
if (error instanceof AmodbError && error.status === 429) {
return 'Too many lookups right now. Try again in a minute.';
}
return 'The movie service did not answer. Try again later.';
}
const movieCommand = new SlashCommandBuilder()
.setName('movie')
.setDescription('Look up a movie')
.addStringOption((option) =>
option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async (ready) => {
await ready.application.commands.set([movieCommand.toJSON()], DISCORD_GUILD_ID);
console.log(`Logged in as ${ready.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'movie') return;
await interaction.deferReply();
try {
const title = interaction.options.getString('title', true).trim();
const found = await amodb('/search/movie', { query: title, language: 'en-US' }, 10 * 60_000);
const hit = found.results[0];
if (!hit) {
await interaction.editReply(`No movie found for "${title}".`);
return;
}
const movie = await amodb(
`/movie/${hit.id}`,
{ language: 'en-US', append_to_response: 'ratings' },
6 * 60 * 60_000,
);
await interaction.editReply({ embeds: [movieEmbed(movie)] });
} catch (error) {
console.error('Lookup failed:', error);
if (error instanceof AmodbError && error.status === 401) {
console.error('amodb rejected AMODB_KEY (401). Fix the key and restart.');
await interaction.editReply('The bot key was rejected. The owner has to fix it.').catch(() => {});
await client.destroy();
process.exit(1);
}
await interaction.editReply(describeError(error)).catch(() => {});
}
});
process.on('unhandledRejection', (error) => console.error('Unhandled rejection:', error));
await client.login(DISCORD_TOKEN);Inicia el bot con el archivo .env, escribe /movie en el servidor de pruebas y luego introduce un título.
Con /movie fight club, el primer resultado de la búsqueda es la película 550, y el embed contiene el título Fight Club (1999), la sinopsis recortada a 400 caracteres y la miniatura https:/, además de estos campos:
| Campo | Valor |
|---|---|
| IMDb | 8.8 |
| Kinopoisk | 8.7 |
| Runtime | 139 min |
| Genres | Drama, Thriller |
La primera consulta de un título hace dos llamadas, y las siguientes dentro de la duración del caché no hacen ninguna. Un 401 detiene el proceso tras un mensaje, y un tiempo de espera agotado produce la respuesta genérica.
node --env-file=.env bot.jsPróximos pasos
- Añade autocompletado para la opción
titlecon el endpoint de búsqueda, que tiene un caché perimetral de 10 minutos. - Añade un comando
/trendingcon tendencias y listas. - Añade un botón de tráiler con el endpoint de vídeos y tráilers.
- Crea el mismo comando para Telegram.