# Руководство по Telegram-боту для фильмов

> Руководство создает бота Telegram на grammY и Node.js. Отправьте /movie fight club, и бот ответит постером, годом, длительностью и рейтингом из amodb. Оба токена читаются из переменных окружения, при 429 бот повторяет запрос по Retry-After, кэширует ответы в памяти и останавливается при отклоненном ключе. Займет около 20 минут.

## Что вы создадите

Вы создадите бота Telegram, который отвечает на `/movie fight club` постером, названием, годом, длительностью и рейтингом. Нужны Node.js 20.6 или новее, токен бота от BotFather внутри Telegram и ключ amodb. Бот использует grammY и встроенный `fetch`, поэтому хватит одного пакета npm. Заложите около 20 минут.

## Шаг 1: получите токен бота и ключ API

Выберите тариф на странице [цены](https://amodb.dev/ru/pricing/). Тариф задает, сколько запросов в минуту может отправлять ваш бот. Создайте ключ в личном кабинете, он состоит из 32 шестнадцатеричных символов. Затем откройте чат с BotFather в Telegram, отправьте `/newbot`, ответьте на два вопроса и скопируйте выданный токен.

Поместите оба значения в файл `.env` и добавьте этот файл в `.gitignore`:

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

## Шаг 2: создайте проект

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

Параметр `type` включает синтаксис `import` и top level `await`. На странице [аутентификация](https://amodb.dev/ru/authentication/) показаны формы ключа в заголовке и в `api_key`.

## Шаг 3: ответьте на первую команду

grammY помещает текст после команды в `ctx.match`. Начните с хелпера, который отправляет заголовок Bearer, и команды, которая выполняет поиск.

```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();
```

## Шаг 4: соберите ответ с постером

Возьмите первое найденное совпадение, загрузите его с `append_to_response=ratings` и соберите подпись. URL постера собирается из `https://img.amodb.dev/t/p/w500` и `poster_path`. Telegram сам скачивает фото, а `img.amodb.dev` не требует ключа. Подпись вмещает не более 1024 символов, поэтому код обрезает описание.

```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);
}
```

Каждый источник в `ratings` представляет собой объект с `value` и `votes` или `null`. Когда `imdb` равен `null`, подпись переключается на агрегированный `vote_average`. Все пять источников объяснены на странице [рейтинги](https://amodb.dev/ru/ratings/).

## Шаг 5: обработайте ошибки и лимиты запросов

Позвольте обработчикам выбрасывать исключения и перехватывайте все в одном месте. Без `bot.catch` grammY останавливает бота, когда обработчик выбрасывает исключение.

| Случай                     | Причина                                     | Реакция бота                                                                                         |
| -------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| 401                        | Ключ отсутствует, неверен или приостановлен | Записать в лог, сообщить в чат, завершить работу. Каждая команда завершилась бы ошибкой.             |
| 429                        | Превышен лимит запросов в минуту            | Ждать `Retry-After` секунд, не более 30, повторить дважды, затем попросить пользователя зайти позже. |
| Пустой `results`           | Ни одно название не подходит под запрос     | Ответить, что фильм не найден.                                                                       |
| Таймаут или сетевая ошибка | `fetch` выбрасывает исключение              | Ответить коротким извинением и записать ошибку в лог.                                                |
| Фото отклонено             | Telegram не может загрузить изображение     | Отправить подпись текстом.                                                                           |

Цикл ниже находится внутри `getJson`. `AmodbError` является небольшим подклассом `Error` с полем `status`, он показан в итоговом листинге.

```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();
}
```

Каждая команда стоит два запроса, поэтому тариф на 20 запросов в минуту обслуживает около 10 новых поисков в минуту. Подробности смотрите на страницах [лимиты запросов](https://amodb.dev/ru/rate-limits/) и [ошибки](https://amodb.dev/ru/errors/).

## Шаг 6: кэшируйте ответы

Проверка лимита запросов выполняется раньше edge-кэша, поэтому запросы экономит только кэш в самом боте. Храните `Map` от URL к данным и время истечения. Используйте сроки edge-кэша: 10 минут для поиска и 6 часов для деталей. Ограничение размера не дает долго работающему боту бесконечно расти.

Строки кэша тоже находятся внутри `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 });
```

## Шаг 7: полный бот

Сохраните это как `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}`) });
```

Запустите его с файлом `.env`, затем отправьте `/movie fight club` вашему боту в Telegram.

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

Для `/movie fight club` первым найденным совпадением оказывается фильм 550. Бот отправляет его постер, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, со следующей подписью:

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

Бот использует long polling, поэтому публичный адрес ему не нужен. Держите процесс запущенным тем инструментом, которым вы уже пользуетесь для сервисов.

## Что дальше

- Добавьте `/trending` с вызовом `trending/movie/week` со страницы [списки трендов](https://amodb.dev/ru/trending-lists/).
- Дайте пользователям выбрать один из пяти результатов с помощью inline-клавиатуры, используя больше данных из ответа [поиска](https://amodb.dev/ru/search/).
- Отправляйте трейлеры через эндпоинт [видео и трейлеры](https://amodb.dev/ru/videos-trailers/).
- Сделайте такую же команду для [Discord](https://amodb.dev/ru/discord-movie-bot/).

## Вопросы и ответы

### Нужен ли боту публичный сервер или вебхук?

Нет. Бот использует long polling через bot.start(), поэтому делает только исходящие запросы. Запускайте его на любой машине, которая остается онлайн. Вебхук необязателен и полезен, только если вы предпочитаете доставку через push.

### Сколько запросов amodb тратит одна команда /movie?

Два: один вызов поиска и один вызов деталей с рейтингами. Название, которое еще лежит в кэше Map, не тратит ни одного. На тарифе с 20 запросами в минуту это около 10 новых поисков в минуту.

### Почему постер иногда не отправляется?

Telegram сам скачивает фото по URL и отклоняет файлы, которые не может загрузить или которые выходят за его ограничения. Бот перехватывает эту ошибку и отправляет подпись обычным текстом, а не молчит.

### Может ли бот отвечать на другом языке?

Да. Измените значение language, например на ru-RU или es-ES, в обоих вызовах. Поддерживаются четырнадцать базовых языков, любой другой тег откатывается на английский. Названия и описания приходят переведенными, если перевод есть.

### Где хранить токен бота и ключ API?

В переменных окружения, загружаемых опцией env-file в Node.js или вашим менеджером процессов, и никогда в Git. Если токен бота утек, создайте новый через BotFather. Для ключа amodb создайте новый в личном кабинете.

## Связанные страницы

- [Руководство по Discord-боту для фильмов](https://amodb.dev/ru/discord-movie-bot/): Соберите Discord-бота на discord.js v14 с командой /movie, которая отвечает embed с постером, рейтингами и описанием из amodb и повторяет запрос при ошибке 429.
- [Руководство по JavaScript movie API](https://amodb.dev/ru/javascript-movie-api/): Напишите скрипт Node.js: по списку id фильмов он загружает детали, актеров и рейтинги одним запросом на фильм, с кэшем TTL, повтором при 429, без зависимостей.
- [API поиска](https://amodb.dev/ru/search/): API поиска с путями и JSON как в TMDB v3. Находите фильмы, сериалы, людей, коллекции, компании и ключевые слова по тексту, с фильтром по году и пагинацией.
- [Рейтинги фильмов и сериалов](https://amodb.dev/ru/ratings/): API рейтингов фильмов: оценки IMDb, Kinopoisk, Metacritic, Rotten Tomatoes и TMDB для тайтла одним запросом, с null для источника, о котором нет данных.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.
- [Аутентификация](https://amodb.dev/ru/authentication/): Как передать ключ amodb: api_key в запросе, заголовок Authorization Bearer или apikey для вызовов OMDb. Формат ключа, хранение, ротация и ошибки 401.

Обновлено: 2026-10-18. Source: https://amodb.dev/ru/telegram-movie-bot/
