Un bot Telegram pour les séances de cinéma, en Python

Deux fichiers avec python-telegram-bot : /seances Lyon répond avec ce qui passe encore aujourd’hui, film par film, et un bouton partage votre position pour les cinémas à moins de 3 km.

5 min de lecture

Sur cette page
  1. Ce qu’il vous faut
  2. 1.Préparer le projet
  3. 2.Lire les séances
  4. 3.Le bot
  5. Pour aller plus loin

Un groupe Telegram de ciné-club, une chaîne locale, ou juste un bot dans la poche : tapez /seances Lyon et recevez ce qui passe encore aujourd’hui, film par film ; ou touchez un bouton, partagez votre position, et recevez les cinémas à moins de trois kilomètres. Ce tutoriel construit ce bot en Python avec python-telegram-bot, en deux fichiers.

Séances à Lyon, le 09/11

Moulin
Lumière Terreaux: 15:20 VFST, 17:55, 20:00
Pathe Vaise: 16:00, 18:55, 21:30
Comoedia: 16:25, 18:15 VFST, 20:35

Wild Horse Nine
Comoedia: 15:50 VOST, 18:20 VOST, 20:50 VOST
…

Ce qu’il vous faut

  • Python 3.10 ou plus récent.
  • Une clé API Cinéma : créez un compte gratuit, confirmez votre adresse e-mail, puis créez une clé depuis le tableau de bord. Le forfait gratuit donne 100 requêtes par semaine pour les tests et les projets non commerciaux ; le bot garde chaque réponse 15 minutes, une question posée deux fois coûte donc une requête.
  • Un bot Telegram : dans Telegram, ouvrez une conversation avec @BotFather, envoyez /newbot, choisissez un nom et un identifiant qui finit par bot. BotFather répond avec un jeton du genre 123456789:AA….

Préparer le projet

Terminal
mkdir bot-seances && cd bot-seances
python -m venv .venv && source .venv/bin/activate
pip install "python-telegram-bot==22.8"

python-telegram-bot installe httpx, que le bot utilise aussi pour appeler l’API. Les deux secrets vont dans des variables d’environnement, jamais dans le code :

Terminal
export TELEGRAM_TOKEN="123456789:AA…"
export API_CINEMA_KEY="ac_live_…"

Lire les séances : showtimes.py

Deux fonctions : l’une lit les séances qui restent aujourd’hui, dans une ville ou autour d’un point, l’autre les écrit en un message Telegram.

showtimes.py
"""Lire les séances du jour sur API Cinéma et les écrire en un message Telegram."""
import os
import time
from html import escape

import httpx

API = "https://api.api-cinema.com/v1"
CACHE_SECONDS = 15 * 60
_cache: dict[tuple, tuple[float, list[dict]]] = {}


async def fetch_showtimes(city: str | None = None, lat: float | None = None, lng: float | None = None,
                          version: str | None = None) -> list[dict]:
    """Les séances qui restent aujourd’hui dans une ville, ou à moins de 3 km d’un point (heure locale de chaque cinéma)."""
    params = {"include": "movie,cinema", "limit": "1000"}
    if city:
        params["city"] = city
    else:
        params["near"] = f"{lat:.3f},{lng:.3f}"  # trois décimales : environ 100 m, assez précis et plus discret
        params["radius_km"] = "3"
    if version:
        params["version"] = version

    key = tuple(sorted(params.items()))
    cached = _cache.get(key)
    if cached and time.monotonic() - cached[0] < CACHE_SECONDS:
        return cached[1]

    # sans date, l’API part de maintenant, triée par heure : lire les pages jusqu’à la fin du premier jour
    showtimes: list[dict] = []
    day = None
    async with httpx.AsyncClient(timeout=15, headers={"Authorization": f"Bearer {os.environ['API_CINEMA_KEY']}"}) as client:
        for _ in range(5):  # une grande ville a quelques milliers de séances par jour : cinq pages au plus
            res = await client.get(f"{API}/showtimes", params=params)
            res.raise_for_status()
            page = res.json()
            if not page["data"]:
                break
            day = day or page["data"][0]["startsAt"][:10]
            showtimes += [s for s in page["data"] if s["startsAt"].startswith(day)]
            offset = int(params.get("offset", 0)) + len(page["data"])
            if offset >= page["meta"]["total"] or not page["data"][-1]["startsAt"].startswith(day):
                break
            params["offset"] = str(offset)
    _cache[key] = (time.monotonic(), showtimes)
    return showtimes


