# 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.

## 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](https://amodb.dev/pt/pricing/). 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`:

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

## Passo 2: crie o projeto

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

A configuração `type` ativa a sintaxe `import` e o `await` no nível superior. A página de [autenticação](https://amodb.dev/pt/authentication/) mostra a chave no cabeçalho e na forma `api_key`.

## 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.

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

## 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.

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

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](https://amodb.dev/pt/ratings/) explica as cinco fontes.

## 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.

| Caso                    | Causa                                   | Reação do bot                                                                                                            |
| ----------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 401                     | Chave ausente, incorreta ou suspensa    | Registra no log, avisa o chat e encerra. Todo comando falharia.                                                          |
| 429                     | Acima das suas requisições por minuto   | Espera `Retry-After` segundos, com limite de 30, tenta de novo duas vezes e depois pede ao usuário que tente mais tarde. |
| `results` vazio         | Nenhum título corresponde à consulta    | Responde que nenhum filme foi encontrado.                                                                                |
| Timeout ou erro de rede | `fetch` lança uma exceção               | Responde com um breve pedido de desculpas e registra o erro.                                                             |
| Foto rejeitada          | O Telegram não consegue buscar a imagem | Envia 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.

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

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](https://amodb.dev/pt/rate-limits/) e [erros](https://amodb.dev/pt/errors/) para os detalhes.

## 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`.

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

## Passo 7: o bot completo

Salve isto como `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}`) });
```

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

```bash
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:

```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.
```

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.

## Próximos passos

- Adicione `/trending` com a chamada `trending/movie/week` de [em alta e listas](https://amodb.dev/pt/trending-lists/).
- Deixe os usuários escolherem um entre cinco resultados com um teclado inline, usando mais da resposta de [busca](https://amodb.dev/pt/search/).
- Envie trailers com o endpoint de [vídeos e trailers](https://amodb.dev/pt/videos-trailers/).
- Crie o mesmo comando para o [Discord](https://amodb.dev/pt/discord-movie-bot/).

## Perguntas e respostas

### O bot precisa de um servidor público ou de um webhook?

Não. O bot usa long polling por meio de bot.start(), então só faz requisições de saída. Rode-o em qualquer máquina que fique ligada. O webhook é opcional e só serve se você preferir entrega por push.

### Quantas requisições ao amodb um comando /movie usa?

Duas: uma chamada de busca e uma de detalhes com avaliações. Um título que ainda está no cache Map não usa nenhuma. No plano de 20 requisições por minuto isso permite cerca de 10 consultas novas por minuto.

### Por que o pôster às vezes não é enviado?

O Telegram baixa a foto da URL por conta própria e rejeita arquivos que não consegue buscar ou que ultrapassam seus limites. O bot captura esse erro e envia a legenda como texto simples em vez de ficar em silêncio.

### O bot pode responder em outro idioma?

Sim. Troque o valor de language, por exemplo ru-RU ou es-ES, nas duas chamadas. Quatorze idiomas base são atendidos, e qualquer outra tag volta para o inglês. Títulos e sinopses chegam traduzidos quando existe tradução.

### Onde devo guardar o token do bot e a chave de API?

Em variáveis de ambiente, carregadas com a opção env-file do Node.js ou com o seu gerenciador de processos, e nunca no Git. Se o token do bot vazar, crie outro com o BotFather. Para uma chave do amodb, crie uma nova no painel da sua conta.

## Páginas relacionadas

- [Tutorial de bot de filmes para Discord](https://amodb.dev/pt/discord-movie-bot/): Crie um bot do Discord com discord.js v14 e o comando /movie, que responde com um embed de pôster, avaliações e sinopse do amodb, com nova tentativa em 429.
- [Tutorial de API de filmes em JavaScript](https://amodb.dev/pt/javascript-movie-api/): Escreva um script Node.js que carrega detalhes, créditos e avaliações de ids de filmes em uma requisição cada, com cache TTL, retry em 429 e sem dependências.
- [API de busca](https://amodb.dev/pt/search/): API de busca com caminhos e JSON do TMDB v3. Encontre filmes, séries, pessoas, coleções, produtoras e palavras-chave por texto, com filtro de ano e paginação.
- [Avaliações de filmes e séries](https://amodb.dev/pt/ratings/): API de avaliações de filmes com as notas do IMDb, Kinopoisk, Metacritic, Rotten Tomatoes e TMDB em uma só chamada, com null se a fonte for desconhecida.
- [Limites de requisições](https://amodb.dev/pt/rate-limits/): O amodb limita cada chave por requisições por minuto em uma janela de 60 segundos: 20, 40 ou 120 por plano. Leia os cabeçalhos e trate o 429 com Retry-After.
- [Autenticação](https://amodb.dev/pt/authentication/): Como enviar sua chave do amodb: api_key na consulta, um cabeçalho Authorization Bearer ou apikey nas chamadas OMDb. Formato, armazenamento, rotação e erros 401.

Atualizado: 2026-10-18. Source: https://amodb.dev/pt/telegram-movie-bot/
