# Падручнік па 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/be/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/be/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/be/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/be/rate-limits/) і [памылкі](https://amodb.dev/be/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/be/trending-lists/).
- Дазвольце карыстальнікам абраць адзін з пяці вынікаў праз убудаваную клавіятуру, выкарыстоўваючы больш даных з адказу [пошуку](https://amodb.dev/be/search/).
- Адпраўляйце трэйлеры праз эндпоінт [відэа і трэйлеры](https://amodb.dev/be/videos-trailers/).
- Зрабіце тую ж каманду для [Discord](https://amodb.dev/be/discord-movie-bot/).

## Пытанні і адказы

### Ці патрэбны боту публічны сервер або webhook?

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

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/telegram-movie-bot/
