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. 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:
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEYPasul 2: creează proiectul
Setarea type activează sintaxa import. Vezi autentificare pentru formele cheii în antet și în interogare.
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.jsPasul 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.
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_, 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:/ plus poster_path; vezi imagini 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.
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. |
Bucla de reîncercare din listingul final citește Retry-After din antetele răspunsului. Vezi erori și limite de cereri pentru fiecare cod.
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.';
}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.
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.
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:/, 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.
node --env-file=.env bot.jsPașii următori
- Adaugă completare automată pentru opțiunea
titlecu endpoint-ul de căutare, care are un cache la margine de 10 minute. - Adaugă o comandă
/trendingfolosind tendințe și liste. - Adaugă un buton de trailer cu endpoint-ul videoclipuri și trailere.
- Construiește aceeași comandă pentru Telegram.