# Посібник з 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: отримайте два облікові дані

Оберіть тариф на сторінці [ціни](https://amodb.dev/uk/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` і `await` верхнього рівня. Сторінка [автентифікація](https://amodb.dev/uk/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/uk/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/uk/rate-limits/) та [помилки](https://amodb.dev/uk/errors/).

## Крок 6: кешуйте відповіді

Перевірка ліміту запитів виконується до кешу на периферії, тож запити економить лише кеш у вашому власному боті. Тримайте `Map` від URL до даних і час закінчення. Використовуйте терміни життя на периферії: 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.
```

Бот використовує довге опитування, тож публічна адреса не потрібна. Тримайте процес запущеним тим інструментом, яким ви вже користуєтеся для сервісів.

## Наступні кроки

- Додайте `/trending` з викликом `trending/movie/week` зі сторінки [списки трендів](https://amodb.dev/uk/trending-lists/).
- Дайте користувачам вибрати один із п'яти результатів за допомогою інлайн-клавіатури, використавши більше з відповіді [пошуку](https://amodb.dev/uk/search/).
- Надсилайте трейлери через ендпоінт [відео та трейлери](https://amodb.dev/uk/videos-trailers/).
- Створіть таку саму команду для [Discord](https://amodb.dev/uk/discord-movie-bot/).

## Запитання й відповіді

### Чи потрібні боту публічний сервер або вебхук?

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

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