# 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：获取凭据

在[定价](https://amodb.dev/zh/pricing/)页选择套餐。套餐之间唯一的区别是速率，最低的允许每分钟 20 个请求。在账户后台创建密钥。

在 Discord 开发者门户创建一个应用，为它添加机器人并复制机器人令牌。打开 OAuth2 页面，使用 URL 生成器，勾选 `bot` 和 `applications.commands` 范围，打开链接把机器人添加到测试服务器。同时复制服务器 ID。把这些值放进 `.env` 文件，并让它不进入 Git：

```bash
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY
```

## 步骤 2：创建项目

```bash
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.js
```

`type` 设置启用 `import` 语法。密钥的请求头形式和查询参数形式见[认证](https://amodb.dev/zh/authentication/)。

## 步骤 3：注册斜杠命令

`SlashCommandBuilder` 描述命令及其必填的 `title` 选项。机器人就绪后把它发送给 Discord。设置了 `DISCORD_GUILD_ID` 时，命令会立即出现在那台服务器中。不设置该变量则在所有服务器注册。

```js
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`；其他尺寸见[图片](https://amodb.dev/zh/images-api/)。嵌入消息的描述最多 4096 个字符，代码保留 400 个。请像最终代码清单那样，把 `EmbedBuilder` 加进 import 行并定义 `POSTER` 常量。

```js
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 秒后超时       | 网络缓慢        | 回复服务没有响应。                                 |

```js
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.';
}
```

最终代码清单中的重试循环会从响应头读取 `Retry-After`。所有错误码见[错误](https://amodb.dev/zh/errors/)和[速率限制](https://amodb.dev/zh/rate-limits/)。

## 步骤 6：缓存答案

速率限制检查先于边缘缓存执行，所以只有机器人自己的缓存才能节省每分钟请求数。这个机器人使用一个小型 `TtlCache` 类：搜索结果 10 分钟，电影详情 6 小时，与边缘缓存的有效期相同。该类在最终代码清单中。这样，两个人在 10 分钟内查询同一个片名，总共只消耗两个请求，而不是四个。

## 步骤 7：完整的机器人

把它保存为 `bot.js`。

```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` 和片名。

```bash
node --env-file=.env bot.js
```

对于 `/movie fight club`，第一条搜索结果是电影 550，嵌入消息包含标题 `Fight Club (1999)`、截断到 400 个字符的简介和缩略图 `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`，以及这些字段：

| 字段        | 值               |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

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

## 下一步

- 用[搜索](https://amodb.dev/zh/search/)接口为 `title` 选项添加自动补全，该接口有 10 分钟的边缘缓存。
- 用[热门趋势与列表](https://amodb.dev/zh/trending-lists/)添加 `/trending` 命令。
- 用[视频与预告片](https://amodb.dev/zh/videos-trailers/)接口添加预告片按钮。
- 为 [Telegram](https://amodb.dev/zh/telegram-movie-bot/) 构建同样的命令。

## 常见问题

### 机器人为什么要延迟回复？

Discord 要求在 3 秒内回应斜杠命令。一次搜索加一次详情调用，或遇到 429 后的等待，可能耗时更长。deferReply() 把期限延长到 15 分钟，随后 editReply() 发送嵌入消息。

### 机器人需要哪些 intent？

只需要 Guilds。斜杠命令以交互的形式到达，所以机器人从不读取消息内容，也不需要特权 intent。只有当你构建的功能需要时，才添加更多 intent。

### Discord 令牌和 amodb 密钥应该怎么保存？

放在环境变量中，例如用 Node.js 的 env-file 选项加载 .env 文件，并让它不进入 Git。如果令牌泄露，请在开发者门户中重置。对于 amodb，请在账户后台创建新的密钥。

### 命令可以显示多个结果吗？

可以。为 results 的前三项各构建一个嵌入消息，因为一条消息最多可以包含 10 个嵌入。每多一次详情调用就多消耗一个请求，所以请留意每分钟请求数。

### 嵌入消息里的海报需要 API 密钥吗？

不需要。缩略图 URL 指向 img.amodb.dev，它无需密钥即可提供图片，Discord 在消息出现时抓取它。只有你的机器人在 JSON 调用中使用密钥。

## 相关页面

- [Telegram 电影机器人教程](https://amodb.dev/zh/telegram-movie-bot/): 用 grammY 和 Node.js 构建 Telegram 机器人，/movie 命令回复来自 amodb 的海报、年份和评分，带 429 重试和内存缓存。
- [JavaScript 电影 API 教程](https://amodb.dev/zh/javascript-movie-api/): 编写 Node.js 脚本，对一组电影 ID 各用一次请求加载详情、演职员和评分，带 TTL 缓存、429 重试且无依赖。
- [搜索 API](https://amodb.dev/zh/search/): 路径和 JSON 与 TMDB v3 相同的搜索 API。按文本查找电影、电视剧、人物、系列合集、公司和关键词，支持年份筛选和分页。
- [电影与电视剧评分](https://amodb.dev/zh/ratings/): 一次调用返回作品的 IMDb、Kinopoisk、Metacritic、Rotten Tomatoes 和 TMDB 评分，来源未知时为 null。
- [速率限制](https://amodb.dev/zh/rate-limits/): amodb 按滑动 60 秒窗口内每分钟请求数限制每个密钥：按套餐为 20、40 或 120。读取速率限制响应头，并用 Retry-After 处理 429。
- [认证](https://amodb.dev/zh/authentication/): 如何发送 amodb 密钥：查询中的 api_key、Bearer 请求头，或 OMDb 的 apikey。含密钥格式、保管和 401 错误。

更新于: 2026-10-18. Source: https://amodb.dev/zh/discord-movie-bot/
