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. 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 :
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEYÉtape 2 : créer le projet
Le réglage type active la syntaxe import. Voir authentification pour les formes en en-tête et en paramètre de requête de la clé.
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.jsÉ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.
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_, 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:/ plus poster_path ; voir images 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.
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. |
La boucle de nouvelle tentative du listing final lit Retry-After dans les en-têtes de la réponse. Voir erreurs et limites de débit pour tous les codes.
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.';
}É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.
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.
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:/, 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.
node --env-file=.env bot.jsPour aller plus loin
- Ajoutez l'autocomplétion de l'option
titleavec le point de terminaison de recherche, qui a un cache de périphérie de 10 minutes. - Ajoutez une commande
/trendingavec tendances et listes. - Ajoutez un bouton de bande-annonce avec le point de terminaison vidéos et bandes-annonces.
- Construisez la même commande pour Telegram.