Skip to main content
  1. My Blog Posts and Stories/

Modernising the Live Countdown Telegram Bot

··1584 words·8 mins

This blog post is based on the Telegram Timer Bot project, and it continues where my earlier post A Live Countdown Telegram Bot left off.

Introduction #

In the original post, I built a Telegram bot that showed a live countdown for events. The bot worked, but it had a hidden cost: every active countdown made the bot wake up every 30 seconds, calculate the time left, and edit the message. The countdown never felt live. It jumped forward in 30-second steps, and each step was an API request. I could not make it smoother by editing more often, because Telegram rate-limits message edits.

While reviewing the project, I discovered that Telegram has a native way to render countdowns. The <tg-time> entity tells the Telegram client to draw a live ticking timer inside the message. The client does all the work. No polling, no edits, no loop.

However, using <tg-time> forced a migration. The bot used pyrogram (now deprecated), which parses HTML on the client side and silently drops tags it does not recognise. <tg-time> was one of those tags. The only reliable way to use it was to talk to the Bot API directly, which led me to python-telegram-bot.

In this blog post, I will go through why the migration was needed, how the new countdown works, and the fixes that came out of the merge request.

Prerequisites #

The migration made the setup simpler. In the original post, you needed three things:

  1. Your API ID
  2. Your API Hash
  3. Your bot token

The new bot needs only your bot token. That is because the old bot used pyrogram, which talks to Telegram through the MTProto protocol and needs the API ID and API Hash. python-telegram-bot talks to the Bot API over plain HTTPS, so a token is enough.

Getting your bot token #

To get the bot token, visit the BotFather and follow the instructions to create a bot. Once you have created the bot, you will be given a token. Copy the token and save it somewhere safe.

The Inspiration #

The inspiration for this upgrade is different from the original project. Back then, a friend asked for an application that could help him track deadlines.

This time, the motivation came from a personal sweep. I have been going through my previous projects and upgrading them one by one, starting with the ones I find more interesting. The timer bot is one of them, and its polling loop was a clear weak point, so it became a natural candidate for this round of modernisation.

Goals of the Project #

The main goals of the project are:

  1. To keep the countdown live, with no visible jumping.
  2. To make the bot simpler to run, with fewer setup steps.
  3. To remove the polling loop and its constant API edits.

With that, let us dive into the migration.

The Migration #

Why pyrogram could not render <tg-time> #

pyrogram parses HTML on the client side before sending the message. Its parser has a small whitelist of tags, and anything outside the whitelist is silently dropped. <tg-time> is not in the whitelist. The message reaches Telegram with the tag already removed, so the entity never renders.

Note: This is a subtle failure. The message sends fine and no errors are raised. However, the unknown tag is just gone. If you test this, the countdown appears as a static placeholder instead of a ticking timer.

The two-step path #

  1. First, I tried an alternative approach: keep pyrogram, and send the countdown message through the raw Bot API with an httpx call. This proved that <tg-time> renders correctly. However, it left the bot with two different ways of sending messages, which was awkward.
  2. Then I migrated the bot to python-telegram-bot. It is a pure Bot API HTTP wrapper, so it does no client-side HTML parsing. parse_mode=HTML passes the entity through untouched, and the raw API workaround became unnecessary.

The new countdown #

The countdown message is built from a single format string:

TIMER_FORMAT = '<b>{event_name}</b>\n⏳ <tg-time unix="{unix}" format="r">0</tg-time>'

The bot sends this with parse_mode=HTML. Telegram renders the event name in bold, and the <tg-time> entity becomes a live countdown that the client keeps ticking.

The event name is HTML-escaped before it goes into the message. With parse_mode=HTML, a raw < or & in the event name would be treated as markup, so escaping is no longer optional.

Here is the complete new flow:

TelegramBotTelegramBotclient ticks the countdown on its ownloop[Until the deadline]User/timer 08/08/2026 22:34 Testsend countdown message (parse_mode=HTML)message sentschedule end job at the deadlineedit message to "Test has already ended :("message editedfinal messageUser

The diagram above shows the whole flow. The bot only acts twice:

  1. Once to send the message
  2. Once at the deadline to edit it

