# Урок за 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/bg/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/bg/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/bg/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/bg/rate-limits/) и [грешки](https://amodb.dev/bg/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.
```

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

## Следващи стъпки

- Добавете `/trending` с извикването `trending/movie/week` от [тенденции и списъци](https://amodb.dev/bg/trending-lists/).
- Оставете потребителите да изберат един от пет резултата с вградена клавиатура, като ползвате повече от отговора на [търсене](https://amodb.dev/bg/search/).
- Изпращайте трейлъри с ендпойнта [видеа и трейлъри](https://amodb.dev/bg/videos-trailers/).
- Направете същата команда за [Discord](https://amodb.dev/bg/discord-movie-bot/).

## Въпроси и отговори

### Нужен ли е на бота публичен сървър или webhook?

Не. Ботът ползва дълго запитване (long polling) през bot.start(), така че прави само изходящи заявки. Пуснете го на всяка машина, която остава онлайн. Webhook е по желание и е полезен само ако предпочитате доставка с натискане.

### Колко заявки към amodb харчи една команда /movie?

Две: едно извикване за търсене и едно за детайли с рейтинги. Заглавие, което още е в кеша Map, не харчи нито една. При плана с 20 заявки в минута това са около 10 нови търсения в минута.

### Защо понякога постерът не се изпраща?

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

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

Да. Сменете стойността на language, например на ru-RU или es-ES, и в двете извиквания. Обслужват се четиринайсет основни езика, а всеки друг таг се заменя с английски. Заглавията и описанията идват преведени, когато има превод.

### Къде да пазя токена на бота и API ключа?

В променливи на средата, заредени с опцията env-file на Node.js или от вашия мениджър на процеси, и никога в Git. Ако токенът на бота изтече, създайте нов през BotFather. За ключ на amodb създайте нов в личния си профил.

## Свързани страници

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

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