# Tutoriel bot Discord de films

> Ce tutoriel construit un bot Discord avec discord.js v14 et une commande slash /movie. Le bot cherche sur amodb, charge le meilleur résultat avec ses notes et répond par un embed contenant l'affiche, les notes, la durée et le résumé. Il diffère la réponse, réessaie sur 429 et met les réponses en cache. Il faut environ 25 minutes.

## Ce que vous construisez

Vous construisez un bot Discord avec une commande slash, `/movie title`. Il répond par un embed qui montre l'affiche, l'année, les notes IMDb et Kinopoisk, la durée, les genres et un court résumé. Il vous faut Node.js 20.6 ou plus récent, discord.js v14, une application Discord avec un jeton de bot et une clé amodb. Prévoyez environ 25 minutes.

## Étape 1 : obtenir vos identifiants

Choisissez une offre sur les [tarifs](https://amodb.dev/fr/pricing/). Le débit est la seule différence entre les offres, et la plus basse autorise 20 requêtes par minute. Créez une clé dans le tableau de bord de votre compte.

Dans le portail des développeurs Discord, créez une application, ajoutez-lui un bot et copiez le jeton du bot. Ouvrez la page OAuth2, utilisez le générateur d'URL, cochez les portées `bot` et `applications.commands` et ouvrez le lien pour ajouter le bot à un serveur de test. Copiez aussi l'identifiant du serveur. Placez les valeurs dans un fichier `.env` et gardez-le hors de Git :

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

## Étape 2 : créer le projet

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

Le réglage `type` active la syntaxe `import`. Voir [authentification](https://amodb.dev/fr/authentication/) pour les formes en en-tête et en paramètre de requête de la clé.

## Étape 3 : enregistrer la commande slash

`SlashCommandBuilder` décrit la commande et son option obligatoire `title`. Le bot l'envoie à Discord quand il est prêt. Avec `DISCORD_GUILD_ID` défini, la commande apparaît tout de suite dans ce serveur. Laissez la variable non définie pour l'enregistrer partout.

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

## Étape 4 : construire l'embed

L'appel de détails ajoute `append_to_response=ratings`, si bien que les notes arrivent avec le film. Chaque source est un objet avec `value` et `votes`, ou `null` quand la note n'est pas connue. L'embed affiche `value` et indique « inconnue » pour `null`. L'affiche est `https://img.amodb.dev/t/p/w342` plus `poster_path` ; voir [images](https://amodb.dev/fr/images-api/) pour les autres tailles. La description d'un embed peut contenir 4096 caractères, et le code en garde 400. Ajoutez `EmbedBuilder` à la ligne d'import et définissez la constante `POSTER`, comme le fait le listing 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;
}
```

## Étape 5 : gérer les erreurs et les limites de débit

Appelez d'abord `deferReply()`. Discord veut une réponse dans les 3 secondes, et une recherche plus un appel de détails, ou une attente après un 429, peut prendre plus de temps. Après un report, vous avez 15 minutes pour appeler `editReply()`.

| Cas                            | Cause                                | Réaction du bot                                                                                                              |
| ------------------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| 401                            | Clé absente, fausse ou suspendue     | La consigner, prévenir le salon, quitter. Chaque commande échouerait.                                                        |
| 429                            | Au-dessus de vos requêtes par minute | Attendre `Retry-After` secondes, plafonnées à 10, réessayer deux fois, puis demander à l'utilisateur de réessayer plus tard. |
| `results` vide                 | Aucun titre ne correspond            | Répondre qu'aucun film n'a été trouvé.                                                                                       |
| Délai dépassé après 8 secondes | Réseau lent                          | Répondre que le service n'a pas répondu.                                                                                     |

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

La boucle de nouvelle tentative du listing final lit `Retry-After` dans les en-têtes de la réponse. Voir [erreurs](https://amodb.dev/fr/errors/) et [limites de débit](https://amodb.dev/fr/rate-limits/) pour tous les codes.

## Étape 6 : mettre les réponses en cache

La vérification de la limite de débit passe avant le cache de périphérie : seul un cache dans votre bot économise des requêtes par minute. Ce bot garde une petite classe `TtlCache` : 10 minutes pour les résultats de recherche et 6 heures pour les détails de films, les mêmes durées que la périphérie. La classe se trouve dans le listing final. Deux personnes qui demandent le même titre en 10 minutes coûtent alors deux requêtes au total, et non quatre.

## Étape 7 : le bot complet

Enregistrez ceci sous `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);
```

Démarrez le bot avec le fichier `.env`, puis tapez `/movie` dans le serveur de test et saisissez un titre.

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

Pour `/movie fight club`, le premier résultat de recherche est le film 550, et l'embed contient le titre `Fight Club (1999)`, le résumé coupé à 400 caractères et la miniature `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, plus ces champs :

| Champ     | Valeur          |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

La première recherche d'un titre fait deux appels, et les recherches suivantes dans la durée de vie du cache n'en font aucun. Une erreur 401 arrête le processus après un message, et un délai dépassé produit la réponse générique.

## Pour aller plus loin

- Ajoutez l'autocomplétion de l'option `title` avec le point de terminaison de [recherche](https://amodb.dev/fr/search/), qui a un cache de périphérie de 10 minutes.
- Ajoutez une commande `/trending` avec [tendances et listes](https://amodb.dev/fr/trending-lists/).
- Ajoutez un bouton de bande-annonce avec le point de terminaison [vidéos et bandes-annonces](https://amodb.dev/fr/videos-trailers/).
- Construisez la même commande pour [Telegram](https://amodb.dev/fr/telegram-movie-bot/).

## Questions et réponses

### Pourquoi le bot diffère-t-il la réponse ?

Discord attend une réponse à une commande slash dans les 3 secondes. Une recherche plus un appel de détails, ou une attente après un 429, peut prendre plus de temps. deferReply() porte ce délai à 15 minutes, et editReply() envoie ensuite l'embed.

### De quels intents le bot a-t-il besoin ?

Seulement Guilds. Les commandes slash arrivent sous forme d'interactions : le bot ne lit donc jamais le contenu des messages et n'a besoin d'aucun intent privilégié. N'ajoutez des intents que si une fonction que vous construisez les exige.

### Comment stocker le jeton Discord et la clé amodb ?

Dans des variables d'environnement, par exemple un fichier .env chargé avec l'option env-file de Node.js, tenu hors de Git. Si un jeton fuite, réinitialisez-le dans le portail des développeurs. Pour amodb, créez une nouvelle clé dans le tableau de bord de votre compte.

### La commande peut-elle montrer plus d'un résultat ?

Oui. Construisez un embed pour chacune des trois premières entrées de results, car un message peut contenir jusqu'à 10 embeds. Chaque appel de détails supplémentaire coûte une requête : surveillez donc vos requêtes par minute.

### L'affiche de l'embed exige-t-elle une clé d'API ?

Non. L'URL de la miniature pointe vers img.amodb.dev, qui sert les images sans clé, et Discord la récupère quand le message apparaît. Seul votre bot utilise la clé, pour les appels JSON.

## Pages liées

- [Tutoriel bot Telegram de films](https://amodb.dev/fr/telegram-movie-bot/): Créez un bot Telegram avec grammY et Node.js qui répond à /movie avec affiche, année et note d'amodb, avec relance sur 429 et un petit cache en mémoire.
- [Tutoriel API de films JavaScript](https://amodb.dev/fr/javascript-movie-api/): Un script Node.js qui charge détails, crédits et notes d'une liste d'ids de films en une requête chacun, avec cache TTL, relance 429 et sans dépendance.
- [API de recherche](https://amodb.dev/fr/search/): API de recherche aux chemins et au JSON de TMDB v3 : films, séries, personnes, collections, sociétés et mots-clés par texte, filtres d'année et pagination.
- [Notes des films et séries](https://amodb.dev/fr/ratings/): API de notes de films : scores IMDb, Kinopoisk, Metacritic, Rotten Tomatoes et TMDB d'un titre en un appel, avec null pour toute source inconnue.
- [Limites de débit](https://amodb.dev/fr/rate-limits/): amodb limite chaque clé en requêtes par minute sur une fenêtre glissante de 60 secondes : 20, 40 ou 120 selon l'offre. Lisez les en-têtes et gérez le 429.
- [Authentification](https://amodb.dev/fr/authentication/): Comment envoyer votre clé amodb : api_key dans la requête, en-tête Authorization Bearer, ou apikey pour OMDb. Format, stockage, rotation et erreurs 401.

Mis à jour: 2026-10-18. Source: https://amodb.dev/fr/discord-movie-bot/
