Tutorial de bot de filmes no Telegram

Este tutorial cria um bot do Telegram com grammY em Node.js. Envie /movie fight club e o bot responde com pôster, ano, duração e nota do amodb. Ele lê os dois tokens de variáveis de ambiente, tenta de novo no 429 com Retry-After, guarda respostas em memória e para quando a chave é rejeitada. Leva cerca de 20 minutos.

.js Telegram Atualizado em Ver em Markdown

O que você vai construir

Você vai criar um bot do Telegram que responde a /movie fight club com o pôster, o título, o ano, a duração e a nota. Você precisa do Node.js 20.6 ou mais novo, de um token de bot do BotFather dentro do Telegram e de uma chave do amodb. O bot usa grammY e o fetch nativo, então um único pacote npm basta. Reserve cerca de 20 minutos.

Passo 1: obtenha duas credenciais

Escolha um plano em preços. O plano define quantas requisições por minuto o seu bot pode enviar. Crie uma chave no painel da sua conta, com 32 caracteres hexadecimais. Depois abra uma conversa com o BotFather no Telegram, envie /newbot, responda às duas perguntas e copie o token que ele imprime.

Coloque os dois valores em um arquivo .env e adicione esse arquivo ao .gitignore:

Shell
BOT_TOKEN=YOUR_BOT_TOKEN
AMODB_KEY=YOUR_API_KEY

Passo 2: crie o projeto

A configuração type ativa a sintaxe import e o await no nível superior. A página de autenticação mostra a chave no cabeçalho e na forma api_key.

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

Passo 3: responda ao primeiro comando

O grammY coloca o texto depois de um comando em ctx.match. Comece com um helper que envia o cabeçalho Bearer e um comando que faz a busca.

JavaScript
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();

Passo 4: monte a resposta com um pôster

Pegue o primeiro resultado da busca, carregue-o com append_to_response=ratings e monte uma legenda. A URL do pôster é https://img.amodb.dev/t/p/w500 mais poster_path. O Telegram baixa a foto por conta própria, e img.amodb.dev não precisa de chave. Uma legenda comporta no máximo 1024 caracteres, então o código corta a sinopse.

Cada fonte em ratings é um objeto com value e votes, ou null. Quando imdb é null, a legenda recorre ao vote_average agregado. A página de avaliações explica as cinco fontes.

JavaScript
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);
}

Passo 5: trate erros e limites de requisições

Deixe os handlers lançarem exceções e capture tudo em um só lugar. Sem bot.catch, o grammY para o bot quando um handler lança uma exceção.

CasoCausaReação do bot
401Chave ausente, incorreta ou suspensaRegistra no log, avisa o chat e encerra. Todo comando falharia.
429Acima das suas requisições por minutoEspera Retry-After segundos, com limite de 30, tenta de novo duas vezes e depois pede ao usuário que tente mais tarde.
results vazioNenhum título corresponde à consultaResponde que nenhum filme foi encontrado.
Timeout ou erro de redefetch lança uma exceçãoResponde com um breve pedido de desculpas e registra o erro.
Foto rejeitadaO Telegram não consegue buscar a imagemEnvia a legenda como texto.

O laço abaixo fica dentro de getJson. AmodbError é uma pequena subclasse de Error com um campo status, mostrada na listagem final.

Cada comando custa duas requisições, então o plano de 20 requisições por minuto atende cerca de 10 consultas novas por minuto. Veja limites de requisições e erros para os detalhes.

JavaScript
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();
}

Passo 6: guarde as respostas em cache

A checagem do limite de requisições roda antes do cache de borda, então só um cache no seu próprio bot economiza requisições. Mantenha um Map da URL para os dados e um prazo de validade. Use as durações da borda: 10 minutos para busca e 6 horas para detalhes. Um limite de tamanho impede que um bot de longa duração cresça sem controle.

As linhas do cache também ficam dentro de getJson.

JavaScript
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 });

Passo 7: o bot completo

Salve isto como bot.js.

JavaScript
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}`) });

Inicie-o com o arquivo .env e depois envie /movie fight club ao seu bot no Telegram.

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

Para /movie fight club, o primeiro resultado da busca é o filme 550. O bot envia o pôster, https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg, com esta legenda:

O bot usa long polling, então não precisa de endereço público. Mantenha o processo rodando com a ferramenta que você já usa para serviços.

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.

Próximos passos

  • Adicione /trending com a chamada trending/movie/week de em alta e listas.
  • Deixe os usuários escolherem um entre cinco resultados com um teclado inline, usando mais da resposta de busca.
  • Envie trailers com o endpoint de vídeos e trailers.
  • Crie o mesmo comando para o Discord.