# Návod na Discord bota pro filmy

> Tento návod vytvoří Discord bota s discord.js v14 a slash příkazem /movie. Bot vyhledá film v amodb, načte nejlepší shodu s hodnoceními a odpoví embedem s plakátem, skóre, délkou a popisem. Odloží odpověď, při 429 opakuje pokus a cachuje odpovědi. Zabere asi 25 minut.

## Co vytvoříte

Vytvoříte Discord bota s jedním slash příkazem, `/movie title`. Odpovídá embedem, který ukazuje plakát, rok, skóre IMDb a Kinopoisk, délku, žánry a krátký popis. Potřebujete Node.js 20.6 nebo novější, discord.js v14, aplikaci Discord s tokenem bota a klíč amodb. Počítejte s asi 25 minutami.

## Krok 1: Získejte přihlašovací údaje

Zvolte tarif na stránce [ceník](https://amodb.dev/cs/pricing/). Tarify se liší jen rychlostí a nejnižší povoluje 20 požadavků za minutu. Vytvořte klíč v panelu účtu.

Ve vývojářském portálu Discordu vytvořte aplikaci, přidejte k ní bota a zkopírujte token bota. Otevřete stránku OAuth2, použijte generátor URL, zaškrtněte rozsahy `bot` a `applications.commands` a otevřete odkaz, kterým bota přidáte na testovací server. Zkopírujte také ID serveru. Hodnoty vložte do souboru `.env` a nedávejte ho do Gitu:

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

## Krok 2: Vytvořte projekt

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

Nastavení `type` zapíná syntaxi `import`. Podobu klíče v hlavičce a v parametru dotazu popisuje stránka [autentizace](https://amodb.dev/cs/authentication/).

## Krok 3: Zaregistrujte slash příkaz

`SlashCommandBuilder` popisuje příkaz a jeho povinnou volbu `title`. Bot ho odešle Discordu, jakmile je připraven. Pokud je nastaveno `DISCORD_GUILD_ID`, příkaz se na tom serveru objeví hned. Nechte proměnnou nenastavenou, pokud ho chcete registrovat všude.

```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: Sestavte embed

Volání detailů přidává `append_to_response=ratings`, takže skóre přicházejí spolu s filmem. Každý zdroj je objekt s `value` a `votes`, nebo `null`, když skóre není známé. Embed vypisuje `value` a pro `null` píše „not known“. Plakát je `https://img.amodb.dev/t/p/w342` plus `poster_path`, ostatní velikosti viz [obrázky](https://amodb.dev/cs/images-api/). Popis embedu může mít 4096 znaků a kód jich ponechá 400. Přidejte `EmbedBuilder` do řádku s importem a definujte konstantu `POSTER`, jak to dělá závěrečný výpis.

```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: Ošetřete chyby a limity požadavků

Nejdřív zavolejte `deferReply()`. Discord chce odpověď do 3 sekund a hledání spolu s voláním detailů, nebo čekání po 429, může trvat déle. Po odložení máte 15 minut na zavolání `editReply()`.

| Případ                 | Příčina                                   | Reakce bota                                                                                                |
| ---------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 401                    | Klíč chybí, je nesprávný nebo pozastavený | Zapíše to do logu, oznámí to kanálu a skončí. Selhal by každý příkaz.                                      |
| 429                    | Překročili jste požadavky za minutu       | Počká `Retry-After` sekund, nejvýše 10, dvakrát zopakuje pokus a pak uživateli doporučí zkusit to později. |
| Prázdné `results`      | Žádný název neodpovídá                    | Odpoví, že žádný film nebyl nalezen.                                                                       |
| Timeout po 8 sekundách | Pomalá síť                                | Odpoví, že služba neodpověděla.                                                                            |

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

Smyčka opakování v závěrečném výpisu čte `Retry-After` z hlaviček odpovědi. Všechny kódy najdete na stránkách [chyby](https://amodb.dev/cs/errors/) a [limity požadavků](https://amodb.dev/cs/rate-limits/).

## Krok 6: Cachujte odpovědi

Kontrola limitu proběhne před cache na edge, takže požadavky za minutu ušetří jen cache ve vašem botovi. Tento bot používá malou třídu `TtlCache`: 10 minut pro výsledky hledání a 6 hodin pro detaily filmů, tedy stejné doby jako edge. Třída je v závěrečném výpisu. Dva lidé, kteří se do 10 minut zeptají na stejný název, pak stojí dohromady dva požadavky, ne čtyři.

## Krok 7: Kompletní bot

Uložte 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);
```

Spusťte bota se souborem `.env`, pak na testovacím serveru napište `/movie` a zadejte název.

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

Pro `/movie fight club` je prvním nalezeným filmem film 550 a embed obsahuje titul `Fight Club (1999)`, popis zkrácený na 400 znaků a náhled `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg` a tato pole:

| Pole      | Hodnota         |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

První vyhledání titulu provede dvě volání a další vyhledání v době platnosti cache žádné. 401 zastaví proces po zprávě a timeout vyvolá obecnou odpověď.

## Další kroky

- Přidejte automatické doplňování pro volbu `title` pomocí endpointu [vyhledávání](https://amodb.dev/cs/search/), který má 10minutovou cache na edge.
- Přidejte příkaz `/trending` pomocí stránky [trendy a seznamy](https://amodb.dev/cs/trending-lists/).
- Přidejte tlačítko s trailerem pomocí endpointu [videa a trailery](https://amodb.dev/cs/videos-trailers/).
- Vytvořte stejný příkaz pro [Telegram](https://amodb.dev/cs/telegram-movie-bot/).

## Otázky a odpovědi

### Proč bot odkládá odpověď?

Discord očekává odpověď na slash příkaz do 3 sekund. Hledání spolu s voláním detailů nebo čekání po 429 může trvat déle. deferReply() to prodlouží na 15 minut a editReply() pak odešle embed.

### Které intents bot potřebuje?

Jen Guilds. Slash příkazy přicházejí jako interakce, bot proto nikdy nečte obsah zpráv a nepotřebuje žádný privilegovaný intent. Další intents přidejte jen tehdy, když je funkce, kterou stavíte, vyžaduje.

### Jak uložit token Discordu a klíč amodb?

V proměnných prostředí, například v souboru .env načteném volbou env-file v Node.js a vyloučeném z Gitu. Pokud token unikne, resetujte ho ve vývojářském portálu. U amodb vytvořte nový klíč v panelu účtu.

### Může příkaz ukázat více než jeden výsledek?

Ano. Sestavte jeden embed pro každou z prvních tří položek v results, protože zpráva může obsahovat až 10 embedů. Každé další volání detailů stojí jeden požadavek, sledujte proto své požadavky za minutu.

### Potřebuje plakát v embedu API klíč?

Ne. URL náhledu míří na img.amodb.dev, který obrázky poskytuje bez klíče, a Discord ho načte, když se zpráva objeví. Klíč používá jen váš bot, pro volání JSON.

## Související stránky

- [Návod na Telegram bota pro filmy](https://amodb.dev/cs/telegram-movie-bot/): Vytvořte Telegram bota v grammY a Node.js, který na /movie odpoví plakátem, rokem a hodnocením z amodb, s opakováním při 429 a malou cache v paměti.
- [Návod na filmové API v JavaScriptu](https://amodb.dev/cs/javascript-movie-api/): Napište skript v Node.js, který jedním požadavkem na ID načte detaily, obsazení a hodnocení filmů, s cache s TTL, opakováním při 429 a bez závislostí.
- [API vyhledávání](https://amodb.dev/cs/search/): API vyhledávání se stejnými cestami a JSON jako TMDB v3. Najděte filmy, seriály, osoby, kolekce, společnosti a klíčová slova podle textu s filtrem roku.
- [Hodnocení filmů a seriálů](https://amodb.dev/cs/ratings/): API hodnocení filmů, které vrací skóre z IMDb, Kinopoisk, Metacritic, Rotten Tomatoes a TMDB pro titul jedním voláním, s null u každého neznámého zdroje.
- [Limity požadavků](https://amodb.dev/cs/rate-limits/): amodb omezuje klíč počtem požadavků za minutu v klouzavém okně 60 sekund: 20, 40 nebo 120 podle tarifu. Čtěte hlavičky limitu a řešte 429 pomocí Retry-After.
- [Autentizace](https://amodb.dev/cs/authentication/): Jak poslat klíč amodb: api_key v dotazu, hlavička Authorization Bearer nebo apikey pro volání OMDb. Formát klíče, uložení, rotace a chyby 401.

Aktualizováno: 2026-10-18. Source: https://amodb.dev/cs/discord-movie-bot/
