# Tutoriel bot Telegram de films

> Ce tutoriel construit un bot Telegram avec grammY sur Node.js. Envoyez /movie fight club et le bot répond avec l'affiche, l'année, la durée et la note d'amodb. Il lit les deux jetons dans des variables d'environnement, réessaie sur 429 avec Retry-After, met les réponses en cache en mémoire et s'arrête sur une clé rejetée. Il prend environ 20 minutes.

## Ce que vous construisez

Vous construisez un bot Telegram qui répond à `/movie fight club` avec l'affiche, le titre, l'année, la durée et la note. Il vous faut Node.js 20.6 ou plus récent, un jeton de bot obtenu auprès de BotFather dans Telegram et une clé amodb. Le bot utilise grammY et le `fetch` intégré : un seul paquet npm suffit. Prévoyez environ 20 minutes.

## Étape 1 : obtenir deux identifiants

Choisissez une offre sur les [tarifs](https://amodb.dev/fr/pricing/). L'offre fixe combien de requêtes par minute votre bot peut envoyer. Créez une clé dans le tableau de bord de votre compte, longue de 32 caractères hexadécimaux. Ouvrez ensuite une conversation avec BotFather dans Telegram, envoyez `/newbot`, répondez aux deux questions et copiez le jeton qu'il affiche.

Placez les deux valeurs dans un fichier `.env` et ajoutez ce fichier à `.gitignore` :

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

## Étape 2 : créer le projet

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

Le réglage `type` active la syntaxe `import` et l'`await` de niveau supérieur. La page [authentification](https://amodb.dev/fr/authentication/) présente les formes en en-tête et en `api_key` de la clé.

## Étape 3 : répondre à la première commande

grammY place le texte qui suit une commande dans `ctx.match`. Commencez par une fonction d'aide qui envoie l'en-tête Bearer et une commande qui cherche.

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

## Étape 4 : construire la réponse avec une affiche

Prenez le premier résultat de recherche, chargez-le avec `append_to_response=ratings` et construisez une légende. L'URL de l'affiche est `https://img.amodb.dev/t/p/w500` plus `poster_path`. Telegram télécharge lui-même la photo, et `img.amodb.dev` n'exige aucune clé. Une légende contient au plus 1024 caractères : le code coupe donc le résumé.

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

Chaque source de `ratings` est un objet avec `value` et `votes`, ou `null`. Quand `imdb` vaut `null`, la légende retombe sur le `vote_average` agrégé. La page [notes](https://amodb.dev/fr/ratings/) explique les cinq sources.

## Étape 5 : gérer les erreurs et les limites de débit

Laissez les gestionnaires lever des exceptions et interceptez tout à un seul endroit. Sans `bot.catch`, grammY arrête le bot quand un gestionnaire lève une exception.

| Cas                            | Cause                                  | Réaction du bot                                                                                                              |
| ------------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 401                            | Clé absente, fausse ou suspendue       | La consigner, prévenir la conversation, quitter. Chaque commande échouerait.                                                 |
| 429                            | Au-dessus de vos requêtes par minute   | Attendre `Retry-After` secondes, plafonnées à 30, réessayer deux fois, puis demander à l'utilisateur de réessayer plus tard. |
| `results` vide                 | Aucun titre ne correspond à la requête | Répondre qu'aucun film n'a été trouvé.                                                                                       |
| Délai dépassé ou erreur réseau | `fetch` lève une exception             | Répondre par de brèves excuses et consigner l'erreur.                                                                        |
| Affiche rejetée                | Telegram ne peut pas récupérer l'image | Envoyer la légende en texte.                                                                                                 |

La boucle ci-dessous se trouve dans `getJson`. `AmodbError` est une petite sous-classe de `Error` avec un champ `status`, visible dans le listing final.

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

Chaque commande coûte deux requêtes : l'offre à 20 requêtes par minute sert donc environ 10 nouvelles recherches par minute. Voir [limites de débit](https://amodb.dev/fr/rate-limits/) et [erreurs](https://amodb.dev/fr/errors/) pour les détails.

## Étape 6 : mettre les réponses en cache

La vérification de la limite de débit passe avant le cache de périphérie : seul un cache dans votre propre bot économise des requêtes. Gardez une `Map` de l'URL vers les données et une heure d'expiration. Utilisez les durées de la périphérie : 10 minutes pour la recherche et 6 heures pour les détails. Un plafond de taille empêche un bot qui tourne longtemps de grossir sans limite.

Les lignes du cache se trouvent aussi dans `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 });
```

## Étape 7 : le bot complet

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

Démarrez-le avec le fichier `.env`, puis envoyez `/movie fight club` à votre bot dans Telegram.

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

Pour `/movie fight club`, le premier résultat de recherche est le film 550. Le bot envoie son affiche, `https://img.amodb.dev/t/p/w500/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, avec cette légende :

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

Le bot utilise l'interrogation longue : il n'a donc besoin d'aucune adresse publique. Gardez le processus actif avec l'outil que vous utilisez déjà pour les services.

## Pour aller plus loin

- Ajoutez `/trending` avec l'appel `trending/movie/week` de [tendances et listes](https://amodb.dev/fr/trending-lists/).
- Laissez les utilisateurs choisir l'un de cinq résultats avec un clavier en ligne, en exploitant davantage la réponse de [recherche](https://amodb.dev/fr/search/).
- Envoyez des bandes-annonces avec le point de terminaison [vidéos et bandes-annonces](https://amodb.dev/fr/videos-trailers/).
- Construisez la même commande pour [Discord](https://amodb.dev/fr/discord-movie-bot/).

## Questions et réponses

### Le bot a-t-il besoin d'un serveur public ou d'un webhook ?

Non. Le bot utilise l'interrogation longue via bot.start(), donc il n'émet que des requêtes sortantes. Lancez-le sur n'importe quelle machine qui reste en ligne. Un webhook est facultatif et n'est utile que si vous préférez une livraison en push.

### Combien de requêtes amodb une commande /movie utilise-t-elle ?

Deux : un appel de recherche et un appel de détails avec les notes. Un titre encore présent dans le cache Map n'en utilise aucune. Avec l'offre à 20 requêtes par minute, cela permet environ 10 nouvelles recherches par minute.

### Pourquoi l'affiche ne s'envoie-t-elle parfois pas ?

Telegram télécharge lui-même la photo depuis l'URL, et il rejette les fichiers qu'il ne peut pas récupérer ou qui dépassent ses limites. Le bot intercepte cette erreur et envoie la légende en texte simple au lieu de rester silencieux.

### Le bot peut-il répondre dans une autre langue ?

Oui. Changez la valeur de language, par exemple ru-RU ou es-ES, dans les deux appels. Quatorze langues de base sont servies, et toute autre balise se replie sur l'anglais. Les titres et les résumés arrivent traduits quand une traduction existe.

### Où garder le jeton du bot et la clé d'API ?

Dans des variables d'environnement, chargées avec l'option env-file de Node.js ou votre gestionnaire de processus, et jamais dans Git. Si le jeton du bot fuite, créez-en un nouveau avec BotFather. Pour une clé amodb, créez-en une nouvelle dans le tableau de bord de votre compte.

## Pages liées

- [Tutoriel bot Discord de films](https://amodb.dev/fr/discord-movie-bot/): Créez un bot Discord avec discord.js v14 et une commande slash /movie qui répond par un embed avec affiche, notes et résumé depuis amodb, avec relance sur 429.
- [Tutoriel API de films JavaScript](https://amodb.dev/fr/javascript-movie-api/): Un script Node.js qui charge détails, crédits et notes d'une liste d'ids de films en une requête chacun, avec cache TTL, relance 429 et sans dépendance.
- [API de recherche](https://amodb.dev/fr/search/): API de recherche aux chemins et au JSON de TMDB v3 : films, séries, personnes, collections, sociétés et mots-clés par texte, filtres d'année et pagination.
- [Notes des films et séries](https://amodb.dev/fr/ratings/): API de notes de films : scores IMDb, Kinopoisk, Metacritic, Rotten Tomatoes et TMDB d'un titre en un appel, avec null pour toute source inconnue.
- [Limites de débit](https://amodb.dev/fr/rate-limits/): amodb limite chaque clé en requêtes par minute sur une fenêtre glissante de 60 secondes : 20, 40 ou 120 selon l'offre. Lisez les en-têtes et gérez le 429.
- [Authentification](https://amodb.dev/fr/authentication/): Comment envoyer votre clé amodb : api_key dans la requête, en-tête Authorization Bearer, ou apikey pour OMDb. Format, stockage, rotation et erreurs 401.

Mis à jour: 2026-10-18. Source: https://amodb.dev/fr/telegram-movie-bot/
