# Telegram 电影机器人教程

> 用 grammY 在 Node.js 上构建 Telegram 机器人。发送 /movie fight club，机器人回复来自 amodb 的海报、年份、时长和评分。它按 Retry-After 重试 429，在内存中缓存答案，密钥被拒即停。约 20 分钟。

## 你将构建什么

你将构建一个 Telegram 机器人，对 `/movie fight club` 回复海报、标题、年份、时长和评分。你需要 Node.js 20.6 或更新版本、在 Telegram 内通过 BotFather 获得的机器人令牌，以及一个 amodb 密钥。机器人使用 grammY 和内置的 `fetch`，所以一个 npm 包就够了。预计用时约 20 分钟。

## 步骤 1：获取两个凭据

在[定价](https://amodb.dev/zh/pricing/)页选择套餐。套餐决定你的机器人每分钟可以发送多少请求。在账户后台创建密钥，长度为 32 个十六进制字符。然后在 Telegram 里打开与 BotFather 的对话，发送 `/newbot`，回答两个问题并复制它打印的令牌。

把两个值放进 `.env` 文件，并把该文件加入 `.gitignore`：

```bash
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEY
```

## 步骤 2：创建项目

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

`type` 设置启用 `import` 语法和顶层 `await`。[认证](https://amodb.dev/zh/authentication/)页展示了密钥的请求头形式和 `api_key` 形式。

## 步骤 3：回应第一条命令

grammY 把命令后面的文本放入 `ctx.match`。先写一个发送 Bearer 请求头的辅助函数和一个执行搜索的命令。

```js
import { Bot } from 'grammy';

const bot = new Bot(process.env.BOT_TOKEN);

async function getJson(path, params) {
  const url = new URL('https://api.amodb.dev/3' + path);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.AMODB_KEY}` } });
  return res.json();
}

bot.command('movie', async (ctx) => {
  const found = await getJson('/search/movie', { query: ctx.match, language: 'en-US' });
  await ctx.reply(found.results[0]?.title ?? 'No movie found.');
});

bot.start();
```

## 步骤 4：构建带海报的回复

取第一条搜索结果，用 `append_to_response=ratings` 加载它，并构建说明文字。海报 URL 是 `https://img.amodb.dev/t/p/w500` 加上 `poster_path`。Telegram 会自己下载照片，而 `img.amodb.dev` 无需密钥。说明文字最多 1024 个字符，所以代码会截断简介。

```js
function buildCaption(movie) {
  const year = (movie.release_date || '').slice(0, 4) || 'year unknown';
  const imdb = movie.ratings?.imdb;
  const rating = imdb
    ? `IMDb ${imdb.value}`
    : movie.vote_average ? `TMDB ${movie.vote_average.toFixed(1)}` : 'not known';
  const lines = [`${movie.title} (${year})`, `Rating: ${rating}`];
  if (movie.runtime) lines.push(`Runtime: ${movie.runtime} min`);
  if (movie.overview) lines.push('', movie.overview.slice(0, 600));
  return lines.join('\n').slice(0, 1024);
}
```

