Урок за Discord бот за филми
Този урок изгражда Discord бот с discord.js v14 и slash команда /movie. Ботът търси в amodb, зарежда най-доброто съвпадение с рейтингите му и отговаря с embed, съдържащ постер, оценки, продължителност и описание. Отлага отговора, повтаря при 429 и кешира отговорите. Отнема около 25 минути.
Какво изграждате
Изграждате Discord бот с една slash команда, /movie title. Тя отговаря с embed, който показва постер, година, оценки от IMDb и Kinopoisk, продължителност, жанрове и кратко описание. Нужни са ви Node.js 20.6 или по-нов, discord.js v14, приложение в Discord с токен на бот и ключ за amodb. Отделете около 25 минути.
Стъпка 1: Вземете данните за достъп
Изберете план на страницата с цените. Единствената разлика между плановете е скоростта, а най-ниският позволява 20 заявки в минута. Създайте ключ в личния си профил.
В портала за разработчици на Discord създайте приложение, добавете към него бот и копирайте токена на бота. Отворете страницата OAuth2, ползвайте генератора на URL, отметнете обхватите bot и applications.commands и отворете връзката, за да добавите бота към тестов сървър. Копирайте и id на сървъра. Сложете стойностите във файл .env и го дръжте извън Git:
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEYСтъпка 2: Създайте проекта
Настройката type включва синтаксиса import. Вижте удостоверяване за формите на ключа в заглавка и в заявка.
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.jsСтъпка 3: Регистрирайте slash командата
SlashCommandBuilder описва командата и задължителната ѝ опция title. Ботът я изпраща на Discord, щом е готов. Със зададена DISCORD_GUILD_ID командата се появява веднага на този сървър. Оставете променливата незададена, за да я регистрирате навсякъде.
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}`);
});Стъпка 4: Построете embed
Извикването за детайли добавя append_, така че оценките идват с филма. Всеки източник е обект с value и votes или null, когато оценката не е известна. Embed отпечатва value и пише „not known“ за null. Постерът е https:/ плюс poster_path; за други размери вижте изображения. Описанието на embed може да съдържа 4096 символа, а кодът оставя 400. Добавете EmbedBuilder към реда за импорт и дефинирайте константата POSTER, както е в крайния листинг.
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;
}Стъпка 5: Обработка на грешки и лимити
Първо извикайте deferReply(). Discord иска отговор в рамките на 3 секунди, а търсене плюс извикване за детайли или изчакване след 429 може да отнеме повече. След отлагането имате 15 минути да извикате editReply().
| Случай | Причина | Реакция на бота |
|---|---|---|
| 401 | Ключът липсва, е грешен или е спрян | Записва в лога, съобщава на канала, излиза. Всяка команда би се провалила. |
| 429 | Надхвърлени заявки в минута | Чака Retry-After секунди, най-много 10, повтаря два пъти, после моли потребителя да опита по-късно. |
Празен results | Никое заглавие не съвпада | Отговаря, че не е намерен филм. |
| Изтекло време след 8 секунди | Бавна мрежа | Отговаря, че услугата не е отговорила. |
Цикълът за повторения в крайния листинг чете Retry-After от заглавките на отговора. Вижте грешки и лимити на заявките за всички кодове.
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.';
}Стъпка 6: Кеширайте отговорите
Проверката на лимита се изпълнява преди edge кеша, така че само кеш във вашия бот спестява заявки в минута. Този бот държи малък клас TtlCache: 10 минути за резултати от търсене и 6 часа за детайли на филм, същите срокове като на edge. Класът е в крайния листинг. Двама души, които искат същото заглавие в рамките на 10 минути, струват общо две заявки, а не четири.
Стъпка 7: Цял бот
Запазете това като 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);Стартирайте бота с файла .env, после напишете /movie на тестовия сървър и въведете заглавие.
За /movie fight club първото съвпадение от търсенето е филм 550, а embed съдържа заглавието Fight Club (1999), описанието, отрязано до 400 символа, и миниатюрата https:/, плюс тези полета:
| Поле | Стойност |
|---|---|
| IMDb | 8.8 |
| Kinopoisk | 8.7 |
| Продължителност | 139 мин |
| Жанрове | Drama, Thriller |
Първото търсене на заглавие прави две извиквания, а следващите в рамките на живота на кеша не правят нито едно. 401 спира процеса след съобщение, а изтеклото време води до общ отговор.
node --env-file=.env bot.jsСледващи стъпки
- Добавете автоматично допълване за опцията
titleс ендпойнта за търсене, който има 10-минутен edge кеш. - Добавете команда
/trendingс тенденции и списъци. - Добавете бутон за трейлър с ендпойнта видеа и трейлъри.
- Направете същата команда за Telegram.