מדריך בוט סרטים ל-Discord

המדריך בונה בוט Discord עם discord.js v14 ופקודת סלאש /movie. הבוט מחפש ב-amodb, טוען את ההתאמה הטובה ביותר עם הדירוגים שלה ועונה עם embed שמכיל פוסטר, ציונים, משך ותקציר. הוא דוחה את התשובה, מנסה שוב ב-429 ושומר תשובות במטמון. זה לוקח כ-25 דקות.

.js Discord עודכן צפייה כ-Markdown

מה בונים

בונים בוט Discord עם פקודת סלאש אחת, /movie title. הוא עונה עם embed שמציג את הפוסטר, השנה, ציוני IMDb ו-Kinopoisk, משך, ז'אנרים ותקציר קצר. צריך Node.js 20.6 ומעלה, discord.js v14, אפליקציית Discord עם אסימון בוט ומפתח amodb. תכננו כ-25 דקות.

שלב 1: קבלת האישורים

בחרו מסלול בעמוד תמחור. קצב הוא ההבדל היחיד בין המסלולים, והנמוך ביותר מאפשר 20 בקשות לדקה. צרו מפתח בלוח הבקרה של החשבון.

בפורטל המפתחים של Discord צרו אפליקציה, הוסיפו לה בוט והעתיקו את אסימון הבוט. פתחו את עמוד OAuth2, השתמשו במחולל הכתובות, סמנו את ההיקפים bot ו-applications.commands ופתחו את הקישור כדי להוסיף את הבוט לשרת בדיקה. העתיקו גם את מזהה השרת. שימו את הערכים בקובץ .env והרחיקו אותו מ-Git:

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

שלב 2: יצירת הפרויקט

ההגדרה type מפעילה תחביר import. את צורות הכותרת והשאילתה של המפתח ראו בעמוד אימות.

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

שלב 3: רישום פקודת הסלאש

SlashCommandBuilder מתאר את הפקודה ואת האפשרות החובה title שלה. הבוט שולח אותה ל-Discord ברגע שהוא מוכן. כש-DISCORD_GUILD_ID מוגדר, הפקודה מופיעה בשרת הזה מיד. אם משאירים את המשתנה לא מוגדר, היא נרשמת בכל מקום.

JavaScript
import { Client, Events, GatewayIntentBits, SlashCommandBuilder } from 'discord.js';

const movieCommand = new SlashCommandBuilder()
  .setName('movie')
  .setDescription('Look up a movie')
  .addStringOption((option) =>
    option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
  );

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.once(Events.ClientReady, async (ready) => {
  await ready.application.commands.set([movieCommand.toJSON()], process.env.DISCORD_GUILD_ID);
  console.log(`Logged in as ${ready.user.tag}`);
});

שלב 4: בניית ה-embed

קריאת הפרטים מוסיפה append_to_response=ratings, ולכן ציונים מגיעים יחד עם הסרט. כל מקור הוא אובייקט עם value ו-votes, או null כשהציון לא ידוע. ה-embed מדפיס את value וכותב "לא ידוע" עבור null. הפוסטר הוא https://img.amodb.dev/t/p/w342 ועוד poster_path; גדלים אחרים בעמוד תמונות. תיאור של embed יכול להכיל 4096 תווים, והקוד שומר 400. הוסיפו את EmbedBuilder לשורת ה-import והגדירו את הקבוע POSTER, כמו ברשימה הסופית.

