A Telegram bot for movie showtimes in Python

Two files with python-telegram-bot: /showtimes Lyon answers with what is still on today, film by film, and a button shares your location for the cinemas within 3 km. Cached, paginated, free plan enough.

5 min read

On this page
  1. What you need
  2. 1.Set up the project
  3. 2.Read the showtimes
  4. 3.The bot
  5. Going further

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 in bot. BotFather answers with a token like 123456789:AA….

Set up the project

Terminal
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:

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

showtimes.py
"""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 text

A few details:

  • Without date or from, 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.
  • startsAt is the cinema's wall-clock time (2026-11-09T20:30:00), so [11:16] gives 20:30 whatever 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.

bot.py
"""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()
Terminal
python bot.py

Open 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 bot

If 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. city matches the town name in every country of the API. Add params["country"] = "FR" (or US, AU) in fetch_showtimes for a bot that serves one country.
  • In a group. Add the bot to a group: /showtimes Lyon works 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 callback await 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> in to_message (escape the URL with escape(url, quote=True)). Telegram counts the text without the tags, but keep an eye on the length.
  • Run it for good. run_polling needs no public address: any small server, a Raspberry Pi or a free container plan keeps the bot online; a systemd service 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.