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.

.js Discord Aktualizováno Zobrazit jako Markdown

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. 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:

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

Krok 2: Vytvořte projekt

Nastavení type zapíná syntaxi import. Podobu klíče v hlavičce a v parametru dotazu popisuje stránka autentizace.

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

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.

JavaScript
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. 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.

JavaScript
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řípadPříčinaReakce bota
401Klíč chybí, je nesprávný nebo pozastavenýZapíše to do logu, oznámí to kanálu a skončí. Selhal by každý příkaz.
429Překročili jste požadavky za minutuPoč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áchPomalá síťOdpoví, že služba neodpověděla.

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 a limity požadavků.

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

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.

JavaScript
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.

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:

PoleHodnota
IMDb8.8
Kinopoisk8.7
Runtime139 min
GenresDrama, 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ěď.

Shell
node --env-file=.env bot.js

Další kroky

  • Přidejte automatické doplňování pro volbu title pomocí endpointu vyhledávání, který má 10minutovou cache na edge.
  • Přidejte příkaz /trending pomocí stránky trendy a seznamy.
  • Přidejte tlačítko s trailerem pomocí endpointu videa a trailery.
  • Vytvořte stejný příkaz pro Telegram.