Tutorial bot Discord per film

Questo tutorial costruisce un bot Discord con discord.js v14 e un comando slash /movie. Il bot cerca su amodb, carica il risultato migliore con le sue valutazioni e risponde con un embed che contiene locandina, punteggi, durata e trama. Rimanda la risposta, ritenta su 429 e mette in cache le risposte. Servono circa 25 minuti.

.js Discord Aggiornato il Vedi in Markdown

Cosa costruisci

Costruisci un bot Discord con un comando slash, /movie title. Risponde con un embed che mostra locandina, anno, punteggi IMDb e Kinopoisk, durata, generi e una breve trama. Ti servono Node.js 20.6 o successivo, discord.js v14, un'applicazione Discord con un token del bot e una chiave amodb. Calcola circa 25 minuti.

Passaggio 1: ottieni le credenziali

Scegli un piano nella pagina dei prezzi. La frequenza è l'unica differenza tra i piani, e il più basso consente 20 richieste al minuto. Crea una chiave nella dashboard del tuo account.

Nel portale sviluppatori di Discord crea un'applicazione, aggiungile un bot e copia il token del bot. Apri la pagina OAuth2, usa il generatore di URL, spunta gli scope bot e applications.commands e apri il link per aggiungere il bot a un server di prova. Copia anche l'ID del server. Metti i valori in un file .env e tienilo fuori da Git:

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

Passaggio 2: crea il progetto

L'impostazione type attiva la sintassi import. Vedi autenticazione per le forme della chiave come header e come parametro di query.

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

Passaggio 3: registra il comando slash

SlashCommandBuilder descrive il comando e la sua opzione obbligatoria title. Il bot lo invia a Discord quando è pronto. Con DISCORD_GUILD_ID impostato, il comando compare subito in quel server. Lascia la variabile non impostata per registrarlo ovunque.

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

Passaggio 4: costruisci l'embed

La chiamata dei dettagli aggiunge append_to_response=ratings, così i punteggi arrivano insieme al film. Ogni fonte è un oggetto con value e votes, oppure null quando il punteggio non è noto. L'embed stampa value e indica «non noto» per null. La locandina è https://img.amodb.dev/t/p/w342 più poster_path; vedi immagini per le altre dimensioni. La descrizione di un embed può contenere 4096 caratteri, e il codice ne tiene 400. Aggiungi EmbedBuilder alla riga di import e definisci la costante POSTER, come fa il listato finale.

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

Passaggio 5: gestisci errori e limiti di richieste

Chiama prima deferReply(). Discord vuole una risposta entro 3 secondi, e una ricerca più una chiamata di dettagli, o un'attesa dopo un 429, può richiedere più tempo. Dopo il rinvio hai 15 minuti per chiamare editReply().

CasoCausaReazione del bot
401Chiave mancante, sbagliata o sospesaLa registra nel log, avvisa il canale, esce. Ogni comando fallirebbe.
429Oltre le tue richieste al minutoAttende i secondi di Retry-After, con un massimo di 10, ritenta due volte, poi chiede all'utente di riprovare più tardi.
results vuotoNessun titolo corrispondeRisponde che non è stato trovato alcun film.
Timeout dopo 8 secondiRete lentaRisponde che il servizio non ha risposto.

Il ciclo di nuovi tentativi del listato finale legge Retry-After dagli header della risposta. Vedi errori e limiti di richieste per tutti i codici.

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

Passaggio 6: metti le risposte in cache

Il controllo del limite di richieste viene prima della cache edge, quindi solo una cache nel tuo bot fa risparmiare richieste al minuto. Questo bot tiene una piccola classe TtlCache: 10 minuti per i risultati di ricerca e 6 ore per i dettagli dei film, gli stessi tempi dell'edge. La classe è nel listato finale. Due persone che chiedono lo stesso titolo entro 10 minuti costano quindi due richieste in tutto, non quattro.

Passaggio 7: il bot completo

Salvalo come 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);

Avvia il bot con il file .env, poi scrivi /movie nel server di prova e inserisci un titolo.

Per /movie fight club il primo risultato della ricerca è il film 550, e l'embed contiene il titolo Fight Club (1999), la trama tagliata a 400 caratteri e la miniatura https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg, più questi campi:

CampoValore
IMDb8.8
Kinopoisk8.7
Runtime139 min
GenresDrama, Thriller

La prima ricerca di un titolo fa due chiamate, e le ricerche successive entro la durata della cache non ne fanno nessuna. Un 401 ferma il processo dopo un messaggio, e un timeout produce la risposta generica.

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

Prossimi passi

  • Aggiungi l'autocompletamento per l'opzione title con l'endpoint di ricerca, che ha una cache edge di 10 minuti.
  • Aggiungi un comando /trending con tendenze e liste.
  • Aggiungi un pulsante per il trailer con l'endpoint video e trailer.
  • Costruisci lo stesso comando per Telegram.