# Tutorial de bot de películas para Telegram

> Este tutorial crea un bot de Telegram con grammY en Node.js. Envía /movie fight club y el bot responde con el póster, el año, la duración y la nota desde amodb. Lee ambas credenciales de variables de entorno, reintenta ante un 429 con Retry-After, guarda respuestas en memoria y se detiene si rechazan la clave. Lleva unos 20 minutos.

## Qué vas a construir

Vas a construir un bot de Telegram que responde a `/movie fight club` con el póster, el título, el año, la duración y la nota. Necesitas Node.js 20.6 o superior, un token de bot de BotFather dentro de Telegram y una clave de amodb. El bot usa grammY y el `fetch` integrado, así que basta un paquete de npm. Calcula unos 20 minutos.

## Paso 1: obtén dos credenciales

Elige un plan en [precios](https://amodb.dev/es/pricing/). El plan fija cuántas solicitudes por minuto puede enviar tu bot. Crea una clave en el panel de tu cuenta, de 32 caracteres hexadecimales. Después abre un chat con BotFather en Telegram, envía `/newbot`, responde las dos preguntas y copia el token que imprime.

Pon ambos valores en un archivo `.env` y añade ese archivo a `.gitignore`:

```bash
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEY
```

## Paso 2: crea el proyecto

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

El ajuste `type` activa la sintaxis `import` y el `await` de nivel superior. La página de [autenticación](https://amodb.dev/es/authentication/) muestra las formas de la clave en el encabezado y en `api_key`.

## Paso 3: responde al primer comando

grammY coloca el texto posterior a un comando en `ctx.match`. Empieza con una función auxiliar que envía el encabezado Bearer y un comando que busca.

```js
import { Bot } from 'grammy';

const bot = new Bot(process.env.BOT_TOKEN);

async function getJson(path, params) {
  const url = new URL('https://api.amodb.dev/3' + path);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AMODB_KEY}` } });
  return res.json();
}

bot.command('movie', async (ctx) => {
  const found = await getJson('/search/movie', { query: ctx.match, language: 'en-US' });
  await ctx.reply(found.results[0]?.title ?? 'No movie found.');
});

bot.start();
```

## Paso 4: construye la respuesta con un póster

Toma el primer resultado de la búsqueda, cárgalo con `append_to_response=ratings` y construye un pie de foto. La URL del póster es `https://img.amodb.dev/t/p/w500` más `poster_path`. Telegram descarga la foto por sí mismo, y `img.amodb.dev` no necesita clave. Un pie de foto admite como máximo 1024 caracteres, así que el código recorta la sinopsis.

```js
function buildCaption(movie) {
  const year = (movie.release_date || '').slice(0, 4) || 'year unknown';
  const imdb = movie.ratings?.imdb;
  const rating = imdb
    ? `IMDb ${imdb.value}`
    : movie.vote_average ? `TMDB ${movie.vote_average.toFixed(1)}` : 'not known';
  const lines = [`${movie.title} (${year})`, `Rating: ${rating}`];
  if (movie.runtime) lines.push(`Runtime: ${movie.runtime} min`);
  if (movie.overview) lines.push('', movie.overview.slice(0, 600));
  return lines.join('\n').slice(0, 1024);
}
```