def to_message(title: str, showtimes: list[dict], max_length: int = 4000) -> str:
    """Un bloc par film, le plus programmé d’abord, chaque cinéma sur une ligne avec ses horaires (HTML Telegram)."""
    films: dict[str, dict] = {}
    for s in showtimes:
        film = films.setdefault(s["movie"]["id"], {"title": s["movie"]["title"], "cinemas": {}, "count": 0})
        label = s["startsAt"][11:16] + ("" if s["version"] in ("VF", "VO") else f" {s['version']}")
        film["cinemas"].setdefault(s["cinema"]["name"], []).append(label)
        film["count"] += 1

    ordered = sorted(films.values(), key=lambda f: f["count"], reverse=True)
    text = f"<b>{escape(title)}</b>"
    for shown, film in enumerate(ordered):
        lines = [f"{escape(cinema)}: {', '.join(times)}" for cinema, times in film["cinemas"].items()]
        block = f"\n\n<b>{escape(film['title'])}</b>\n" + "\n".join(lines)
        # un message Telegram contient 4 096 caractères : s’arrêter avant, et dire combien de films manquent
        if len(text) + len(block) > max_length - 40:
            left = len(ordered) - shown
            text += f"\n\n… et {left} autre{'s' if left > 1 else ''} film{'s' if left > 1 else ''}"
            break
        text += block
    return text

Quelques détails :

  • Sans date ni from, l’API part de maintenant, à l’heure locale de chaque cinéma, triée par heure. La fonction garde les lignes de la première date, et ne lit la page suivante (offset) que tant que ce jour n’est pas fini : Lyon tient en une requête, Paris en demande deux.
  • startsAt est l’heure affichée au cinéma (2026-11-09T20:30:00) : [11:16] donne 20:30, quel que soit le fuseau de la machine qui fait tourner le bot.
  • La position est arrondie à trois décimales (environ 100 mètres) avant de quitter le bot : assez pour trouver les cinémas autour, et le cache sert aussi aux voisins.
  • Un message Telegram contient 4 096 caractères. Le texte s’arrête au dernier film qui tient, et dit combien il en manque. Titres et noms de cinémas passent par escape, parce que le message part en HTML.

Le bot : bot.py

Trois gestionnaires : /start (avec un bouton qui partage la position), /seances <ville> [vost|vo|vf], et une position partagée.

bot.py
"""Un bot Telegram qui répond avec les séances du jour : /seances <ville>, ou une position partagée."""
import logging
import os

import httpx
from telegram import KeyboardButton, ReplyKeyboardMarkup, Update
from telegram.constants import ChatAction, ParseMode
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters

from showtimes import fetch_showtimes, to_message

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)  # ses lignes INFO afficheraient le jeton du bot
VERSIONS = {"vost", "vo", "vf"}
KEYBOARD = ReplyKeyboardMarkup([[KeyboardButton("📍 Séances près de moi", request_location=True)]], resize_keyboard=True)


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Envoyez /seances suivi d’une ville (/seances Lyon, /seances Paris vost), "
        "ou partagez votre position pour les cinémas à moins de 3 km.",
        reply_markup=KEYBOARD,
    )


