# Návod na Telegram bota pro filmy

> Tento návod vytvoří Telegram bota v grammY na Node.js. Pošlete /movie fight club a bot odpoví plakátem, rokem, délkou a hodnocením z amodb. Oba tokeny čte z proměnných prostředí, při 429 opakuje pokus podle Retry-After, cachuje odpovědi v paměti a při odmítnutém klíči se zastaví. Zabere asi 20 minut.

## Co vytvoříte

Vytvoříte Telegram bota, který na `/movie fight club` odpoví plakátem, názvem, rokem, délkou a hodnocením. Potřebujete Node.js 20.6 nebo novější, token bota od BotFather v Telegramu a klíč amodb. Bot používá grammY a vestavěný `fetch`, takže stačí jeden balíček npm. Počítejte s asi 20 minutami.

## Krok 1: Získejte dva přihlašovací údaje

Zvolte tarif na stránce [ceník](https://amodb.dev/cs/pricing/). Tarif určuje, kolik požadavků za minutu může váš bot posílat. Vytvořte klíč v panelu účtu, má 32 hexadecimálních znaků. Pak v Telegramu otevřete chat s BotFather, pošlete `/newbot`, odpovězte na dvě otázky a zkopírujte token, který vypíše.

Obě hodnoty vložte do souboru `.env` a přidejte tento soubor do `.gitignore`:

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

## Krok 2: Vytvořte projekt

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

Nastavení `type` zapíná syntaxi `import` a top level `await`. Stránka [autentizace](https://amodb.dev/cs/authentication/) ukazuje podobu klíče v hlavičce i v `api_key`.

## Krok 3: Odpovězte na první příkaz

grammY vkládá text za příkazem do `ctx.match`. Začněte pomocnou funkcí, která posílá hlavičku Bearer, a příkazem, který hledá.

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

## Krok 4: Sestavte odpověď s plakátem

Vezměte první nalezený výsledek, načtěte ho s `append_to_response=ratings` a sestavte popisek. URL plakátu je `https://img.amodb.dev/t/p/w500` plus `poster_path`. Telegram stahuje fotografii sám a `img.amodb.dev` klíč nepotřebuje. Popisek pojme nejvýše 1024 znaků, proto kód popis zkracuje.

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

Každý zdroj v `ratings` je objekt s `value` a `votes`, nebo `null`. Když je `imdb` rovno `null`, popisek se vrátí k souhrnnému `vote_average`. Všech pět zdrojů vysvětluje stránka [hodnocení](https://amodb.dev/cs/ratings/).

## Krok 5: Ošetřete chyby a limity požadavků

Nechte handlery vyhazovat chyby a zachyťte vše na jednom místě. Bez `bot.catch` grammY zastaví bota, když handler vyhodí chybu.

| Případ                  | Příčina                                   | Reakce bota                                                                                                |
| ----------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 401                     | Klíč chybí, je nesprávný nebo pozastavený | Zapíše to do logu, oznámí to chatu a skončí. Selhal by každý příkaz.                                       |
| 429                     | Překročili jste požadavky za minutu       | Počká `Retry-After` sekund, nejvýše 30, dvakrát zopakuje pokus a pak uživateli doporučí zkusit to později. |
| Prázdné `results`       | Dotazu neodpovídá žádný titul             | Odpoví, že žádný film nebyl nalezen.                                                                       |
| Timeout nebo chyba sítě | `fetch` vyhodí výjimku                    | Odpoví krátkou omluvou a zapíše chybu do logu.                                                             |
| Plakát odmítnut         | Telegram obrázek nemůže načíst            | Pošle popisek jako text.                                                                                   |

Smyčka níže je uvnitř `getJson`. `AmodbError` je malá podtřída `Error` s polem `status`, ukázaná v závěrečném výpisu.

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

Každý příkaz stojí dva požadavky, takže tarif s 20 požadavky za minutu obslouží asi 10 nových vyhledání za minutu. Podrobnosti najdete na stránkách [limity požadavků](https://amodb.dev/cs/rate-limits/) a [chyby](https://amodb.dev/cs/errors/).

## Krok 6: Cachujte odpovědi

Kontrola limitu proběhne před cache na edge, takže požadavky ušetří jen cache ve vašem vlastním botovi. Držte `Map` z URL na data a čas vypršení. Použijte doby z edge: 10 minut pro hledání a 6 hodin pro detaily. Horní hranice velikosti brání tomu, aby dlouho běžící bot rostl bez omezení.

Řádky cache jsou také uvnitř `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 });
```

## Krok 7: Kompletní bot

Uložte to jako `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}`) });
```

Spusťte ho se souborem `.env` a pak svému botovi v Telegramu pošlete `/movie fight club`.

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

Pro `/movie fight club` je prvním nalezeným výsledkem film 550. Bot pošle jeho plakát, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, s tímto popiskem:

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

Bot používá long polling, takže nepotřebuje veřejnou adresu. Proces udržujte v chodu nástrojem, který už pro služby používáte.

## Další kroky

- Přidejte `/trending` voláním `trending/movie/week` ze stránky [trendy a seznamy](https://amodb.dev/cs/trending-lists/).
- Nechte uživatele vybrat jeden z pěti výsledků pomocí inline klávesnice, s využitím většího množství odpovědi [vyhledávání](https://amodb.dev/cs/search/).
- Posílejte trailery pomocí endpointu [videa a trailery](https://amodb.dev/cs/videos-trailers/).
- Vytvořte stejný příkaz pro [Discord](https://amodb.dev/cs/discord-movie-bot/).

## Otázky a odpovědi

### Potřebuje bot veřejný server nebo webhook?

Ne. Bot používá long polling přes bot.start(), takže posílá jen odchozí požadavky. Spusťte ho na libovolném počítači, který zůstává online. Webhook je volitelný a hodí se jen tehdy, když dáváte přednost doručování push.

### Kolik požadavků amodb spotřebuje jeden příkaz /movie?

Dva: jedno volání hledání a jedno volání detailů s hodnocením. Titul, který je stále v cache Map, nespotřebuje žádný. V tarifu s 20 požadavky za minutu to dává asi 10 nových vyhledání za minutu.

### Proč se plakát někdy nepodaří odeslat?

Telegram stahuje fotografii z URL sám a odmítne soubory, které nemůže načíst nebo které překračují jeho limity. Bot tuto chybu zachytí a místo mlčení pošle popisek jako prostý text.

### Může bot odpovídat v jiném jazyce?

Ano. Změňte hodnotu language, například na ru-RU nebo es-ES, v obou voláních. Poskytuje se čtrnáct základních jazyků a jakýkoli jiný tag se vrátí k angličtině. Názvy a popisy přijdou přeložené, pokud překlad existuje.

### Kde mám držet token bota a klíč API?

V proměnných prostředí, načtených volbou env-file v Node.js nebo správcem procesů, a nikdy v Gitu. Pokud token bota unikne, vytvořte nový přes BotFather. U klíče amodb vytvořte nový v panelu účtu.

## Související stránky

- [Návod na Discord bota pro filmy](https://amodb.dev/cs/discord-movie-bot/): Vytvořte Discord bota s discord.js v14 a slash příkazem /movie, který odpoví embedem s plakátem, hodnocením a popisem z amodb, včetně opakování při 429.
- [Návod na filmové API v JavaScriptu](https://amodb.dev/cs/javascript-movie-api/): Napište skript v Node.js, který jedním požadavkem na ID načte detaily, obsazení a hodnocení filmů, s cache s TTL, opakováním při 429 a bez závislostí.
- [API vyhledávání](https://amodb.dev/cs/search/): API vyhledávání se stejnými cestami a JSON jako TMDB v3. Najděte filmy, seriály, osoby, kolekce, společnosti a klíčová slova podle textu s filtrem roku.
- [Hodnocení filmů a seriálů](https://amodb.dev/cs/ratings/): API hodnocení filmů, které vrací skóre z IMDb, Kinopoisk, Metacritic, Rotten Tomatoes a TMDB pro titul jedním voláním, s null u každého neznámého zdroje.
- [Limity požadavků](https://amodb.dev/cs/rate-limits/): amodb omezuje klíč počtem požadavků za minutu v klouzavém okně 60 sekund: 20, 40 nebo 120 podle tarifu. Čtěte hlavičky limitu a řešte 429 pomocí Retry-After.
- [Autentizace](https://amodb.dev/cs/authentication/): Jak poslat klíč amodb: api_key v dotazu, hlavička Authorization Bearer nebo apikey pro volání OMDb. Formát klíče, uložení, rotace a chyby 401.

Aktualizováno: 2026-10-18. Source: https://amodb.dev/cs/telegram-movie-bot/