JavaScript
function movieEmbed(movie) {
  const score = (source) => (source ? String(source.value) : 'not known');
  const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
  const embed = new EmbedBuilder()
    .setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
    .setDescription((movie.overview || 'No overview yet.').slice(0, 400))
    .addFields(
      { name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
      { name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
      { name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
      { name: 'Genres', value: genres },
    );
  if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
  return embed;
}

שלב 5: טיפול בשגיאות ובמגבלות קצב

קראו קודם ל-deferReply(). Discord רוצה תשובה תוך 3 שניות, וחיפוש ועוד קריאת פרטים, או המתנה אחרי 429, יכולים לקחת יותר. אחרי דחייה יש לכם 15 דקות לקרוא ל-editReply().

מקרהסיבהתגובת הבוט
401המפתח חסר, שגוי או מושעהלרשום ביומן, להודיע לערוץ ולצאת. כל פקודה הייתה נכשלת.
429מעל הבקשות שלכם לדקהלהמתין Retry-After שניות, עד 10, לנסות שוב פעמיים ואז לבקש מהמשתמש לנסות מאוחר יותר.
results ריקאין כותר תואםלהשיב שלא נמצא סרט.
פסק זמן אחרי 8 שניותרשת איטיתלהשיב שהשירות לא ענה.

לולאת הניסיון החוזר ברשימה הסופית קוראת את Retry-After מכותרות התגובה. את כל הקודים ראו בעמודים שגיאות ומגבלות קצב.

JavaScript
function describeError(error) {
  if (error instanceof AmodbError && error.status === 429) {
    return 'Too many lookups right now. Try again in a minute.';
  }
  return 'The movie service did not answer. Try again later.';
}

שלב 6: שמירת תשובות במטמון

בדיקת מגבלת הקצב רצה לפני מטמון הקצה, ולכן רק מטמון בבוט שלכם חוסך בקשות לדקה. הבוט הזה מחזיק מחלקה קטנה TtlCache: 10 דקות לתוצאות חיפוש ו-6 שעות לפרטי סרט, אותם משכים כמו בקצה. המחלקה נמצאת ברשימה הסופית. שני אנשים ששואלים על אותו כותר בתוך 10 דקות עולים אז שתי בקשות בסך הכול, לא ארבע.

שלב 7: הבוט המלא

שמרו את זה כ-bot.js.

JavaScript
import {
  Client,
  EmbedBuilder,
  Events,
  GatewayIntentBits,
  SlashCommandBuilder,
} from 'discord.js';

const { DISCORD_TOKEN, DISCORD_GUILD_ID, AMODB_KEY } = process.env;
if (!DISCORD_TOKEN || !AMODB_KEY) {
  console.error('Set DISCORD_TOKEN and AMODB_KEY first.');
  process.exit(1);
}

const API = 'https://api.amodb.dev/3';
const POSTER = 'https://img.amodb.dev/t/p/w342';

class AmodbError extends Error {
  constructor(status) {
    super(`amodb returned ${status}`);
    this.status = status;
  }
}

class TtlCache {
  #items = new Map();

  get(key) {
    const item = this.#items.get(key);
    if (!item) return undefined;
    if (item.expires < Date.now()) {
      this.#items.delete(key);
      return undefined;
    }
    return item.value;
  }

  set(key, value, ttlMs) {
    this.#items.set(key, { value, expires: Date.now() + ttlMs });
  }
}

const cache = new TtlCache();

async function amodb(path, params, ttlMs) {
  const url = new URL(API + path);
  url.search = new URLSearchParams(params).toString();
  const cached = cache.get(url.href);
  if (cached) return cached;

  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${AMODB_KEY}` },
      signal: AbortSignal.timeout(8_000),
    });
    if (res.status === 429 && attempt < 2) {
      const wait = Math.min(Number(res.headers.get('retry-after')) || 3, 10);
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) throw new AmodbError(res.status);
    const body = await res.json();
    cache.set(url.href, body, ttlMs);
    return body;
  }
}

function movieEmbed(movie) {
  const score = (source) => (source ? String(source.value) : 'not known');
  const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
  const embed = new EmbedBuilder()
    .setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
    .setDescription((movie.overview || 'No overview yet.').slice(0, 400))
    .addFields(
      { name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
      { name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
      { name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
      { name: 'Genres', value: genres },
    );
  if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
  return embed;
}

function describeError(error) {
  if (error instanceof AmodbError && error.status === 429) {
    return 'Too many lookups right now. Try again in a minute.';
  }
  return 'The movie service did not answer. Try again later.';
}

const movieCommand = new SlashCommandBuilder()
  .setName('movie')
  .setDescription('Look up a movie')
  .addStringOption((option) =>
    option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
  );

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.once(Events.ClientReady, async (ready) => {
  await ready.application.commands.set([movieCommand.toJSON()], DISCORD_GUILD_ID);
  console.log(`Logged in as ${ready.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'movie') return;

  await interaction.deferReply();
  try {
    const title = interaction.options.getString('title', true).trim();
    const found = await amodb('/search/movie', { query: title, language: 'en-US' }, 10 * 60_000);
    const hit = found.results[0];
    if (!hit) {
      await interaction.editReply(`No movie found for "${title}".`);
      return;
    }
    const movie = await amodb(
      `/movie/${hit.id}`,
      { language: 'en-US', append_to_response: 'ratings' },
      6 * 60 * 60_000,
    );
    await interaction.editReply({ embeds: [movieEmbed(movie)] });
  } catch (error) {
    console.error('Lookup failed:', error);
    if (error instanceof AmodbError && error.status === 401) {
      console.error('amodb rejected AMODB_KEY (401). Fix the key and restart.');
      await interaction.editReply('The bot key was rejected. The owner has to fix it.').catch(() => {});
      await client.destroy();
      process.exit(1);
    }
    await interaction.editReply(describeError(error)).catch(() => {});
  }
});

process.on('unhandledRejection', (error) => console.error('Unhandled rejection:', error));

await client.login(DISCORD_TOKEN);

הפעילו את הבוט עם קובץ ה-.env, ואז הקלידו /movie בשרת הבדיקה והזינו כותרת.

עבור /movie fight club תוצאת החיפוש הראשונה היא סרט 550, וה-embed מחזיק את הכותרת Fight Club (1999), את התקציר החתוך ל-400 תווים ואת התמונה הממוזערת https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg, בתוספת השדות האלה:

שדהערך
IMDb8.8
Kinopoisk8.7
Runtime139 min
GenresDrama, Thriller

חיפוש ראשון של כותר מבצע שתי קריאות, וחיפושים מאוחרים בתוך משך המטמון לא מבצעים אף אחת. 401 עוצר את התהליך אחרי הודעה, ופסק זמן מייצר את התשובה הכללית.

Shell
node --env-file=.env bot.js

הצעדים הבאים