Sur cette page
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 parbot. BotFather répond avec un jeton du genre123456789:AA….
Préparer le projet
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 :
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.
"""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 textQuelques détails :
- Sans
datenifrom, 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. startsAtest l’heure affichée au cinéma (2026-11-09T20:30:00) :[11:16]donne20: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.
"""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()python bot.pyOuvrez 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 botSi 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.
citycherche le nom de la ville dans tous les pays de l’API. Ajoutezparams["country"] = "FR"(ouUS,AU) dansfetch_showtimespour un bot qui ne sert qu’un pays. - Dans un groupe. Ajoutez le bot à un groupe :
/seances Lyony 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 callbackawait 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>dansto_message(échappez l’URL avecescape(url, quote=True)). Telegram compte le texte sans les balises, mais surveillez la longueur. - Le faire tourner pour de bon.
run_pollingn’a pas besoin d’adresse publique : un petit serveur, un Raspberry Pi ou une offre de conteneur gratuite gardent le bot en ligne ; un servicesystemdle 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.