# Telegram movie bot tutorial

> This tutorial builds a Telegram bot with grammY on Node.js. Send /movie fight club and the bot replies with the poster, year, runtime and rating from amodb. It reads both tokens from environment variables, retries on 429 using Retry-After, caches answers in memory and stops on a rejected key. It takes about 20 minutes.

## What you build

You build a Telegram bot that answers `/movie fight club` with the poster, title, year, runtime and rating. You need Node.js 20.6 or newer, a bot token from BotFather inside Telegram and an amodb key. The bot uses grammY and the built-in `fetch`, so one npm package is enough. Plan on about 20 minutes.

## Step 1: Get two credentials

Pick a plan on [pricing](https://amodb.dev/pricing/). The plan sets how many requests per minute your bot may send. Create a key in your account dashboard, 32 hex characters long. Then open a chat with BotFather in Telegram, send `/newbot`, answer the two questions and copy the token it prints.

Put both values in a `.env` file and add that file to `.gitignore`:

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

## Step 2: Create the project

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

The `type` setting turns on `import` syntax and top level `await`. The [authentication](https://amodb.dev/authentication/) page shows the header and the `api_key` forms of the key.

## Step 3: Answer the first command

grammY puts the text after a command into `ctx.match`. Start with a helper that sends the Bearer header and a command that searches.

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

## Step 4: Build the reply with a poster

Take the first search hit, load it with `append_to_response=ratings` and build a caption. The poster URL is `https://img.amodb.dev/t/p/w500` plus `poster_path`. Telegram downloads the photo itself, and `img.amodb.dev` needs no key. A caption holds at most 1024 characters, so the code cuts the overview.

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

Each source in `ratings` is an object with `value` and `votes`, or `null`. When `imdb` is `null`, the caption falls back to the aggregate `vote_average`. The [ratings](https://amodb.dev/ratings/) page explains all five sources.

## Step 5: Handle errors and rate limits

Let handlers throw and catch everything in one place. Without `bot.catch`, grammY stops the bot when a handler throws.

| Case                     | Cause                           | Bot reaction                                                                           |
| ------------------------ | ------------------------------- | -------------------------------------------------------------------------------------- |
| 401                      | Key missing, wrong or suspended | Log it, tell the chat, exit. Every command would fail.                                 |
| 429                      | Over your requests per minute   | Wait `Retry-After` seconds, capped at 30, retry twice, then ask the user to try later. |
| Empty `results`          | No title matches the query      | Reply that no movie was found.                                                         |
| Timeout or network error | `fetch` throws                  | Reply with a short apology and log the error.                                          |
| Photo rejected           | Telegram cannot fetch the image | Send the caption as text.                                                              |

The loop below sits inside `getJson`. `AmodbError` is a small `Error` subclass with a `status` field, shown in the final listing.

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

Each command costs two requests, so the 20 requests per minute plan serves about 10 new lookups a minute. See [rate limits](https://amodb.dev/rate-limits/) and [errors](https://amodb.dev/errors/) for the details.

## Step 6: Cache answers

The rate limit check runs before the edge cache, so only a cache in your own bot saves requests. Keep a `Map` from URL to data and an expiry time. Use the edge lifetimes: 10 minutes for search and 6 hours for details. A size cap keeps a long running bot from growing without limit.

The cache lines also sit inside `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 });
```

## Step 7: The complete bot

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

Start it with the `.env` file, then send `/movie fight club` to your bot in Telegram.

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

For `/movie fight club` the first search hit is movie 550. The bot sends its poster, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, with this caption:

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

The bot uses long polling, so it needs no public address. Keep the process running with the tool you already use for services.

## Next steps

- Add `/trending` with the `trending/movie/week` call from [trending and lists](https://amodb.dev/trending-lists/).
- Let users pick one of five results with an inline keyboard, using more of the [search](https://amodb.dev/search/) response.
- Send trailers with the [videos and trailers](https://amodb.dev/videos-trailers/) endpoint.
- Build the same command for [Discord](https://amodb.dev/discord-movie-bot/).

## Questions and answers

### Does the bot need a public server or a webhook?

No. The bot uses long polling through bot.start(), so it only makes outgoing requests. Run it on any machine that stays online. A webhook is optional and only useful if you prefer push delivery.

### How many amodb requests does one /movie command use?

Two: one search call and one details call with ratings. A title that is still in the Map cache uses none. On the 20 requests per minute plan that allows about 10 new lookups per minute.

### Why does the poster sometimes fail to send?

Telegram downloads the photo from the URL itself, and it rejects files it cannot fetch or that break its limits. The bot catches that error and sends the caption as plain text instead of staying silent.

### Can the bot answer in another language?

Yes. Change the language value, for example ru-RU or es-ES, in both calls. Fourteen base languages are served, and any other tag falls back to English. Titles and overviews arrive translated when a translation exists.

### Where should I keep the bot token and the API key?

In environment variables, loaded with the env-file option of Node.js or your process manager, and never in Git. If the bot token leaks, create a new one with BotFather. For an amodb key, create a new one in your account dashboard.

## Related pages

- [Discord movie bot tutorial](https://amodb.dev/discord-movie-bot/): Build a Discord bot with discord.js v14 and a /movie slash command that replies with an embed of poster, ratings and overview from amodb, with 429 retry.
- [JavaScript movie API tutorial](https://amodb.dev/javascript-movie-api/): Write a Node.js script that loads details, credits and ratings for a list of movie ids in one request each, with a TTL cache, 429 retry and no dependencies.
- [Search API](https://amodb.dev/search/): Search API with the same paths and JSON as TMDB v3. Find movies, TV shows, people, collections, companies and keywords by text, with year filters and paging.
- [Movie and TV ratings](https://amodb.dev/ratings/): Movie ratings API that returns IMDb, Kinopoisk, Metacritic, Rotten Tomatoes and TMDB scores for a title in one call, with null for any source not known.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.
- [Authentication](https://amodb.dev/authentication/): How to send your amodb key: api_key in the query, an Authorization Bearer header, or apikey for OMDb calls. Key format, storage, rotation and the 401 errors.

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