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

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

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

## Pasul 2: creează proiectul

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

Setarea `type` activează sintaxa `import`. Vezi [autentificare](https://amodb.dev/ro/authentication/) pentru formele cheii în antet și în interogare.

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

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

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

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

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

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

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

Bucla de reîncercare din listingul final citește `Retry-After` din antetele răspunsului. Vezi [erori](https://amodb.dev/ro/errors/) și [limite de cereri](https://amodb.dev/ro/rate-limits/) pentru fiecare cod.

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

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

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

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

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âmp      | Valoare         |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Durată    | 139 min         |
| Genuri    | Drama, 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.

## Pașii următori

- Adaugă completare automată pentru opțiunea `title` cu endpoint-ul de [căutare](https://amodb.dev/ro/search/), care are un cache la margine de 10 minute.
- Adaugă o comandă `/trending` folosind [tendințe și liste](https://amodb.dev/ro/trending-lists/).
- Adaugă un buton de trailer cu endpoint-ul [videoclipuri și trailere](https://amodb.dev/ro/videos-trailers/).
- Construiește aceeași comandă pentru [Telegram](https://amodb.dev/ro/telegram-movie-bot/).

## Întrebări și răspunsuri

### De ce amână botul răspunsul?

Discord așteaptă un răspuns la o comandă slash în 3 secunde. O căutare plus un apel de detalii, sau o așteptare după un 429, pot dura mai mult. deferReply() extinde asta la 15 minute, iar editReply() trimite apoi embed-ul.

### De ce intents are nevoie botul?

Doar Guilds. Comenzile slash sosesc ca interacțiuni, deci botul nu citește niciodată conținutul mesajelor și nu are nevoie de niciun intent privilegiat. Adaugă alte intents doar când o funcție pe care o construiești le cere.

### Cum ar trebui să stochez token-ul Discord și cheia amodb?

În variabile de mediu, de exemplu într-un fișier .env încărcat cu opțiunea env-file din Node.js, ținut în afara Git. Dacă un token se scurge, resetează-l în portalul pentru dezvoltatori. Pentru amodb, creează o cheie nouă în panoul contului tău.

### Poate comanda să arate mai mult de un rezultat?

Da. Construiește câte un embed pentru fiecare dintre primele trei intrări din results, pentru că un mesaj poate avea până la 10 embed-uri. Fiecare apel de detalii în plus costă o cerere, deci urmărește cererile pe minut.

### Are nevoie posterul din embed de o cheie API?

Nu. URL-ul miniaturii indică img.amodb.dev, care servește imagini fără cheie, iar Discord îl preia când apare mesajul. Doar botul tău folosește cheia, pentru apelurile JSON.

## Pagini conexe

- [Tutorial bot Telegram de filme](https://amodb.dev/ro/telegram-movie-bot/): Bot Telegram cu grammY și Node.js care răspunde la /movie cu poster, an și evaluare din amodb, cu reîncercare la 429 și un mic cache în memorie.
- [Tutorial API filme JavaScript](https://amodb.dev/ro/javascript-movie-api/): Scrie un script Node.js care încarcă detalii, credite și evaluări pentru o listă de filme, o cerere pe film, cu cache TTL, reîncercare la 429, fără dependențe.
- [API căutare](https://amodb.dev/ro/search/): API căutare cu căile și JSON-ul TMDB v3. Găsește filme, seriale, persoane, colecții, companii și cuvinte-cheie după text, cu filtre de an și paginare.
- [Evaluări pentru filme și TV](https://amodb.dev/ro/ratings/): API evaluări filme ce întoarce scorurile IMDb, Kinopoisk, Metacritic, Rotten Tomatoes și TMDB pentru un titlu într-un apel, cu null pentru sursele necunoscute.
- [Limite de cereri](https://amodb.dev/ro/rate-limits/): amodb limitează fiecare cheie în cereri pe minut, în fereastră glisantă de 60 de secunde: 20, 40 sau 120. Citește antetele și tratează 429 cu Retry-After.
- [Autentificare](https://amodb.dev/ro/authentication/): Cum trimiți cheia amodb: api_key în interogare, antetul Authorization Bearer sau apikey pentru apelurile OMDb. Format, stocare, rotire și erorile 401.

Actualizat: 2026-10-18. Source: https://amodb.dev/ro/discord-movie-bot/
