Руководство по 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: получите токен бота и ключ API

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

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

Shell
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEY

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

Параметр type включает синтаксис import и top level 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: кэшируйте ответы

Проверка лимита запросов выполняется раньше edge-кэша, поэтому запросы экономит только кэш в самом боте. Храните Map от URL к данным и время истечения. Используйте сроки edge-кэша: 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, со следующей подписью:

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

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 со страницы списки трендов.
  • Дайте пользователям выбрать один из пяти результатов с помощью inline-клавиатуры, используя больше данных из ответа поиска.
  • Отправляйте трейлеры через эндпоинт видео и трейлеры.
  • Сделайте такую же команду для Discord.