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.

.js Discord Atualizado em Ver em Markdown

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

Shell
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY

Passo 2: crie o projeto

A configuração type ativa a sintaxe import. Veja autenticação para as formas da chave em cabeçalho e em parâmetro de consulta.

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

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.

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

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

CasoCausaReação do bot
401Chave ausente, errada ou suspensaRegistra no log, avisa o canal e encerra. Todo comando falharia.
429Acima do seu limite de requisições por minutoEspera Retry-After segundos, no máximo 10, tenta de novo duas vezes e depois pede ao usuário que tente mais tarde.
results vazioNenhum título correspondeResponde que nenhum filme foi encontrado.
Timeout após 8 segundosRede lentaResponde que o serviço não respondeu.

O loop de repetição da listagem final lê Retry-After nos cabeçalhos da resposta. Veja erros e limites de requisições para todos os códigos.

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

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.

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

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:

CampoValor
IMDb8.8
Kinopoisk8.7
Runtime139 min
GenresDrama, 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.

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

Próximos passos

  • Adicione autocompletar para a opção title com o endpoint de busca, que tem 10 minutos de cache de borda.
  • Adicione um comando /trending usando em alta e listas.
  • Adicione um botão de trailer com o endpoint de vídeos e trailers.
  • Crie o mesmo comando para o Telegram.