A Telegram group for a film club, a channel for a city, or just a bot in your pocket: type /showtimes Lyon and get what is still on today, film by film; or tap a button, share your location, and get the cinemas within three kilometres. This tutorial builds that bot in Python with python-telegram-bot, in two files.
Showtimes in Lyon, 2026-11-09
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
…What you need
- Python 3.10 or later.
- An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 100 requests a week for trials and non-commercial projects; the bot keeps each answer 15 minutes, so a question asked twice costs one request.
- A Telegram bot: in Telegram, open a chat with @BotFather, send
/newbot, pick a name and a username ending inbot. BotFather answers with a token like123456789:AA….
Set up the project
mkdir showtimes-bot && cd showtimes-bot
python -m venv .venv && source .venv/bin/activate
pip install "python-telegram-bot==22.8"python-telegram-bot brings httpx, which the bot also uses to call the API. The two secrets go in environment variables, never in the code:
export TELEGRAM_TOKEN="123456789:AA…"
export API_CINEMA_KEY="ac_live_…"Read the showtimes: showtimes.py
Two functions: one reads the rest of today's showtimes, in a city or around a point, the other writes them as one Telegram message.
"""Read today's showtimes from API Cinéma and write them as a Telegram message."""
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]:
"""The rest of today's showtimes in a city, or within 3 km of a point (each cinema's local time)."""
params = {"include": "movie,cinema", "limit": "1000"}
if city:
params["city"] = city
else:
params["near"] = f"{lat:.3f},{lng:.3f}" # three decimals: about 100 m, enough and kinder to privacy
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]
# without a date the API starts from now, sorted by time: read pages until the first day is over
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): # a big city has a few thousand showtimes a day: five pages at most
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:
"""One block per film, the most programmed first, each cinema on a line with its times (Telegram HTML)."""
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)
# a Telegram message holds 4,096 characters: stop before, and say how many films are left out
if len(text) + len(block) > max_length - 40:
left = len(ordered) - shown
text += f"\n\n… and {left} more film{'s' if left > 1 else ''}"
break
text += block
return textA few details:
- Without
dateorfrom, the API starts from now, in each cinema's local time, sorted by time. The function keeps the rows of the first date, and reads the next page (offset) only while that day is not over: Lyon fits in one request, Paris needs two. startsAtis the cinema's wall-clock time (2026-11-09T20:30:00), so[11:16]gives20:30whatever the time zone of the machine that runs the bot.- A location is rounded to three decimals (about 100 metres) before it leaves the bot: enough to find the cinemas around, and the cache works for neighbours too.
- A Telegram message holds 4,096 characters. The text stops at the last film that fits, and says how many are left out. Titles and cinema names go through
escape, because the message is sent as HTML.
The bot: bot.py
Three handlers: /start (with a button that shares the location), /showtimes <city> [vost|vo|vf], and a shared location.
"""A Telegram bot that answers with today's showtimes: /showtimes <city>, or a shared location."""
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) # its INFO lines would print the bot token
VERSIONS = {"vost", "vo", "vf"}
KEYBOARD = ReplyKeyboardMarkup([[KeyboardButton("📍 Showtimes near me", request_location=True)]], resize_keyboard=True)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"Send /showtimes followed by a city (/showtimes Lyon, /showtimes Chicago vost), "
"or share your location for the cinemas within 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: # an error status (401 wrong key, 429 quota used up) or no network
logging.warning("API Cinéma request failed: %r", error)
await update.message.reply_text("The showtimes are unavailable right now, try again later.")
return
if not showtimes:
await update.message.reply_text("No more showtimes today there.")
return
await update.message.reply_text(
to_message(f"{title}, {showtimes[0]['startsAt'][:10]}", showtimes),
parse_mode=ParseMode.HTML,
)
async def showtimes_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("Which city? For example: /showtimes Lyon")
return
await reply(update, f"Showtimes in {city}", city=city, version=version)
async def location(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
point = update.message.location
await reply(update, "Showtimes within 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("showtimes", showtimes_command))
app.add_handler(MessageHandler(filters.LOCATION, location))
app.run_polling()
if __name__ == "__main__":
main()python bot.pyOpen a chat with your bot and send /start, then /showtimes Lyon. On a phone, the 📍 Showtimes near me button sends your position, and the bot answers with the cinemas within 3 km. /showtimes Chicago vost keeps the original versions with subtitles; it works the same in any city of the API, in France, the United States or Australia.
To get the commands in Telegram's menu, send /setcommands to BotFather, pick your bot and paste:
showtimes - Today's showtimes in a city, e.g. /showtimes Lyon
start - How to use the botIf the key is wrong or the weekly quota is used up, the bot answers The showtimes are unavailable right now and writes the reason in its log; it keeps running.
Going further
- Paris, France or Paris, Texas.
citymatches the town name in every country of the API. Addparams["country"] = "FR"(orUS,AU) infetch_showtimesfor a bot that serves one country. - In a group. Add the bot to a group:
/showtimes Lyonworks there too. The location button only shows in private chats, as Telegram wants. - A daily post in a channel. python-telegram-bot has a job queue (
pip install "python-telegram-bot[job-queue]==22.8"):app.job_queue.run_daily(callback, time=datetime.time(17, 0)), and in the callbackawait context.bot.send_message(chat_id, to_message(…), parse_mode=ParseMode.HTML). Make the bot an administrator of the channel first. - Links to book. Each showtime has
bookingUrl: write the time as<a href="…">20:30</a>into_message(escape the URL withescape(url, quote=True)). Telegram counts the text without the tags, but keep an eye on the length. - Run it for good.
run_pollingneeds no public address: any small server, a Raspberry Pi or a free container plan keeps the bot online; asystemdservice restarts it after a reboot.
Every parameter and field is in the documentation. The same idea for Discord is the Discord bot, and the Python tutorial goes further with pandas, CSV and a weekly report.