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

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

## מה בונים

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

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

בחרו מסלול בעמוד [תמחור](https://amodb.dev/he/pricing/). קצב הוא ההבדל היחיד בין המסלולים, והנמוך ביותר מאפשר 20 בקשות לדקה. צרו מפתח בלוח הבקרה של החשבון.

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

```bash
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY
```

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

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

ההגדרה `type` מפעילה תחביר `import`. את צורות הכותרת והשאילתה של המפתח ראו בעמוד [אימות](https://amodb.dev/he/authentication/).

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

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

```js
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`; גדלים אחרים בעמוד [תמונות](https://amodb.dev/he/images-api/). תיאור של embed יכול להכיל 4096 תווים, והקוד שומר 400. הוסיפו את `EmbedBuilder` לשורת ה-import והגדירו את הקבוע `POSTER`, כמו ברשימה הסופית.

```js
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 שניות | רשת איטית                | להשיב שהשירות לא ענה.                                                                  |

```js
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.';
}
```

לולאת הניסיון החוזר ברשימה הסופית קוראת את `Retry-After` מכותרות התגובה. את כל הקודים ראו בעמודים [שגיאות](https://amodb.dev/he/errors/) ו[מגבלות קצב](https://amodb.dev/he/rate-limits/).

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

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

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

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

```js
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` בשרת הבדיקה והזינו כותרת.

```bash
node --env-file=.env bot.js
```

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

| שדה       | ערך             |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

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

## הצעדים הבאים

- הוסיפו השלמה אוטומטית לאפשרות `title` עם נקודת הקצה של [חיפוש](https://amodb.dev/he/search/), שיש לה מטמון קצה של 10 דקות.
- הוסיפו פקודת `/trending` עם [טרנדים ורשימות](https://amodb.dev/he/trending-lists/).
- הוסיפו כפתור טריילר עם נקודת הקצה של [סרטונים וטריילרים](https://amodb.dev/he/videos-trailers/).
- בנו את אותה פקודה עבור [Telegram](https://amodb.dev/he/telegram-movie-bot/).

## שאלות ותשובות

### למה הבוט דוחה את התשובה?

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

### אילו intents הבוט צריך?

רק Guilds. פקודות סלאש מגיעות כאינטראקציות, ולכן הבוט אף פעם לא קורא תוכן הודעות ולא צריך intent מיוחד. הוסיפו intents נוספים רק כשתכונה שאתם בונים צריכה אותם.

### איך לשמור את האסימון של Discord ואת מפתח amodb?

במשתני סביבה, למשל קובץ .env שנטען עם האפשרות env-file של Node.js, מחוץ ל-Git. אם האסימון דלף, אפסו אותו בפורטל המפתחים. עבור amodb, צרו מפתח חדש בלוח הבקרה של החשבון.

### הפקודה יכולה להציג יותר מתוצאה אחת?

כן. בנו embed אחד לכל אחד משלושת הפריטים הראשונים ב-results, כי הודעה יכולה להכיל עד 10 embeds. כל קריאת פרטים נוספת עולה בקשה אחת, ולכן שימו לב לבקשות לדקה.

### האם הפוסטר ב-embed דורש מפתח API?

לא. כתובת התמונה הממוזערת מצביעה על img.amodb.dev, ששרת תמונות בלי מפתח, ו-Discord שולף אותה כשההודעה מופיעה. רק הבוט שלכם משתמש במפתח, לקריאות ה-JSON.

## עמודים קשורים

- [מדריך בוט סרטים ל-Telegram](https://amodb.dev/he/telegram-movie-bot/): בנו בוט Telegram עם grammY ו-Node.js שעונה ל-/movie עם פוסטר, שנה, משך ודירוג מ-amodb, עם ניסיון חוזר ב-429 לפי Retry-After ומטמון קטן בזיכרון.
- [מדריך API לסרטים ב-JavaScript](https://amodb.dev/he/javascript-movie-api/): כתבו סקריפט Node.js שטוען פרטים, קרדיטים ודירוגים לרשימת מזהי סרטים בבקשה אחת לכל סרט עם append_to_response, עם מטמון TTL, ניסיון חוזר ב-429 ובלי תלויות.
- [API חיפוש](https://amodb.dev/he/search/): API חיפוש עם אותם נתיבים ו-JSON כמו TMDB v3. מצאו סרטים, סדרות, אנשים, אוספים, חברות ומילות מפתח לפי טקסט, עם סינון לפי שנה ועימוד של 20 תוצאות בעמוד.
- [דירוגי סרטים וסדרות](https://amodb.dev/he/ratings/): API דירוגי סרטים וסדרות שמחזיר ציוני IMDb, Kinopoisk, Metacritic, Rotten Tomatoes ו-TMDB לכותר בקריאה אחת, עם value ו-votes ו-null לכל מקור שאינו ידוע.
- [מגבלות קצב](https://amodb.dev/he/rate-limits/): amodb מגביל כל מפתח לפי בקשות לדקה בחלון נע של 60 שניות: 20, 40 או 120 בהתאם למסלול שלכם. קראו את כותרות מגבלת הקצב וטפלו ב-429 עם Retry-After.
- [אימות](https://amodb.dev/he/authentication/): איך שולחים את מפתח amodb: api_key בשאילתה, כותרת Authorization Bearer או apikey בקריאות OMDb. פורמט המפתח, שמירה, החלפה ושגיאות 401 שכדאי להכיר.

עודכן: 2026-10-18. Source: https://amodb.dev/he/discord-movie-bot/
