# Poradnik bota Discord z filmami

> Ten poradnik buduje bota Discord w discord.js v14 z komendą /movie. Bot wyszukuje film w amodb, pobiera najlepsze dopasowanie wraz z ocenami i odpowiada embedem z plakatem, wynikami, czasem trwania i opisem. Odracza odpowiedź, ponawia żądanie po 429 i zapisuje odpowiedzi w cache. Zajmuje około 25 minut.

## Co zbudujesz

Zbudujesz bota Discord z jedną komendą ukośnikową, `/movie title`. Odpowiada embedem, który pokazuje plakat, rok, wyniki IMDb i Kinopoisk, czas trwania, gatunki i krótki opis. Potrzebujesz Node.js 20.6 lub nowszego, discord.js v14, aplikacji Discord z tokenem bota i klucza amodb. Zaplanuj około 25 minut.

## Krok 1: Zdobądź dane dostępowe

Wybierz plan na stronie [cennik](https://amodb.dev/pl/pricing/). Jedyną różnicą między planami jest szybkość, a najniższy dopuszcza 20 żądań na minutę. Utwórz klucz w panelu konta.

W portalu deweloperskim Discord utwórz aplikację, dodaj do niej bota i skopiuj token bota. Otwórz stronę OAuth2, użyj generatora URL, zaznacz zakresy `bot` i `applications.commands` i otwórz link, by dodać bota na serwer testowy. Skopiuj też ID serwera. Wpisz wartości do pliku `.env` i trzymaj go poza Git:

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

## Krok 2: Utwórz projekt

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

Ustawienie `type` włącza składnię `import`. Formy klucza w nagłówku i w zapytaniu opisuje strona [uwierzytelnianie](https://amodb.dev/pl/authentication/).

## Krok 3: Zarejestruj komendę ukośnikową

`SlashCommandBuilder` opisuje komendę i jej wymaganą opcję `title`. Bot wysyła ją do Discorda, gdy jest gotowy. Gdy ustawisz `DISCORD_GUILD_ID`, komenda pojawia się na tym serwerze od razu. Zostaw zmienną nieustawioną, by zarejestrować ją wszędzie.

```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}`);
});
```

## Krok 4: Zbuduj embed

Wywołanie szczegółów dodaje `append_to_response=ratings`, więc wyniki przychodzą razem z filmem. Każde źródło to obiekt z `value` i `votes` albo `null`, gdy wynik nie jest znany. Embed wypisuje `value` i dla `null` pisze „not known”. Plakat to `https://img.amodb.dev/t/p/w342` plus `poster_path`, inne rozmiary zobacz w [obrazy](https://amodb.dev/pl/images-api/). Opis embeda może mieć 4096 znaków, a kod zostawia 400. Dodaj `EmbedBuilder` do linii z importem i zdefiniuj stałą `POSTER`, jak robi to końcowy listing.

```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;
}
```

## Krok 5: Obsłuż błędy i limity żądań

Najpierw wywołaj `deferReply()`. Discord chce odpowiedzi w 3 sekundy, a wyszukiwanie wraz z wywołaniem szczegółów lub czekanie po 429 może trwać dłużej. Po odroczeniu masz 15 minut na wywołanie `editReply()`.

| Przypadek              | Przyczyna                               | Reakcja bota                                                                                                   |
| ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 401                    | Brak klucza, jest błędny lub zawieszony | Zapisuje to w logu, informuje kanał i kończy. Każda komenda by zawiodła.                                       |
| 429                    | Przekroczono żądania na minutę          | Czeka `Retry-After` sekund, maksymalnie 10, ponawia dwa razy, a potem prosi użytkownika, by spróbował później. |
| Puste `results`        | Żaden tytuł nie pasuje                  | Odpowiada, że nie znaleziono filmu.                                                                            |
| Timeout po 8 sekundach | Wolna sieć                              | Odpowiada, że usługa nie odpowiedziała.                                                                        |

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