Cada fuente de `ratings` es un objeto con `value` y `votes`, o `null`. Cuando `imdb` es `null`, el pie de foto recurre al `vote_average` agregado. La página de [puntuaciones](https://amodb.dev/es/ratings/) explica las cinco fuentes.

## Paso 5: gestiona errores y límites de solicitudes

Deja que los manejadores lancen errores y captura todo en un solo lugar. Sin `bot.catch`, grammY detiene el bot cuando un manejador lanza un error.

| Caso                          | Causa                                  | Reacción del bot                                                                                                        |
| ----------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| 401                           | Clave ausente, incorrecta o suspendida | Lo registra, avisa al chat y termina. Todos los comandos fallarían.                                                     |
| 429                           | Superaste tus solicitudes por minuto   | Espera `Retry-After` segundos, con un tope de 30, reintenta dos veces y luego pide al usuario que lo intente más tarde. |
| `results` vacío               | Ningún título coincide con la consulta | Responde que no se encontró ninguna película.                                                                           |
| Tiempo agotado o error de red | `fetch` lanza un error                 | Responde con una breve disculpa y registra el error.                                                                    |
| Foto rechazada                | Telegram no puede obtener la imagen    | Envía el pie de foto como texto.                                                                                        |

El bucle de abajo está dentro de `getJson`. `AmodbError` es una pequeña subclase de `Error` con un campo `status`, que aparece en el listado final.

```js
for (let attempt = 0; ; attempt++) {
  const res = await fetch(id, { headers, signal: AbortSignal.timeout(10_000) });
  if (res.status === 429 && attempt < 2) {
    const seconds = Math.min(Number(res.headers.get('retry-after')) || 5, 30);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    continue;
  }
  if (!res.ok) throw new AmodbError(res.status);
  return res.json();
}
```

Cada comando cuesta dos solicitudes, así que el plan de 20 solicitudes por minuto atiende unas 10 búsquedas nuevas por minuto. Consulta [límites de solicitudes](https://amodb.dev/es/rate-limits/) y [errores](https://amodb.dev/es/errors/) para ver los detalles.

## 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 propio bot ahorra solicitudes. Guarda un `Map` de URL a datos y una hora de caducidad. Usa las duraciones del perímetro: 10 minutos para la búsqueda y 6 horas para los detalles. Un tope de tamaño evita que un bot que lleva mucho tiempo en marcha crezca sin límite.

Las líneas del caché también están dentro de `getJson`.

```js
const hit = cache.get(id);
if (hit && hit.expires > Date.now()) return hit.data;
// ...after a successful fetch:
if (cache.size > 500) cache.delete(cache.keys().next().value);
cache.set(id, { data, expires: Date.now() + ttlMs });
```

## Paso 7: el bot completo

Guarda esto como `bot.js`.

```js
import { Bot } from 'grammy';

const { BOT_TOKEN, AMODB_KEY } = process.env;
if (!BOT_TOKEN || !AMODB_KEY) {
  console.error('Set BOT_TOKEN and AMODB_KEY first.');
  process.exit(1);
}

const API = 'https://api.amodb.dev/3';
const IMG = 'https://img.amodb.dev/t/p/w500';
const headers = { Authorization: `Bearer ${AMODB_KEY}` };
const cache = new Map();

class AmodbError extends Error {
  constructor(status) {
    super(`amodb returned ${status}`);
    this.status = status;
  }
}

async function getJson(path, params, ttlMs) {
  const url = new URL(API + path);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
  const id = url.toString();

  const hit = cache.get(id);
  if (hit && hit.expires > Date.now()) return hit.data;

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(id, { headers, signal: AbortSignal.timeout(10_000) });
    if (res.status === 429 && attempt < 2) {
      const seconds = Math.min(Number(res.headers.get('retry-after')) || 5, 30);
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      continue;
    }
    if (!res.ok) throw new AmodbError(res.status);
    const data = await res.json();
    if (cache.size > 500) cache.delete(cache.keys().next().value);
    cache.set(id, { data, expires: Date.now() + ttlMs });
    return data;
  }
}

function buildCaption(movie) {
  const year = (movie.release_date || '').slice(0, 4) || 'year unknown';
  const imdb = movie.ratings?.imdb;
  const rating = imdb
    ? `IMDb ${imdb.value}`
    : movie.vote_average ? `TMDB ${movie.vote_average.toFixed(1)}` : 'not known';
  const lines = [`${movie.title} (${year})`, `Rating: ${rating}`];
  if (movie.runtime) lines.push(`Runtime: ${movie.runtime} min`);
  if (movie.overview) lines.push('', movie.overview.slice(0, 600));
  return lines.join('\n').slice(0, 1024);
}

const bot = new Bot(BOT_TOKEN);

bot.command('start', (ctx) =>
  ctx.reply('Send /movie and a title, for example /movie fight club.'));

bot.command('movie', async (ctx) => {
  const title = ctx.match.trim();
  if (!title) return ctx.reply('Usage: /movie fight club');
  await ctx.replyWithChatAction('typing');

  const found = await getJson('/search/movie', { query: title, language: 'en-US' }, 10 * 60_000);
  const hit = found.results[0];
  if (!hit) return ctx.reply(`No movie found for "${title}".`);

  const movie = await getJson(
    `/movie/${hit.id}`,
    { language: 'en-US', append_to_response: 'ratings' },
    6 * 60 * 60_000,
  );
  const caption = buildCaption(movie);
  if (!movie.poster_path) return ctx.reply(caption);
  try {
    await ctx.replyWithPhoto(IMG + movie.poster_path, { caption });
  } catch {
    await ctx.reply(caption);
  }
});

bot.catch(async ({ ctx, error }) => {
  console.error('Update failed:', error);
  if (error instanceof AmodbError && error.status === 401) {
    console.error('amodb rejected AMODB_KEY (401). Fix the key and restart.');
    await ctx.reply('The bot key was rejected. The owner has to fix it.').catch(() => {});
    process.exit(1);
  }
  const text = error instanceof AmodbError && error.status === 429
    ? 'Too many requests right now. Try again in a minute.'
    : 'The movie service did not answer. Try again later.';
  await ctx.reply(text).catch(() => {});
});

await bot.api.setMyCommands([{ command: 'movie', description: 'Find a movie by title' }]);
process.once('SIGINT', () => bot.stop());
process.once('SIGTERM', () => bot.stop());
bot.start({ onStart: (me) => console.log(`Running as @${me.username}`) });
```

Inícialo con el archivo `.env` y luego envía `/movie fight club` a tu bot en Telegram.

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

Con `/movie fight club`, el primer resultado de la búsqueda es la película 550. El bot envía su póster, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, con este pie de foto:

```text
Fight Club (1999)
Rating: IMDb 8.8
Runtime: 139 min

A ticking-time-bomb insomniac and a slippery soap salesman channel primal male aggression into a shocking new form of therapy. Their concept catches on, with underground "fight clubs" forming in every town, until an eccentric gets in the way and ignites an out-of-control spiral toward oblivion.
```

El bot usa long polling, así que no necesita una dirección pública. Mantén el proceso en marcha con la herramienta que ya uses para servicios.

## Próximos pasos

- Añade `/trending` con la llamada `trending/movie/week` de [tendencias y listas](https://amodb.dev/es/trending-lists/).
- Deja que los usuarios elijan uno de cinco resultados con un teclado en línea, usando más de la respuesta de [búsqueda](https://amodb.dev/es/search/).
- Envía tráilers con el endpoint de [vídeos y tráilers](https://amodb.dev/es/videos-trailers/).
- Crea el mismo comando para [Discord](https://amodb.dev/es/discord-movie-bot/).

## Preguntas y respuestas

### ¿Necesita el bot un servidor público o un webhook?

No. El bot usa long polling con bot.start(), así que solo hace solicitudes salientes. Ejecútalo en cualquier equipo que siga en línea. Un webhook es opcional y solo sirve si prefieres la entrega push.

### ¿Cuántas solicitudes a amodb usa un comando /movie?

Dos: una llamada de búsqueda y una de detalles con puntuaciones. Un título que sigue en el caché Map no usa ninguna. Con el plan de 20 solicitudes por minuto eso permite unas 10 búsquedas nuevas por minuto.

### ¿Por qué a veces falla el envío del póster?

Telegram descarga la foto desde la URL por sí mismo, y rechaza los archivos que no puede obtener o que superan sus límites. El bot captura ese error y envía el pie de foto como texto en lugar de quedarse en silencio.

### ¿Puede el bot responder en otro idioma?

Sí. Cambia el valor de language, por ejemplo ru-RU o es-ES, en ambas llamadas. Se sirven catorce idiomas base, y cualquier otra etiqueta recurre al inglés. Los títulos y las sinopsis llegan traducidos cuando existe una traducción.

### ¿Dónde debo guardar el token del bot y la clave de API?

En variables de entorno, cargadas con la opción env-file de Node.js o con tu gestor de procesos, y nunca en Git. Si el token del bot se filtra, crea uno nuevo con BotFather. Para una clave de amodb, crea una nueva en el panel de tu cuenta.

## Páginas relacionadas

- [Tutorial de bot de películas para Discord](https://amodb.dev/es/discord-movie-bot/): Crea un bot de Discord con discord.js v14 y un comando slash /movie que responde con un embed de póster, notas y sinopsis desde amodb, con reintento ante 429.
- [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/telegram-movie-bot/
