Посібник з Telegram-бота для фільмів

Цей посібник створює бота Telegram на grammY у Node.js. Надішліть /movie fight club, і бот відповість постером, роком, тривалістю та рейтингом з amodb. Він читає обидва токени зі змінних середовища, повторює запит при 429 за Retry-After, кешує відповіді в пам'яті й зупиняється при відхиленому ключі. Це займає близько 20 хвилин.

.js Telegram Оновлено Відкрити в Markdown

Що ви створите

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

Крок 1: отримайте два облікові дані

Оберіть тариф на сторінці ціни. Тариф визначає, скільки запитів на хвилину може надсилати ваш бот. Створіть ключ у панелі облікового запису, довжиною 32 шістнадцяткових символи. Потім відкрийте чат з BotFather у Telegram, надішліть /newbot, дайте відповідь на два запитання й скопіюйте токен, який він виведе.

Покладіть обидва значення у файл .env і додайте цей файл до .gitignore:

Shell
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEY

Крок 2: створіть проєкт

Параметр type вмикає синтаксис import і await верхнього рівня. Сторінка автентифікація показує форми ключа в заголовку й через api_key.

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

Крок 3: відповідь на першу команду

grammY кладе текст після команди в ctx.match. Почніть з допоміжної функції, що надсилає заголовок Bearer, і команди, яка шукає.

JavaScript
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 символи, тому код обрізає опис.

Кожне джерело в ratings це об'єкт із value і votes або null. Коли imdb дорівнює null, підпис переходить на агрегований vote_average. Сторінка рейтинги пояснює всі п'ять джерел.

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

Крок 5: обробка помилок і лімітів запитів

Нехай обробники викидають помилки, а перехоплюйте все в одному місці. Без bot.catch grammY зупиняє бота, коли обробник викидає помилку.

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

Цикл нижче розташований усередині getJson. AmodbError це невеликий підклас Error з полем status, показаний у фінальному лістингу.

Кожна команда коштує два запити, тож тариф 20 запитів на хвилину обслуговує близько 10 нових пошуків за хвилину. Подробиці дивіться на сторінках ліміти запитів та помилки.

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

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

Перевірка ліміту запитів виконується до кешу на периферії, тож запити економить лише кеш у вашому власному боті. Тримайте Map від URL до даних і час закінчення. Використовуйте терміни життя на периферії: 10 хвилин для пошуку й 6 годин для деталей. Обмеження розміру не дає довгоживучому боту розростатися безмежно.

Рядки кешу теж розташовані всередині getJson.

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

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

Shell
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 зі сторінки списки трендів.
  • Дайте користувачам вибрати один із п'яти результатів за допомогою інлайн-клавіатури, використавши більше з відповіді пошуку.
  • Надсилайте трейлери через ендпоінт відео та трейлери.
  • Створіть таку саму команду для Discord.