async def reply(update: Update, title: str, **query) -> None:
    await update.message.chat.send_action(ChatAction.TYPING)
    try:
        showtimes = await fetch_showtimes(**query)
    except httpx.HTTPError as error:  # un statut d’erreur (401 mauvaise clé, 429 quota épuisé) ou pas de réseau
        logging.warning("échec de la requête API Cinéma : %r", error)
        await update.message.reply_text("Les séances sont indisponibles pour le moment, réessayez plus tard.")
        return
    if not showtimes:
        await update.message.reply_text("Plus de séance aujourd’hui par ici.")
        return
    await update.message.reply_text(
        to_message(f"{title}, le {showtimes[0]['startsAt'][8:10]}/{showtimes[0]['startsAt'][5:7]}", showtimes),
        parse_mode=ParseMode.HTML,
    )


async def seances_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    words = list(context.args)
    version = words.pop().upper() if words and words[-1].lower() in VERSIONS else None
    city = " ".join(words)
    if not city:
        await update.message.reply_text("Quelle ville ? Par exemple : /seances Lyon")
        return
    await reply(update, f"Séances à {city}", city=city, version=version)


async def location(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    point = update.message.location
    await reply(update, "Séances à moins de 3 km", lat=point.latitude, lng=point.longitude)


def main() -> None:
    app = Application.builder().token(os.environ["TELEGRAM_TOKEN"]).build()
    app.add_handler(CommandHandler(["start", "help"], start))
    app.add_handler(CommandHandler("seances", seances_command))
    app.add_handler(MessageHandler(filters.LOCATION, location))
    app.run_polling()


if __name__ == "__main__":
    main()
Terminal
python bot.py

Ouvrez une conversation avec votre bot et envoyez /start, puis /seances Lyon. Sur un téléphone, le bouton 📍 Séances près de moi envoie votre position, et le bot répond avec les cinémas à moins de 3 km. /seances Paris vost ne garde que les versions originales sous-titrées ; ça marche pareil dans toutes les villes de l’API, en France, aux États-Unis ou en Australie.

Pour avoir les commandes dans le menu de Telegram, envoyez /setcommands à BotFather, choisissez votre bot et collez :

seances - Les séances du jour dans une ville, ex. /seances Lyon
start - Comment utiliser le bot

Si la clé est fausse ou que le quota de la semaine est épuisé, le bot répond Les séances sont indisponibles pour le moment et écrit la raison dans son journal ; il continue de tourner.

Pour aller plus loin

  • Paris, France ou Paris, Texas. city cherche le nom de la ville dans tous les pays de l’API. Ajoutez params["country"] = "FR" (ou US, AU) dans fetch_showtimes pour un bot qui ne sert qu’un pays.
  • Dans un groupe. Ajoutez le bot à un groupe : /seances Lyon y marche aussi. Le bouton de position ne s’affiche qu’en conversation privée, c’est une règle de Telegram.
  • Un message quotidien dans une chaîne. python-telegram-bot a une file de tâches (pip install "python-telegram-bot[job-queue]==22.8") : app.job_queue.run_daily(callback, time=datetime.time(17, 0)), et dans le callback await context.bot.send_message(chat_id, to_message(…), parse_mode=ParseMode.HTML). Faites d’abord du bot un administrateur de la chaîne.
  • Des liens pour réserver. Chaque séance a un bookingUrl : écrivez l’horaire en <a href="…">20:30</a> dans to_message (échappez l’URL avec escape(url, quote=True)). Telegram compte le texte sans les balises, mais surveillez la longueur.
  • Le faire tourner pour de bon. run_polling n’a pas besoin d’adresse publique : un petit serveur, un Raspberry Pi ou une offre de conteneur gratuite gardent le bot en ligne ; un service systemd le relance après un redémarrage.

Tous les paramètres et champs sont dans la documentation. La même idée pour Discord, c’est le bot Discord, et le tutoriel Python va plus loin avec pandas, le CSV et un rapport hebdomadaire.