# Tutorial de bot de filmes para Discord

> Este tutorial cria um bot do Discord com discord.js v14 e o comando /movie. O bot busca no amodb, carrega o melhor resultado com as avaliações e responde com um embed com pôster, notas, duração e sinopse. Ele adia a resposta, tenta de novo em caso de 429 e guarda as respostas em cache. Leva cerca de 25 minutos.

## O que você vai construir

Você vai construir um bot do Discord com um comando de barra, `/movie title`. Ele responde com um embed que mostra o pôster, o ano, as notas do IMDb e do Kinopoisk, a duração, os gêneros e uma sinopse curta. Você precisa do Node.js 20.6 ou mais recente, do discord.js v14, de um aplicativo do Discord com token de bot e de uma chave do amodb. Conte com cerca de 25 minutos.

## Passo 1: obtenha suas credenciais

Escolha um plano em [preços](https://amodb.dev/pt/pricing/). A taxa é a única diferença entre os planos, e o mais básico permite 20 requisições por minuto. Crie uma chave no painel da sua conta.

No portal de desenvolvedores do Discord, crie um aplicativo, adicione um bot a ele e copie o token do bot. Abra a página OAuth2, use o gerador de URL, marque os escopos `bot` e `applications.commands` e abra o link para adicionar o bot a um servidor de teste. Copie também o id do servidor. Coloque os valores em um arquivo `.env` e mantenha-o fora do Git:

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

## Passo 2: crie o projeto

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

A configuração `type` ativa a sintaxe `import`. Veja [autenticação](https://amodb.dev/pt/authentication/) para as formas da chave em cabeçalho e em parâmetro de consulta.

## Passo 3: registre o comando de barra

`SlashCommandBuilder` descreve o comando e sua opção obrigatória `title`. O bot o envia ao Discord assim que estiver pronto. Com `DISCORD_GUILD_ID` definido, o comando aparece nesse servidor na hora. Deixe a variável sem definir para registrá-lo em todos os servidores.

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

## Passo 4: monte o embed

A chamada de detalhes adiciona `append_to_response=ratings`, então as notas chegam junto com o filme. Cada fonte é um objeto com `value` e `votes`, ou `null` quando a nota não é conhecida. O embed imprime `value` e mostra "not known" para `null`. O pôster é `https://img.amodb.dev/t/p/w342` mais `poster_path`; veja [imagens](https://amodb.dev/pt/images-api/) para outros tamanhos. A descrição de um embed pode ter 4096 caracteres, e o código mantém 400. Adicione `EmbedBuilder` à linha de import e defina a constante `POSTER`, como faz a listagem final.

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

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

Chame `deferReply()` primeiro. O Discord quer uma resposta em até 3 segundos, e uma busca mais uma chamada de detalhes, ou uma espera após um 429, pode levar mais tempo. Depois de adiar, você tem 15 minutos para chamar `editReply()`.

| Caso                    | Causa                                         | Reação do bot                                                                                                        |
| ----------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 401                     | Chave ausente, errada ou suspensa             | Registra no log, avisa o canal e encerra. Todo comando falharia.                                                     |
| 429                     | Acima do seu limite de requisições por minuto | Espera `Retry-After` segundos, no máximo 10, tenta de novo duas vezes e depois pede ao usuário que tente mais tarde. |
| `results` vazio         | Nenhum título corresponde                     | Responde que nenhum filme foi encontrado.                                                                            |
| Timeout após 8 segundos | Rede lenta                                    | Responde que o serviço não respondeu.                                                                                |

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

O loop de repetição da listagem final lê `Retry-After` nos cabeçalhos da resposta. Veja [erros](https://amodb.dev/pt/errors/) e [limites de requisições](https://amodb.dev/pt/rate-limits/) para todos os códigos.

## Passo 6: guarde as respostas em cache

A verificação do limite de requisições roda antes do cache de borda, então só um cache no seu bot economiza requisições por minuto. Este bot mantém uma pequena classe `TtlCache`: 10 minutos para resultados de busca e 6 horas para detalhes de filmes, os mesmos tempos da borda. A classe está na listagem final. Duas pessoas pedindo o mesmo título em 10 minutos custam então duas requisições no total, não quatro.

## Passo 7: o bot completo

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

Inicie o bot com o arquivo `.env`, depois digite `/movie` no servidor de teste e informe um título.

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

Para `/movie fight club`, o primeiro resultado da busca é o filme 550, e o embed contém o título `Fight Club (1999)`, a sinopse cortada em 400 caracteres e a miniatura `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, além destes campos:

| Campo     | Valor           |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

A primeira consulta de um título faz duas chamadas, e as consultas seguintes dentro do tempo de vida do cache não fazem nenhuma. Um 401 encerra o processo depois de uma mensagem, e um timeout produz a resposta genérica.

## Próximos passos

- Adicione autocompletar para a opção `title` com o endpoint de [busca](https://amodb.dev/pt/search/), que tem 10 minutos de cache de borda.
- Adicione um comando `/trending` usando [em alta e listas](https://amodb.dev/pt/trending-lists/).
- Adicione um botão de trailer com o endpoint de [vídeos e trailers](https://amodb.dev/pt/videos-trailers/).
- Crie o mesmo comando para o [Telegram](https://amodb.dev/pt/telegram-movie-bot/).

## Perguntas e respostas

### Por que o bot adia a resposta?

O Discord espera uma resposta a um comando de barra em até 3 segundos. Uma busca mais uma chamada de detalhes, ou uma espera após um 429, pode levar mais tempo. deferReply() estende o prazo para 15 minutos, e então editReply() envia o embed.

### Quais intents o bot precisa?

Apenas Guilds. Os comandos de barra chegam como interações, então o bot nunca lê o conteúdo das mensagens e não precisa de nenhum intent privilegiado. Adicione mais intents só quando um recurso que você criar exigir.

### Como devo guardar o token do Discord e a chave do amodb?

Em variáveis de ambiente, por exemplo um arquivo .env carregado com a opção env-file do Node.js e mantido fora do Git. Se um token vazar, redefina-o no portal de desenvolvedores. Para o amodb, crie uma nova chave no painel da sua conta.

### O comando pode mostrar mais de um resultado?

Sim. Monte um embed para cada uma das três primeiras entradas de results, porque uma mensagem pode ter até 10 embeds. Cada chamada de detalhes extra custa uma requisição, então fique de olho nas requisições por minuto.

### O pôster no embed precisa de chave de API?

Não. A URL da miniatura aponta para img.amodb.dev, que serve imagens sem chave, e o Discord a busca quando a mensagem aparece. Só o seu bot usa a chave, nas chamadas JSON.

## Páginas relacionadas

- [Tutorial de bot de filmes no Telegram](https://amodb.dev/pt/telegram-movie-bot/): Crie um bot do Telegram com grammY e Node.js que responde /movie com pôster, ano e nota do amodb, com nova tentativa no 429 e um pequeno cache em memória.
- [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/discord-movie-bot/
