# Падручнік па 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/be/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/be/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` і піша «not known» для `null`. Постэр гэта `https://img.amodb.dev/t/p/w342` плюс `poster_path`; іншыя памеры гл. на старонцы [выявы](https://amodb.dev/be/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/be/errors/) і [ліміты запытаў](https://amodb.dev/be/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             |
| Працягласць | 139 хв          |
| Жанры       | Drama, Thriller |

Першы пошук назвы робіць два выклікі, а наступныя ў межах жыцця кэшу не робяць ніводнага. 401 спыняе працэс пасля паведамлення, а тайм-аўт дае агульны адказ.

## Наступныя крокі

- Дадайце аўтадапаўненне для опцыі `title` праз эндпоінт [пошуку](https://amodb.dev/be/search/), у якога edge-кэш на 10 хвілін.
- Дадайце каманду `/trending` праз [трэнды і спісы](https://amodb.dev/be/trending-lists/).
- Дадайце кнопку трэйлера праз эндпоінт [відэа і трэйлеры](https://amodb.dev/be/videos-trailers/).
- Зрабіце тую ж каманду для [Telegram](https://amodb.dev/be/telegram-movie-bot/).

## Пытанні і адказы

### Чаму бот адкладае адказ?

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

### Якія intents патрэбныя боту?

Толькі Guilds. Слэш-каманды прыходзяць як узаемадзеянні, таму бот ніколі не чытае змест паведамленняў і не патрабуе прывілеяваных intents. Дадавайце іншыя толькі тады, калі гэтага патрабуе функцыя, якую вы будуеце.

### Як захоўваць токен Discord і ключ amodb?

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

### Ці можа каманда паказаць больш за адзін вынік?

Так. Стварыце адзін embed на кожны з першых трох запісаў у results, бо паведамленне можа ўтрымліваць да 10 embed. Кожны дадатковы выклік дэталяў каштуе адзін запыт, таму сачыце за запытамі ў хвіліну.

### Ці патрабуе постэр у embed ключ API?

Не. URL мініяцюры паказвае на img.amodb.dev, які аддае выявы без ключа, і Discord загружае яго, калі паведамленне з’яўляецца. Ключ выкарыстоўвае толькі ваш бот, для выклікаў JSON.

## Звязаныя старонкі

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

Абноўлена: 2026-10-18. Source: https://amodb.dev/be/discord-movie-bot/
