# 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](https://amodb.dev/es/pricing/). 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:

```bash
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY
```

## Paso 2: crea el proyecto

```bash
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.js
```

El ajuste `type` activa la sintaxis `import`. Consulta [autenticación](https://amodb.dev/es/authentication/) para ver las formas de la clave en el encabezado y en la consulta.

## Paso 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.

```js
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_to_response=ratings`, 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://img.amodb.dev/t/p/w342` más `poster_path`; consulta [imágenes](https://amodb.dev/es/images-api/) 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.

```js
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ó.                                                                                   |

```js
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.';
}
```

El bucle de reintentos del listado final lee `Retry-After` de los encabezados de la respuesta. Consulta [errores](https://amodb.dev/es/errors/) y [límites de solicitudes](https://amodb.dev/es/rate-limits/) para ver todos los códigos.

## 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`.

```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.

```bash
node --env-file=.env bot.js
```

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://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, 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.

## Próximos pasos

- Añade autocompletado para la opción `title` con el endpoint de [búsqueda](https://amodb.dev/es/search/), que tiene un caché perimetral de 10 minutos.
- Añade un comando `/trending` con [tendencias y listas](https://amodb.dev/es/trending-lists/).
- Añade un botón de tráiler con el endpoint de [vídeos y tráilers](https://amodb.dev/es/videos-trailers/).
- Crea el mismo comando para [Telegram](https://amodb.dev/es/telegram-movie-bot/).

## Preguntas y respuestas

### ¿Por qué el bot difiere la respuesta?

Discord espera una respuesta a un comando slash en 3 segundos. Una búsqueda más una llamada de detalles, o una espera tras un 429, pueden tardar más. deferReply() lo extiende a 15 minutos y después editReply() envía el embed.

### ¿Qué intents necesita el bot?

Solo Guilds. Los comandos slash llegan como interacciones, así que el bot nunca lee el contenido de los mensajes y no necesita ningún intent privilegiado. Añade más intents solo cuando una función que construyas los requiera.

### ¿Cómo guardo el token de Discord y la clave de amodb?

En variables de entorno, por ejemplo un archivo .env cargado con la opción env-file de Node.js, fuera de Git. Si un token se filtra, restablécelo en el portal de desarrolladores. Para amodb, crea una clave nueva en el panel de tu cuenta.

### ¿Puede el comando mostrar más de un resultado?

Sí. Crea un embed por cada una de las tres primeras entradas de results, porque un mensaje admite hasta 10 embeds. Cada llamada de detalles adicional cuesta una solicitud, así que vigila tus solicitudes por minuto.

### ¿El póster del embed necesita una clave de API?

No. La URL de la miniatura apunta a img.amodb.dev, que sirve imágenes sin clave, y Discord la descarga cuando aparece el mensaje. Solo tu bot usa la clave, para las llamadas JSON.

## Páginas relacionadas

- [Tutorial de bot de películas para Telegram](https://amodb.dev/es/telegram-movie-bot/): Crea un bot de Telegram con grammY y Node.js que responde a /movie con póster, año y nota desde amodb, con reintento ante 429 y un pequeño caché en memoria.
- [Tutorial de API de películas en JavaScript](https://amodb.dev/es/javascript-movie-api/): Crea un script de Node.js que carga detalles, créditos y puntuaciones con una solicitud por película, caché TTL, reintento ante 429 y sin dependencias.
- [API de búsqueda](https://amodb.dev/es/search/): API de búsqueda con rutas y JSON de TMDB v3. Encuentra películas, series, personas, colecciones, productoras y palabras clave por texto, con filtros de año.
- [Puntuaciones de películas y series](https://amodb.dev/es/ratings/): API de puntuaciones de películas: IMDb, Kinopoisk, Metacritic, Rotten Tomatoes y TMDB de un título en una sola llamada, con null si falta una fuente.
- [Límites de solicitudes](https://amodb.dev/es/rate-limits/): amodb limita cada clave por solicitudes por minuto en una ventana deslizante de 60 segundos: 20, 40 o 120 según el plan. Lee los encabezados y gestiona el 429.
- [Autenticación](https://amodb.dev/es/authentication/): Cómo enviar tu clave de amodb: api_key en la consulta, un encabezado Bearer o apikey en llamadas OMDb. Formato, almacenamiento, rotación y errores 401.

Actualizado: 2026-10-18. Source: https://amodb.dev/es/discord-movie-bot/
