# Посібник з 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/uk/pricing/). Тарифи відрізняються лише швидкістю, найнижчий дозволяє 20 запитів на хвилину. Створіть ключ у панелі вашого акаунта.

У порталі розробників Discord створіть застосунок, додайте до нього бота й скопіюйте токен бота. Відкрийте сторінку OAuth2, скористайтеся генератором URL, позначте області `bot` та `applications.commands` і відкрийте посилання, щоб додати бота на тестовий сервер. Скопіюйте також id сервера. Покладіть значення у файл `.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/uk/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` пише «not known». Постер це `https://img.amodb.dev/t/p/w342` плюс `poster_path`; інші розміри дивіться на сторінці [зображення](https://amodb.dev/uk/images-api/). Опис embed може вмістити 4096 символів, а код залишає 400. Додайте `EmbedBuilder` до рядка імпорту та визначте константу `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/uk/errors/) та [ліміти запитів](https://amodb.dev/uk/rate-limits/).

## Крок 6: кешуйте відповіді

Перевірка ліміту запитів виконується перед edge-кешем, тож заощадити запити на хвилину може лише кеш у вашому боті. Цей бот містить невеликий клас `TtlCache`: 10 хвилин для результатів пошуку та 6 годин для деталей фільму, ті самі терміни, що й в edge. Клас є у фінальному лістингу. Двоє людей, які просять ту саму назву протягом 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/uk/search/), який має edge-кеш на 10 хвилин.
- Додайте команду `/trending` на основі [списків трендів](https://amodb.dev/uk/trending-lists/).
- Додайте кнопку трейлера через ендпоінт [відео та трейлери](https://amodb.dev/uk/videos-trailers/).
- Створіть таку саму команду для [Telegram](https://amodb.dev/uk/telegram-movie-bot/).

## Запитання й відповіді

### Чому бот відкладає відповідь?

Discord очікує відповіді на слеш-команду протягом 3 секунд. Пошук разом із запитом деталей або очікування після 429 можуть тривати довше. deferReply() подовжує цей час до 15 хвилин, а editReply() потім надсилає embed.

### Які інтенти потрібні боту?

Лише Guilds. Слеш-команди надходять як взаємодії, тож бот ніколи не читає вміст повідомлень і не потребує привілейованого інтенту. Додавайте інші інтенти, лише коли їх потребує створена вами функція.

### Як зберігати токен Discord і ключ amodb?

У змінних середовища, наприклад у файлі .env, який завантажується опцією env-file в Node.js і не потрапляє до Git. Якщо токен витік, скиньте його в порталі розробників. Для amodb створіть новий ключ у панелі вашого акаунта.

### Чи може команда показати більше одного результату?

Так. Побудуйте один embed для кожного з перших трьох елементів results, адже повідомлення може містити до 10 embed. Кожен додатковий запит деталей коштує один запит, тож стежте за своїми запитами на хвилину.

### Чи потрібен ключ API для постера в embed?

Ні. URL мініатюри вказує на img.amodb.dev, що віддає зображення без ключа, і Discord завантажує його, коли з'являється повідомлення. Ключ використовує лише ваш бот, для викликів JSON.

## Пов'язані сторінки

- [Посібник з Telegram-бота для фільмів](https://amodb.dev/uk/telegram-movie-bot/): Створіть бота Telegram на grammY і Node.js, що відповідає на /movie з постером, роком і рейтингом з amodb, з повтором при 429 і невеликим кешем у пам'яті.
- [Посібник з JavaScript API фільмів](https://amodb.dev/uk/javascript-movie-api/): Напишіть скрипт Node.js, що завантажує деталі, акторів і рейтинги для списку id фільмів одним запитом на кожен, з TTL-кешем і повтором при 429. Без залежностей.
- [API пошуку](https://amodb.dev/uk/search/): API пошуку з тими самими шляхами та JSON, що в TMDB v3. Шукайте фільми, серіали, персон, колекції, компанії та ключові слова за текстом, з фільтром за роком.
- [Рейтинги фільмів і серіалів](https://amodb.dev/uk/ratings/): API рейтингів фільмів повертає оцінки IMDb, Kinopoisk, Metacritic, Rotten Tomatoes і TMDB для фільму чи серіалу одним викликом, з null для невідомих джерел.
- [Ліміти запитів](https://amodb.dev/uk/rate-limits/): amodb обмежує кожен ключ запитами на хвилину в ковзному вікні 60 секунд: 20, 40 або 120 за тарифом. Читайте заголовки ліміту й обробляйте 429 із Retry-After.
- [Автентифікація](https://amodb.dev/uk/authentication/): Як надсилати ключ amodb: api_key у запиті, заголовок Authorization Bearer або apikey для викликів OMDb. Формат ключа, зберігання, заміна та помилки 401.

Оновлено: 2026-10-18. Source: https://amodb.dev/uk/discord-movie-bot/