`ratings` 中的每个来源是包含 `value` 和 `votes` 的对象，或为 `null`。当 `imdb` 为 `null` 时，说明文字会回退到汇总的 `vote_average`。[评分](https://amodb.dev/zh/ratings/)页说明了全部五个来源。

## 步骤 5：处理错误和速率限制

让处理函数直接抛出，并在一处统一捕获。没有 `bot.catch` 时，处理函数一旦抛出，grammY 就会停止机器人。

| 情况           | 原因              | 机器人的反应                                    |
| ------------ | --------------- | ----------------------------------------- |
| 401          | 密钥缺失、错误或已停用     | 记录日志，告知聊天，然后退出。每个命令都会失败。                  |
| 429          | 超过每分钟请求数        | 等待 `Retry-After` 秒（上限 30），重试两次，然后请用户稍后再试。 |
| `results` 为空 | 没有片名匹配查询        | 回复未找到电影。                                  |
| 超时或网络错误      | `fetch` 抛出异常    | 回复一句简短的致歉并记录错误。                           |
| 照片被拒绝        | Telegram 无法获取图片 | 把说明文字作为文本发送。                              |

下面的循环位于 `getJson` 内部。`AmodbError` 是一个带有 `status` 字段的小型 `Error` 子类，在最终代码清单中给出。

```js
for (let attempt = 0; ; attempt++) {
  const res = await fetch(id, { headers, signal: AbortSignal.timeout(10_000) });
  if (res.status === 429 && attempt < 2) {
    const seconds = Math.min(Number(res.headers.get('retry-after')) || 5, 30);
    await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    continue;
  }
  if (!res.ok) throw new AmodbError(res.status);
  return res.json();
}
```

每条命令消耗两个请求，所以每分钟 20 个请求的套餐每分钟大约能服务 10 次新的查询。详情见[速率限制](https://amodb.dev/zh/rate-limits/)和[错误](https://amodb.dev/zh/errors/)。

## 步骤 6：缓存答案

速率限制检查先于边缘缓存执行，所以只有你自己机器人里的缓存才能节省请求。保留一个从 URL 到数据和过期时间的 `Map`。使用边缘缓存的有效期：搜索 10 分钟，详情 6 小时。设置大小上限，避免长期运行的机器人无限增长。

缓存相关的几行也位于 `getJson` 内部。

```js
const hit = cache.get(id);
if (hit && hit.expires > Date.now()) return hit.data;
// ...after a successful fetch:
if (cache.size > 500) cache.delete(cache.keys().next().value);
cache.set(id, { data, expires: Date.now() + ttlMs });
```

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

把它保存为 `bot.js`。

```js
import { Bot } from 'grammy';

const { BOT_TOKEN, AMODB_KEY } = process.env;
if (!BOT_TOKEN || !AMODB_KEY) {
  console.error('Set BOT_TOKEN and AMODB_KEY first.');
  process.exit(1);
}

const API = 'https://api.amodb.dev/3';
const IMG = 'https://img.amodb.dev/t/p/w500';
const headers = { Authorization: `Bearer ${AMODB_KEY}` };
const cache = new Map();

class AmodbError extends Error {
  constructor(status) {
    super(`amodb returned ${status}`);
    this.status = status;
  }
}

async function getJson(path, params, ttlMs) {
  const url = new URL(API + path);
  for (const [name, value] of Object.entries(params)) url.searchParams.set(name, value);
  const id = url.toString();

  const hit = cache.get(id);
  if (hit && hit.expires > Date.now()) return hit.data;

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(id, { headers, signal: AbortSignal.timeout(10_000) });
    if (res.status === 429 && attempt < 2) {
      const seconds = Math.min(Number(res.headers.get('retry-after')) || 5, 30);
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      continue;
    }
    if (!res.ok) throw new AmodbError(res.status);
    const data = await res.json();
    if (cache.size > 500) cache.delete(cache.keys().next().value);
    cache.set(id, { data, expires: Date.now() + ttlMs });
    return data;
  }
}

function buildCaption(movie) {
  const year = (movie.release_date || '').slice(0, 4) || 'year unknown';
  const imdb = movie.ratings?.imdb;
  const rating = imdb
    ? `IMDb ${imdb.value}`
    : movie.vote_average ? `TMDB ${movie.vote_average.toFixed(1)}` : 'not known';
  const lines = [`${movie.title} (${year})`, `Rating: ${rating}`];
  if (movie.runtime) lines.push(`Runtime: ${movie.runtime} min`);
  if (movie.overview) lines.push('', movie.overview.slice(0, 600));
  return lines.join('\n').slice(0, 1024);
}

const bot = new Bot(BOT_TOKEN);

bot.command('start', (ctx) =>
  ctx.reply('Send /movie and a title, for example /movie fight club.'));

bot.command('movie', async (ctx) => {
  const title = ctx.match.trim();
  if (!title) return ctx.reply('Usage: /movie fight club');
  await ctx.replyWithChatAction('typing');

  const found = await getJson('/search/movie', { query: title, language: 'en-US' }, 10 * 60_000);
  const hit = found.results[0];
  if (!hit) return ctx.reply(`No movie found for "${title}".`);

  const movie = await getJson(
    `/movie/${hit.id}`,
    { language: 'en-US', append_to_response: 'ratings' },
    6 * 60 * 60_000,
  );
  const caption = buildCaption(movie);
  if (!movie.poster_path) return ctx.reply(caption);
  try {
    await ctx.replyWithPhoto(IMG + movie.poster_path, { caption });
  } catch {
    await ctx.reply(caption);
  }
});

bot.catch(async ({ ctx, error }) => {
  console.error('Update failed:', error);
  if (error instanceof AmodbError && error.status === 401) {
    console.error('amodb rejected AMODB_KEY (401). Fix the key and restart.');
    await ctx.reply('The bot key was rejected. The owner has to fix it.').catch(() => {});
    process.exit(1);
  }
  const text = error instanceof AmodbError && error.status === 429
    ? 'Too many requests right now. Try again in a minute.'
    : 'The movie service did not answer. Try again later.';
  await ctx.reply(text).catch(() => {});
});

await bot.api.setMyCommands([{ command: 'movie', description: 'Find a movie by title' }]);
process.once('SIGINT', () => bot.stop());
process.once('SIGTERM', () => bot.stop());
bot.start({ onStart: (me) => console.log(`Running as @${me.username}`) });
```

用 `.env` 文件启动它，然后在 Telegram 中向你的机器人发送 `/movie fight club`。

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

对于 `/movie fight club`，第一条搜索结果是电影 550。机器人发送它的海报 `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`，并附上这段说明文字：

```text
Fight Club (1999)
Rating: IMDb 8.8
Runtime: 139 min

A ticking-time-bomb insomniac and a slippery soap salesman channel primal male aggression into a shocking new form of therapy. Their concept catches on, with underground "fight clubs" forming in every town, until an eccentric gets in the way and ignites an out-of-control spiral toward oblivion.
```

机器人使用长轮询，所以不需要公网地址。请用你平时运行服务的工具让进程保持运行。

## 下一步

- 用[热门趋势与列表](https://amodb.dev/zh/trending-lists/)中的 `trending/movie/week` 调用添加 `/trending`。
- 借助[搜索](https://amodb.dev/zh/search/)响应的更多内容，用内联键盘让用户从五个结果中选择一个。
- 用[视频与预告片](https://amodb.dev/zh/videos-trailers/)接口发送预告片。
- 为 [Discord](https://amodb.dev/zh/discord-movie-bot/) 构建同样的命令。

## 常见问题

### 机器人需要公网服务器或 webhook 吗？

不需要。机器人通过 bot.start() 使用长轮询，所以只发出站请求。在任何保持在线的机器上运行即可。webhook 是可选的，只在你更喜欢推送投递时才有用。

### 一次 /movie 命令用掉多少个 amodb 请求？

两个：一次搜索调用和一次带评分的详情调用。仍在 Map 缓存中的片名则一个也不用。在每分钟 20 个请求的套餐上，每分钟大约可以查询 10 个新片名。

### 为什么海报有时发送失败？

Telegram 会自己从 URL 下载照片，无法获取或超出其限制的文件会被拒绝。机器人会捕获该错误，改为发送纯文本说明，而不是保持沉默。

### 机器人可以用其他语言回答吗？

可以。在两次调用中修改 language 的值，例如 ru-RU 或 es-ES。提供 14 种基础语言，其他标签会回退到英语。存在译文时，标题和简介会以译文返回。

### 机器人令牌和 API 密钥应该放在哪里？

放在环境变量中，用 Node.js 的 env-file 选项或你的进程管理器加载，绝不要放进 Git。如果机器人令牌泄露，请用 BotFather 创建新的。对于 amodb 密钥，请在账户后台创建新的。

## 相关页面

- [Discord 电影机器人教程](https://amodb.dev/zh/discord-movie-bot/): 用 discord.js v14 构建 Discord 机器人，/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/telegram-movie-bot/
