Discord 电影机器人教程

本教程用 discord.js v14 构建带 /movie 斜杠命令的 Discord 机器人。机器人搜索 amodb,加载最佳匹配及其评分,并用包含海报、评分、时长和简介的嵌入消息回复。它延迟回复、在 429 时重试并缓存答案,约 25 分钟。

.js Discord 更新于 以 Markdown 查看

你将构建什么

你将构建一个带有一个斜杠命令 /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:

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

步骤 2:创建项目

type 设置启用 import 语法。密钥的请求头形式和查询参数形式见认证。

Shell
mkdir 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 时,命令会立即出现在那台服务器中。不设置该变量则在所有服务器注册。

JavaScript
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_to_response=ratings,所以评分随电影一起到达。每个来源是包含 value 和 votes 的对象,评分未知时为 null。嵌入消息打印 value,对 null 则写“未知”。海报是 https://img.amodb.dev/t/p/w342 加上 poster_path;其他尺寸见图片。嵌入消息的描述最多 4096 个字符,代码保留 400 个。请像最终代码清单那样,把 EmbedBuilder 加进 import 行并定义 POSTER 常量。

JavaScript
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。所有错误码见错误和速率限制。

JavaScript
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。

JavaScript
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://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg,以及这些字段:

字段值
IMDb8.8
Kinopoisk8.7
Runtime139 min
GenresDrama, Thriller

第一次查询某个片名会发起两次调用,在缓存有效期内再次查询则一次也不发起。401 会在发出消息后让进程停止,超时则产生通用的回复。

Shell
node --env-file=.env bot.js

下一步