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:获取两个凭据
在定价页选择套餐。套餐决定你的机器人每分钟可以发送多少请求。在账户后台创建密钥,长度为 32 个十六进制字符。然后在 Telegram 里打开与 BotFather 的对话,发送 /newbot,回答两个问题并复制它打印的令牌。
把两个值放进 .env 文件,并把该文件加入 .gitignore:
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEYmkdir movie-bot && cd movie-bot
npm init -y
npm pkg set type=module
npm install grammy步骤 3:回应第一条命令
grammY 把命令后面的文本放入 ctx.match。先写一个发送 Bearer 请求头的辅助函数和一个执行搜索的命令。
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_ 加载它,并构建说明文字。海报 URL 是 https:/ 加上 poster_path。Telegram 会自己下载照片,而 img.amodb.dev 无需密钥。说明文字最多 1024 个字符,所以代码会截断简介。
ratings 中的每个来源是包含 value 和 votes 的对象,或为 null。当 imdb 为 null 时,说明文字会回退到汇总的 vote_average。评分页说明了全部五个来源。
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);
}步骤 5:处理错误和速率限制
让处理函数直接抛出,并在一处统一捕获。没有 bot.catch 时,处理函数一旦抛出,grammY 就会停止机器人。
| 情况 | 原因 | 机器人的反应 |
|---|---|---|
| 401 | 密钥缺失、错误或已停用 | 记录日志,告知聊天,然后退出。每个命令都会失败。 |
| 429 | 超过每分钟请求数 | 等待 Retry-After 秒(上限 30),重试两次,然后请用户稍后再试。 |
results 为空 | 没有片名匹配查询 | 回复未找到电影。 |
| 超时或网络错误 | fetch 抛出异常 | 回复一句简短的致歉并记录错误。 |
| 照片被拒绝 | Telegram 无法获取图片 | 把说明文字作为文本发送。 |
下面的循环位于 getJson 内部。AmodbError 是一个带有 status 字段的小型 Error 子类,在最终代码清单中给出。
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();
}步骤 6:缓存答案
速率限制检查先于边缘缓存执行,所以只有你自己机器人里的缓存才能节省请求。保留一个从 URL 到数据和过期时间的 Map。使用边缘缓存的有效期:搜索 10 分钟,详情 6 小时。设置大小上限,避免长期运行的机器人无限增长。
缓存相关的几行也位于 getJson 内部。
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。
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。
node --env-file=.env bot.js对于 /movie fight club,第一条搜索结果是电影 550。机器人发送它的海报 https:/,并附上这段说明文字:
机器人使用长轮询,所以不需要公网地址。请用你平时运行服务的工具让进程保持运行。
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.