Discord movie bot tutorial
This tutorial builds a Discord bot with discord.js v14 and a /movie slash command. The bot searches amodb, loads the best match with its ratings and answers with an embed holding the poster, scores, runtime and overview. It defers the reply, retries on 429 and caches answers. It takes about 25 minutes.
What you build
You build a Discord bot with one slash command, /movie title. It replies with an embed that shows the poster, year, IMDb and Kinopoisk scores, runtime, genres and a short overview. You need Node.js 20.6 or newer, discord.js v14, a Discord application with a bot token and an amodb key. Plan on about 25 minutes.
Step 1: Get your credentials
Pick a plan on pricing. Rate is the only difference between plans, and the lowest allows 20 requests per minute. Create a key in your account dashboard.
In the Discord developer portal create an application, add a bot to it and copy the bot token. Open the OAuth2 page, use the URL generator, tick the bot and applications.commands scopes and open the link to add the bot to a test server. Copy the server id as well. Put the values in a .env file and keep it out of Git:
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEYStep 2: Create the project
The type setting turns on import syntax. See authentication for the header and query forms of the key.
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.jsStep 3: Register the slash command
SlashCommandBuilder describes the command and its required title option. The bot sends it to Discord once it is ready. With DISCORD_GUILD_ID set, the command appears in that server at once. Leave the variable unset to register it everywhere.
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}`);
});Step 4: Build the embed
The details call adds append_, so scores arrive with the movie. Each source is an object with value and votes, or null when the score is not known. The embed prints value and says "not known" for null. The poster is https:/ plus poster_path; see images for other sizes. An embed description can hold 4096 characters, and the code keeps 400. Add EmbedBuilder to the import line and define the POSTER constant, as the final listing does.
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;
}Step 5: Handle errors and rate limits
Call deferReply() first. Discord wants an answer within 3 seconds, and a search plus a details call, or a wait after a 429, can take longer. After a deferral you have 15 minutes to call editReply().
| Case | Cause | Bot reaction |
|---|---|---|
| 401 | Key missing, wrong or suspended | Log it, tell the channel, exit. Every command would fail. |
| 429 | Over your requests per minute | Wait Retry-After seconds, capped at 10, retry twice, then ask the user to try later. |
Empty results | No title matches | Reply that no movie was found. |
| Timeout after 8 seconds | Slow network | Reply that the service did not answer. |
The retry loop in the final listing reads Retry-After from the response headers. See errors and rate limits for every code.
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.';
}Step 6: Cache answers
The rate limit check runs before the edge cache, so only a cache in your bot saves requests per minute. This bot keeps a small TtlCache class: 10 minutes for search results and 6 hours for movie details, the same lifetimes as the edge. The class is in the final listing. Two people asking for the same title within 10 minutes then cost two requests in total, not four.
Step 7: The complete bot
Save this as bot.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);Start the bot with the .env file, then type /movie in the test server and enter a title.
For /movie fight club the first search hit is movie 550, and the embed holds the title Fight Club (1999), the overview cut to 400 characters and the thumbnail https:/, plus these fields:
| Field | Value |
|---|---|
| IMDb | 8.8 |
| Kinopoisk | 8.7 |
| Runtime | 139 min |
| Genres | Drama, Thriller |
The first lookup of a title makes two calls, and later lookups inside the cache lifetime make none. A 401 stops the process after a message, and a timeout produces the generic reply.
node --env-file=.env bot.jsNext steps
- Add autocomplete for the
titleoption with the search endpoint, which has a 10 minute edge cache. - Add a
/trendingcommand using trending and lists. - Add a trailer button with the videos and trailers endpoint.
- Build the same command for Telegram.