Discord 电影机器人教程
本教程用 discord.js v14 构建带 /movie 斜杠命令的 Discord 机器人。机器人搜索 amodb,加载最佳匹配及其评分,并用包含海报、评分、时长和简介的嵌入消息回复。它延迟回复、在 429 时重试并缓存答案,约 25 分钟。
你将构建什么
你将构建一个带有一个斜杠命令 /movie title 的 Discord 机器人。它回复一条嵌入消息,显示海报、年份、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_KEYmkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.js步骤 3:注册斜杠命令
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:构建嵌入消息
详情调用加上 append_,所以评分随电影一起到达。每个来源是包含 value 和 votes 的对象,评分未知时为 null。嵌入消息打印 value,对 null 则写“未知”。海报是 https:/ 加上 poster_path;其他尺寸见图片。嵌入消息的描述最多 4096 个字符,代码保留 400 个。请像最终代码清单那样,把 EmbedBuilder 加进 import 行并定义 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 秒后超时 | 网络缓慢 | 回复服务没有响应。 |
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:缓存答案
速率限制检查先于边缘缓存执行,所以只有机器人自己的缓存才能节省每分钟请求数。这个机器人使用一个小型 TtlCache 类:搜索结果 10 分钟,电影详情 6 小时,与边缘缓存的有效期相同。该类在最终代码清单中。这样,两个人在 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,嵌入消息包含标题 Fight Club (1999)、截断到 400 个字符的简介和缩略图 https:/,以及这些字段:
| 字段 | 值 |
|---|---|
| IMDb | 8.8 |
| Kinopoisk | 8.7 |
| Runtime | 139 min |
| Genres | Drama, Thriller |
第一次查询某个片名会发起两次调用,在缓存有效期内再次查询则一次也不发起。401 会在发出消息后让进程停止,超时则产生通用的回复。
node --env-file=.env bot.js