Посібник з 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: отримайте два облікові дані
Оберіть тариф на сторінці ціни. Тариф визначає, скільки запитів на хвилину може надсилати ваш бот. Створіть ключ у панелі облікового запису, довжиною 32 шістнадцяткових символи. Потім відкрийте чат з BotFather у Telegram, надішліть /newbot, дайте відповідь на два запитання й скопіюйте токен, який він виведе.
Покладіть обидва значення у файл .env і додайте цей файл до .gitignore:
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEYКрок 2: створіть проєкт
Параметр type вмикає синтаксис import і 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: кешуйте відповіді
Перевірка ліміту запитів виконується до кешу на периферії, тож запити економить лише кеш у вашому власному боті. Тримайте Map від URL до даних і час закінчення. Використовуйте терміни життя на периферії: 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:/, з таким підписом:
Бот використовує довге опитування, тож публічна адреса не потрібна. Тримайте процес запущеним тим інструментом, яким ви вже користуєтеся для сервісів.
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.