מדריך בוט סרטים ל-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 ובנו כיתוב. כתובת הפוסטר היא 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 מכתובת לנתונים וזמן תפוגה. השתמשו במשכים של הקצה: 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 מעמוד טרנדים ורשימות.
  • תנו למשתמשים לבחור אחת מחמש תוצאות עם מקלדת מוטמעת, תוך שימוש בעוד חלקים מתגובת חיפוש.
  • שלחו טריילרים עם נקודת הקצה של סרטונים וטריילרים.
  • בנו את אותה פקודה עבור Discord.