Tutorial bot Discord de filme

Acest tutorial construiește un bot Discord cu discord.js v14 și o comandă slash /movie. Botul caută în amodb, încarcă cea mai bună potrivire cu evaluările ei și răspunde cu un embed cu poster, scoruri, durată și descriere. Amână răspunsul, reîncearcă la 429 și ține răspunsurile în cache. Durează cam 25 de minute.

.js Discord Actualizat Vezi ca Markdown

Ce construiești

Construiești un bot Discord cu o singură comandă slash, /movie title. Răspunde cu un embed care arată posterul, anul, scorurile IMDb și Kinopoisk, durata, genurile și o descriere scurtă. Ai nevoie de Node.js 20.6 sau mai nou, discord.js v14, o aplicație Discord cu un token de bot și o cheie amodb. Socotește cam 25 de minute.

Pasul 1: obține credențialele

Alege un plan la prețuri. Ritmul este singura diferență între planuri, iar cel mai mic permite 20 de cereri pe minut. Creează o cheie în panoul contului tău.

În portalul Discord pentru dezvoltatori creează o aplicație, adaugă-i un bot și copiază token-ul botului. Deschide pagina OAuth2, folosește generatorul de URL-uri, bifează scope-urile bot și applications.commands și deschide linkul ca să adaugi botul pe un server de test. Copiază și ID-ul serverului. Pune valorile într-un fișier .env și ține-l în afara Git:

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

Pasul 2: creează proiectul

Setarea type activează sintaxa import. Vezi autentificare pentru formele cheii în antet și în interogare.

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

Pasul 3: înregistrează comanda slash

SlashCommandBuilder descrie comanda și opțiunea ei obligatorie title. Botul o trimite la Discord imediat ce este gata. Cu DISCORD_GUILD_ID setat, comanda apare imediat pe acel server. Lasă variabila nesetată ca să o înregistrezi peste tot.

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

Pasul 4: construiește embed-ul

Apelul de detalii adaugă append_to_response=ratings, deci scorurile sosesc odată cu filmul. Fiecare sursă este un obiect cu value și votes, sau null când scorul nu se cunoaște. Embed-ul afișează value și spune „not known” pentru null. Posterul este https://img.amodb.dev/t/p/w342 plus poster_path; vezi imagini pentru alte dimensiuni. O descriere de embed poate avea 4096 de caractere, iar codul păstrează 400. Adaugă EmbedBuilder la linia de import și definește constanta POSTER, ca în listingul final.

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

Pasul 5: tratează erorile și limitele de cereri

Apelează mai întâi deferReply(). Discord vrea un răspuns în 3 secunde, iar o căutare plus un apel de detalii, sau o așteptare după un 429, pot dura mai mult. După amânare ai 15 minute să apelezi editReply().

CazCauzăReacția botului
401Cheie lipsă, greșită sau suspendatăO înregistrează în log, anunță canalul, iese. Orice comandă ar eșua.
429Ai depășit cererile pe minutAșteaptă Retry-After secunde, cu plafon de 10, reîncearcă de două ori, apoi cere utilizatorului să încerce mai târziu.
results golNiciun titlu nu se potriveșteRăspunde că nu s-a găsit niciun film.
Timeout după 8 secundeRețea lentăRăspunde că serviciul nu a răspuns.

Bucla de reîncercare din listingul final citește Retry-After din antetele răspunsului. Vezi erori și limite de cereri pentru fiecare cod.

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

Pasul 6: păstrează răspunsurile în cache

Verificarea limitei de cereri rulează înaintea cache-ului la margine, deci doar un cache în botul tău economisește cereri pe minut. Acest bot ține o mică clasă TtlCache: 10 minute pentru rezultatele căutării și 6 ore pentru detaliile filmelor, aceleași durate ca la margine. Clasa este în listingul final. Două persoane care cer același titlu în 10 minute costă atunci două cereri în total, nu patru.

Pasul 7: botul complet

Salvează asta ca 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);

Pornește botul cu fișierul .env, apoi scrie /movie pe serverul de test și introdu un titlu.

Pentru /movie fight club, prima potrivire din căutare este filmul 550, iar embed-ul conține titlul Fight Club (1999), descrierea tăiată la 400 de caractere și miniatura https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg, plus aceste câmpuri:

CâmpValoare
IMDb8.8
Kinopoisk8.7
Durată139 min
GenuriDrama, Thriller

Prima căutare a unui titlu face două apeluri, iar căutările următoare din durata cache-ului nu fac niciunul. Un 401 oprește procesul după un mesaj, iar un timeout produce răspunsul generic.

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

Pașii următori

  • Adaugă completare automată pentru opțiunea title cu endpoint-ul de căutare, care are un cache la margine de 10 minute.
  • Adaugă o comandă /trending folosind tendințe și liste.
  • Adaugă un buton de trailer cu endpoint-ul videoclipuri și trailere.
  • Construiește aceeași comandă pentru Telegram.