Pętla ponowień w końcowym listingu czyta `Retry-After` z nagłówków odpowiedzi. Wszystkie kody znajdziesz na stronach [błędy](https://amodb.dev/pl/errors/) i [limity żądań](https://amodb.dev/pl/rate-limits/).

## Krok 6: Zapisuj odpowiedzi w cache

Sprawdzenie limitu działa przed cache na edge, więc tylko cache w twoim bocie oszczędza żądania na minutę. Ten bot ma małą klasę `TtlCache`: 10 minut dla wyników wyszukiwania i 6 godzin dla szczegółów filmu, czyli te same czasy co edge. Klasa jest w końcowym listingu. Dwie osoby pytające o ten sam tytuł w ciągu 10 minut kosztują wtedy łącznie dwa żądania, nie cztery.

## Krok 7: Kompletny bot

Zapisz to jako `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);
```

Uruchom bota z plikiem `.env`, potem wpisz `/movie` na serwerze testowym i podaj tytuł.

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

Dla `/movie fight club` pierwszym trafieniem jest film 550, a embed zawiera tytuł `Fight Club (1999)`, opis przycięty do 400 znaków i miniaturę `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg` oraz te pola:

| Pole      | Wartość         |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

Pierwsze wyszukanie tytułu wykonuje dwa wywołania, a kolejne w czasie życia cache żadnego. 401 zatrzymuje proces po komunikacie, a timeout daje ogólną odpowiedź.

## Kolejne kroki

- Dodaj autouzupełnianie dla opcji `title` przez endpoint [wyszukiwanie](https://amodb.dev/pl/search/), który ma 10-minutowy cache na edge.
- Dodaj komendę `/trending` ze strony [trendy i listy](https://amodb.dev/pl/trending-lists/).
- Dodaj przycisk zwiastuna przez endpoint [wideo i zwiastuny](https://amodb.dev/pl/videos-trailers/).
- Zbuduj tę samą komendę dla [Telegram](https://amodb.dev/pl/telegram-movie-bot/).

## Pytania i odpowiedzi

### Dlaczego bot odracza odpowiedź?

Discord oczekuje odpowiedzi na komendę ukośnikową w ciągu 3 sekund. Wyszukiwanie wraz z wywołaniem szczegółów lub czekanie po 429 może trwać dłużej. deferReply() wydłuża ten czas do 15 minut, a editReply() wysyła potem embed.

### Jakich intents potrzebuje bot?

Tylko Guilds. Komendy ukośnikowe przychodzą jako interakcje, więc bot nigdy nie czyta treści wiadomości i nie potrzebuje uprzywilejowanego intentu. Dodawaj kolejne intents tylko wtedy, gdy wymaga ich budowana funkcja.

### Jak przechowywać token Discorda i klucz amodb?

W zmiennych środowiskowych, na przykład w pliku .env wczytywanym opcją env-file w Node.js i trzymanym poza Git. Jeśli token wycieknie, zresetuj go w portalu deweloperskim. Dla amodb utwórz nowy klucz w panelu konta.

### Czy komenda może pokazać więcej niż jeden wynik?

Tak. Zbuduj jeden embed dla każdej z pierwszych trzech pozycji w results, bo wiadomość może zawierać do 10 embedów. Każde dodatkowe wywołanie szczegółów kosztuje jedno żądanie, więc pilnuj swoich żądań na minutę.

### Czy plakat w embedzie wymaga klucza API?

Nie. URL miniatury wskazuje na img.amodb.dev, który serwuje obrazy bez klucza, a Discord pobiera go, gdy wiadomość się pojawia. Klucza używa tylko twój bot, do wywołań JSON.

## Powiązane strony

- [Poradnik bota Telegram z filmami](https://amodb.dev/pl/telegram-movie-bot/): Zbuduj bota Telegram w grammY i Node.js, który na /movie odpowiada plakatem, rokiem i oceną z amodb, z ponawianiem po 429 i małym cache w pamięci.
- [Poradnik API filmów w JavaScript](https://amodb.dev/pl/javascript-movie-api/): Napisz skrypt Node.js, który jednym żądaniem na ID pobiera szczegóły, obsadę i oceny filmów, z cache z TTL, ponawianiem po 429 i bez zależności.
- [API wyszukiwania](https://amodb.dev/pl/search/): API wyszukiwania z tymi samymi ścieżkami i JSON co TMDB v3. Znajdź filmy, seriale, osoby, kolekcje, firmy i słowa kluczowe po tekście, z filtrem roku.
- [Oceny filmów i seriali](https://amodb.dev/pl/ratings/): API ocen filmów zwraca wyniki z IMDb, Kinopoisk, Metacritic, Rotten Tomatoes i TMDB dla tytułu jednym wywołaniem, z null dla każdego nieznanego źródła.
- [Limity żądań](https://amodb.dev/pl/rate-limits/): amodb limituje klucz liczbą żądań na minutę w przesuwanym oknie 60 sekund: 20, 40 lub 120 zależnie od planu. Czytaj nagłówki limitu i obsłuż 429 z Retry-After.
- [Uwierzytelnianie](https://amodb.dev/pl/authentication/): Jak wysłać klucz amodb: api_key w zapytaniu, nagłówek Authorization Bearer albo apikey dla wywołań OMDb. Format klucza, przechowywanie, rotacja i błędy 401.

Zaktualizowano: 2026-10-18. Source: https://amodb.dev/pl/discord-movie-bot/
