Руководство по 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: получите токен бота и ключ API
Выберите тариф на странице цены. Тариф задает, сколько запросов в минуту может отправлять ваш бот. Создайте ключ в личном кабинете, он состоит из 32 шестнадцатеричных символов. Затем откройте чат с BotFather в Telegram, отправьте /newbot, ответьте на два вопроса и скопируйте выданный токен.
Поместите оба значения в файл .env и добавьте этот файл в .gitignore:
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEYШаг 2: создайте проект
Параметр type включает синтаксис import и top level await. На странице аутентификация показаны формы ключа в заголовке и в api_key.
mkdir movie-bot && cd movie-bot
npm init -y
npm pkg set type=module
npm install grammyШаг 3: ответьте на первую команду
grammY помещает текст после команды в ctx.match. Начните с хелпера, который отправляет заголовок Bearer, и команды, которая выполняет поиск.
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_ и соберите подпись. URL постера собирается из https:/ и poster_path. Telegram сам скачивает фото, а img.amodb.dev не требует ключа. Подпись вмещает не более 1024 символов, поэтому код обрезает описание.
Каждый источник в ratings представляет собой объект с value и votes или null. Когда imdb равен null, подпись переключается на агрегированный vote_average. Все пять источников объяснены на странице рейтинги.
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 новых поисков в минуту. Подробности смотрите на страницах лимиты запросов и ошибки.
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.
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.
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.
node --env-file=.env bot.jsДля /movie fight club первым найденным совпадением оказывается фильм 550. Бот отправляет его постер, https:/, со следующей подписью:
Бот использует long polling, поэтому публичный адрес ему не нужен. Держите процесс запущенным тем инструментом, которым вы уже пользуетесь для сервисов.
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.