# Руководство по 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/ru/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/ru/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`; другие размеры смотрите на странице [API изображений](https://amodb.dev/ru/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/ru/errors/) и [лимиты запросов](https://amodb.dev/ru/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/ru/search/) с кэшем на границе сети на 10 минут.
- Добавьте команду `/trending` на основе страницы [списки трендов](https://amodb.dev/ru/trending-lists/).
- Добавьте кнопку трейлера через эндпоинт [видео и трейлеры](https://amodb.dev/ru/videos-trailers/).
- Постройте такую же команду для [Telegram](https://amodb.dev/ru/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/ru/telegram-movie-bot/): Создайте бота Telegram на grammY и Node.js, который отвечает на /movie постером, годом и рейтингом из amodb, с повтором при 429 и небольшим кэшем в памяти.
- [Руководство по JavaScript movie API](https://amodb.dev/ru/javascript-movie-api/): Напишите скрипт Node.js: по списку id фильмов он загружает детали, актеров и рейтинги одним запросом на фильм, с кэшем TTL, повтором при 429, без зависимостей.
- [API поиска](https://amodb.dev/ru/search/): API поиска с путями и JSON как в TMDB v3. Находите фильмы, сериалы, людей, коллекции, компании и ключевые слова по тексту, с фильтром по году и пагинацией.
- [Рейтинги фильмов и сериалов](https://amodb.dev/ru/ratings/): API рейтингов фильмов: оценки IMDb, Kinopoisk, Metacritic, Rotten Tomatoes и TMDB для тайтла одним запросом, с null для источника, о котором нет данных.
- [Лимиты запросов](https://amodb.dev/ru/rate-limits/): amodb ограничивает ключ запросами в минуту в скользящем окне 60 секунд: 20, 40 или 120 по тарифу. Читайте заголовки лимита и обрабатывайте 429 с Retry-After.
- [Аутентификация](https://amodb.dev/ru/authentication/): Как передать ключ amodb: api_key в запросе, заголовок Authorization Bearer или apikey для вызовов OMDb. Формат ключа, хранение, ротация и ошибки 401.

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