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

## 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](https://amodb.dev/it/pricing/). 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:

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

## Passaggio 2: crea il progetto

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

L'impostazione `type` attiva la sintassi `import`. Vedi [autenticazione](https://amodb.dev/it/authentication/) per le forme della chiave come header e come parametro di query.

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

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

## 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](https://amodb.dev/it/images-api/) 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.

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

## 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()`.

| Caso                   | Causa                                | Reazione del bot                                                                                                           |
| ---------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| 401                    | Chiave mancante, sbagliata o sospesa | La registra nel log, avvisa il canale, esce. Ogni comando fallirebbe.                                                      |
| 429                    | Oltre le tue richieste al minuto     | Attende i secondi di `Retry-After`, con un massimo di 10, ritenta due volte, poi chiede all'utente di riprovare più tardi. |
| `results` vuoto        | Nessun titolo corrisponde            | Risponde che non è stato trovato alcun film.                                                                               |
| Timeout dopo 8 secondi | Rete lenta                           | Risponde che il servizio non ha risposto.                                                                                  |

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

Il ciclo di nuovi tentativi del listato finale legge `Retry-After` dagli header della risposta. Vedi [errori](https://amodb.dev/it/errors/) e [limiti di richieste](https://amodb.dev/it/rate-limits/) per tutti i codici.

## 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`.

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

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

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

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:

| Campo     | Valore          |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, 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.

## Prossimi passi

- Aggiungi l'autocompletamento per l'opzione `title` con l'endpoint di [ricerca](https://amodb.dev/it/search/), che ha una cache edge di 10 minuti.
- Aggiungi un comando `/trending` con [tendenze e liste](https://amodb.dev/it/trending-lists/).
- Aggiungi un pulsante per il trailer con l'endpoint [video e trailer](https://amodb.dev/it/videos-trailers/).
- Costruisci lo stesso comando per [Telegram](https://amodb.dev/it/telegram-movie-bot/).

## Domande e risposte

### Perché il bot rimanda la risposta?

Discord si aspetta una risposta a un comando slash entro 3 secondi. Una ricerca più una chiamata di dettagli, o un'attesa dopo un 429, può richiedere più tempo. deferReply() porta questo limite a 15 minuti, e editReply() invia poi l'embed.

### Di quali intent ha bisogno il bot?

Solo Guilds. I comandi slash arrivano come interazioni, quindi il bot non legge mai il contenuto dei messaggi e non ha bisogno di alcun intent privilegiato. Aggiungi altri intent solo quando una funzione che costruisci li richiede.

### Come conservo il token di Discord e la chiave amodb?

In variabili d'ambiente, per esempio un file .env caricato con l'opzione env-file di Node.js, tenuto fuori da Git. Se un token trapela, reimpostalo nel portale sviluppatori. Per amodb, crea una nuova chiave nella dashboard del tuo account.

### Il comando può mostrare più di un risultato?

Sì. Costruisci un embed per ciascuna delle prime tre voci di results, perché un messaggio può contenere fino a 10 embed. Ogni chiamata di dettagli in più costa una richiesta, quindi tieni d'occhio le richieste al minuto.

### La locandina nell'embed richiede una chiave API?

No. L'URL della miniatura punta a img.amodb.dev, che serve le immagini senza chiave, e Discord lo recupera quando il messaggio compare. Solo il tuo bot usa la chiave, per le chiamate JSON.

## Pagine correlate

- [Tutorial bot Telegram per film](https://amodb.dev/it/telegram-movie-bot/): Crea un bot Telegram con grammY e Node.js che risponde a /movie con locandina, anno e valutazione da amodb, con retry su 429 e una piccola cache in memoria.
- [Tutorial API film JavaScript](https://amodb.dev/it/javascript-movie-api/): Uno script Node.js che carica dettagli, crediti e valutazioni di un elenco di ID film con una richiesta ciascuno, cache TTL, retry su 429 e zero dipendenze.
- [API di ricerca](https://amodb.dev/it/search/): API di ricerca con percorsi e JSON di TMDB v3. Trova film, serie, persone, collezioni, società e parole chiave per testo, con filtri anno e paginazione.
- [Valutazioni di film e serie TV](https://amodb.dev/it/ratings/): API valutazioni film: punteggi IMDb, Kinopoisk, Metacritic, Rotten Tomatoes e TMDB di un titolo in una chiamata, con null per le fonti non note.
- [Limiti di richieste](https://amodb.dev/it/rate-limits/): amodb limita ogni chiave per richieste al minuto in una finestra scorrevole di 60 secondi: 20, 40 o 120 in base al piano. Leggi gli header, gestisci il 429.
- [Autenticazione](https://amodb.dev/it/authentication/): Come inviare la chiave amodb: api_key nella query, un header Authorization Bearer o apikey per le chiamate OMDb. Formato, conservazione, rotazione, errori 401.

Aggiornato: 2026-10-18. Source: https://amodb.dev/it/discord-movie-bot/
