# Poradnik bota Telegram z filmami

> Ten poradnik buduje bota Telegram w grammY na Node.js. Wyślij /movie fight club, a bot odpowie plakatem, rokiem, czasem trwania i oceną z amodb. Oba tokeny czyta ze zmiennych środowiskowych, ponawia żądanie po 429 według Retry-After, zapisuje odpowiedzi w pamięci i zatrzymuje się przy odrzuconym kluczu. Zajmuje około 20 minut.

## Co zbudujesz

Zbudujesz bota Telegram, który na `/movie fight club` odpowiada plakatem, tytułem, rokiem, czasem trwania i oceną. Potrzebujesz Node.js 20.6 lub nowszego, tokenu bota od BotFather w Telegramie i klucza amodb. Bot używa grammY i wbudowanego `fetch`, więc wystarczy jeden pakiet npm. Zaplanuj około 20 minut.

## Krok 1: Zdobądź dwa dane dostępowe

Wybierz plan na stronie [cennik](https://amodb.dev/pl/pricing/). Plan określa, ile żądań na minutę może wysyłać twój bot. Utwórz klucz w panelu konta, ma 32 znaki szesnastkowe. Potem otwórz w Telegramie czat z BotFather, wyślij `/newbot`, odpowiedz na dwa pytania i skopiuj token, który wypisze.

Wpisz obie wartości do pliku `.env` i dodaj ten plik do `.gitignore`:

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

## Krok 2: Utwórz projekt

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

Ustawienie `type` włącza składnię `import` i top level `await`. Strona [uwierzytelnianie](https://amodb.dev/pl/authentication/) pokazuje formę klucza w nagłówku i w `api_key`.

## Krok 3: Odpowiedz na pierwszą komendę

grammY wkłada tekst po komendzie do `ctx.match`. Zacznij od funkcji pomocniczej, która wysyła nagłówek Bearer, i komendy, która wyszukuje.

```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: Zbuduj odpowiedź z plakatem

Weź pierwsze trafienie, pobierz je z `append_to_response=ratings` i zbuduj podpis. URL plakatu to `https://img.amodb.dev/t/p/w500` plus `poster_path`. Telegram sam pobiera zdjęcie, a `img.amodb.dev` nie wymaga klucza. Podpis mieści najwyżej 1024 znaki, więc kod przycina opis.

```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żde źródło w `ratings` to obiekt z `value` i `votes` albo `null`. Gdy `imdb` ma wartość `null`, podpis wraca do zagregowanego `vote_average`. Wszystkie pięć źródeł wyjaśnia strona [oceny](https://amodb.dev/pl/ratings/).

## Krok 5: Obsłuż błędy i limity żądań

Pozwól handlerom rzucać błędy i łap wszystko w jednym miejscu. Bez `bot.catch` grammY zatrzymuje bota, gdy handler rzuci błąd.

| Przypadek              | Przyczyna                               | Reakcja bota                                                                                                   |
| ---------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| 401                    | Brak klucza, jest błędny lub zawieszony | Zapisuje to w logu, informuje czat i kończy. Każda komenda by zawiodła.                                        |
| 429                    | Przekroczono żądania na minutę          | Czeka `Retry-After` sekund, maksymalnie 30, ponawia dwa razy, a potem prosi użytkownika, by spróbował później. |
| Puste `results`        | Żaden tytuł nie pasuje do zapytania     | Odpowiada, że nie znaleziono filmu.                                                                            |
| Timeout lub błąd sieci | `fetch` rzuca wyjątek                   | Odpowiada krótkimi przeprosinami i zapisuje błąd w logu.                                                       |
| Plakat odrzucony       | Telegram nie może pobrać obrazu         | Wysyła podpis jako tekst.                                                                                      |

Poniższa pętla jest wewnątrz `getJson`. `AmodbError` to mała podklasa `Error` z polem `status`, pokazana w końcowym listingu.

```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żda komenda kosztuje dwa żądania, więc plan z 20 żądaniami na minutę obsłuży około 10 nowych wyszukań na minutę. Szczegóły znajdziesz na stronach [limity żądań](https://amodb.dev/pl/rate-limits/) i [błędy](https://amodb.dev/pl/errors/).

## Krok 6: Zapisuj odpowiedzi w cache

Sprawdzenie limitu działa przed cache na edge, więc żądania oszczędza tylko cache w twoim własnym bocie. Trzymaj `Map` z URL na dane i czas wygaśnięcia. Użyj czasów życia z edge: 10 minut dla wyszukiwania i 6 godzin dla szczegółów. Górny limit rozmiaru powstrzymuje długo działającego bota przed nieograniczonym wzrostem.

Linie cache też są wewnątrz `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: Kompletny bot

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

Uruchom go z plikiem `.env`, a potem wyślij `/movie fight club` swojemu botowi w Telegramie.

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

Dla `/movie fight club` pierwszym trafieniem jest film 550. Bot wysyła jego plakat, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, z tym podpisem:

```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 używa long polling, więc nie potrzebuje publicznego adresu. Utrzymuj proces przy życiu narzędziem, którego już używasz do usług.

## Kolejne kroki

- Dodaj `/trending` wywołaniem `trending/movie/week` ze strony [trendy i listy](https://amodb.dev/pl/trending-lists/).
- Pozwól użytkownikom wybrać jeden z pięciu wyników za pomocą klawiatury inline, korzystając z większej części odpowiedzi [wyszukiwanie](https://amodb.dev/pl/search/).
- Wysyłaj zwiastuny przez endpoint [wideo i zwiastuny](https://amodb.dev/pl/videos-trailers/).
- Zbuduj tę samą komendę dla [Discord](https://amodb.dev/pl/discord-movie-bot/).

## Pytania i odpowiedzi

### Czy bot potrzebuje publicznego serwera lub webhooka?

Nie. Bot używa long polling przez bot.start(), więc wysyła tylko żądania wychodzące. Uruchom go na dowolnej maszynie, która pozostaje online. Webhook jest opcjonalny i przydaje się tylko, jeśli wolisz dostarczanie push.

### Ile żądań amodb zużywa jedna komenda /movie?

Dwa: jedno wywołanie wyszukiwania i jedno wywołanie szczegółów z ocenami. Tytuł, który jest jeszcze w cache Map, nie zużywa żadnego. W planie z 20 żądaniami na minutę daje to około 10 nowych wyszukań na minutę.

### Dlaczego plakat czasem nie chce się wysłać?

Telegram sam pobiera zdjęcie z URL i odrzuca pliki, których nie może pobrać albo które przekraczają jego limity. Bot łapie ten błąd i zamiast milczeć wysyła podpis jako zwykły tekst.

### Czy bot może odpowiadać w innym języku?

Tak. Zmień wartość language, na przykład na ru-RU lub es-ES, w obu wywołaniach. Obsługiwanych jest czternaście języków bazowych, a każdy inny tag wraca do angielskiego. Tytuły i opisy przychodzą przetłumaczone, jeśli tłumaczenie istnieje.

### Gdzie trzymać token bota i klucz API?

W zmiennych środowiskowych, wczytywanych opcją env-file w Node.js lub menedżerem procesów, i nigdy w Git. Jeśli token bota wycieknie, utwórz nowy przez BotFather. Dla klucza amodb utwórz nowy w panelu konta.

## Powiązane strony

- [Poradnik bota Discord z filmami](https://amodb.dev/pl/discord-movie-bot/): Zbuduj bota Discord w discord.js v14 z komendą /movie, która odpowiada embedem z plakatem, ocenami i opisem z amodb, z ponawianiem po 429 i cache.
- [Poradnik API filmów w JavaScript](https://amodb.dev/pl/javascript-movie-api/): Napisz skrypt Node.js, który jednym żądaniem na ID pobiera szczegóły, obsadę i oceny filmów, z cache z TTL, ponawianiem po 429 i bez zależności.
- [API wyszukiwania](https://amodb.dev/pl/search/): API wyszukiwania z tymi samymi ścieżkami i JSON co TMDB v3. Znajdź filmy, seriale, osoby, kolekcje, firmy i słowa kluczowe po tekście, z filtrem roku.
- [Oceny filmów i seriali](https://amodb.dev/pl/ratings/): API ocen filmów zwraca wyniki z IMDb, Kinopoisk, Metacritic, Rotten Tomatoes i TMDB dla tytułu jednym wywołaniem, z null dla każdego nieznanego źródła.
- [Limity żądań](https://amodb.dev/pl/rate-limits/): amodb limituje klucz liczbą żądań na minutę w przesuwanym oknie 60 sekund: 20, 40 lub 120 zależnie od planu. Czytaj nagłówki limitu i obsłuż 429 z Retry-After.
- [Uwierzytelnianie](https://amodb.dev/pl/authentication/): Jak wysłać klucz amodb: api_key w zapytaniu, nagłówek Authorization Bearer albo apikey dla wywołań OMDb. Format klucza, przechowywanie, rotacja i błędy 401.

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