Everything in between happens on the Telegram client.

The other <tg-*> tags #

The <tg-time> entity is not the only Telegram-specific tag. In parse_mode=HTML, the Bot API supports these three tags with the tg- prefix:

TagPurpose
<tg-time unix="..." format="...">A live date-time entity that the client keeps updated. This is the tag the bot uses for the countdown.
<tg-spoiler>...</tg-spoiler>Spoiler text that stays hidden until the reader taps it.
<tg-emoji emoji-id="...">...</tg-emoji>An inline custom emoji, shown by its emoji ID.

The format attribute of <tg-time> controls how the timestamp is rendered. The bot uses format="r" for the relative countdown; the full list of formats is in the date-time entity formatting section of the Bot API docs.

Note: The other tg-* tags you may see in the docs (e.g. tg-map, tg-math, tg-collage, tg-slideshow, tg-reference) only work in rich messages sent with sendRichMessageDraft. They are not supported in regular parse_mode=HTML messages like the ones this bot sends. You can browse the full list of rich message tags in the Rich HTML style section of the docs.

Scheduling the end edit #

The old bot polled forever. The new bot schedules only one edit to update the message to "has already ended :(" when the deadline passes. python-telegram-bot provides this through its JobQueue, which is backed by APScheduler.

event_jobs[key] = context.job_queue.run_once(
    end_countdown, when=deadline.astimezone(), data=key)
Note: JobQueue treats naive datetimes as UTC. If you pass when=deadline without .astimezone(), the job fires at the wrong time for any timezone east or west of UTC.

Cancellation works the same way. /cancel removes the job and edits the message to "is cancelled :(" immediately, instead of waiting for the next poll.

The comparison #

AspectBefore (pyrogram)After (python-telegram-bot)
CountdownBot polls every 30 seconds and edits the messageTelegram client renders <tg-time> natively
UpdatesOne API edit per timer per 30 secondsNo edits until the deadline
Dependenciespyrogram, TgCryptopython-telegram-bot, APScheduler
SetupAPI ID, API Hash, bot tokenBot token only
End editPolling loop notices the deadlineJobQueue fires once at the deadline
CancellationLoop checks storage on each tickJob removed and message edited immediately

What We Fixed Along the Way #

The help button became a command #

The original bot showed help through a HELP ❓ button on the start message. The button was an alternative way to navigate the bot, but it kept misbehaving. After several attempts to harden the callback path, I decided the button was not worth the complexity.

  1. I added a plain /help command that replies with the help text directly.
  2. I removed the HELP ❓ button from /start.
  3. I updated /start to tell users to run /help if they need help.
  4. I removed the entire callback system, the handler, the callback dictionary, and the MsgPack class that packaged them.

The help message was not rendering #

The help message had a second bug. The command formats used backticks, which only render as code when a parse mode is set. The reply was sent without a parse mode, so the backticks appeared literally in the chat.

The fix was to use <code> tags instead, and to send the help and error messages with parse_mode=HTML, the same mode the countdown message already used.

Deleting code is a feature #

The button removal deleted the MsgPack class, the callback handler, three keyboard definitions, and their tests. Every one of those was code that had to be tested, documented, and understood. Once the button was gone, none of it had a reason to exist. Shipping two ways to show help doubled the cost of the feature.

Try It Live #

A variant of this countdown bot is running live on Telegram as @realtime_countdown_bot. Feel free to open a chat with it and start a countdown of your own.

Note: The live bot does not support cancellation. Once a countdown starts, it runs until the deadline.

Conclusion #

The migration did not change what the bot does. It still takes a /timer command and shows a live countdown. What changed is how the countdown happens:

  1. The Telegram client renders the timer natively
  2. The bot makes exactly one edit when the deadline passes

The project remains a small personal tool, but it is now simpler to run and easier to maintain. Feel free to clone the repository and make changes to suit your needs.

Stay tuned for more posts on this project.

  1. A Live Countdown Telegram Bot
  2. Telegram Timer Bot
  3. python-telegram-bot
  4. Telegram Bot API
  5. BotFather
  6. Telegram Bot API: Date-time Entity Formatting
  7. Telegram Bot API: Rich HTML Style