DEV Community

Sly Bye
Sly Bye

Posted on

Your link checker thinks deleted Telegram bots are alive

Delete a Telegram bot and https://t.me/your_deleted_bot keeps returning HTTP 200
with a page that looks completely normal. Every link checker I know of — CI actions,
directory scripts, monitoring cron jobs — reports it as healthy forever.

If you maintain anything that lists Telegram bots, some fraction of your list is
already dead and your checks are telling you it is fine.

Reproducing it

Pick a username that has never existed:

curl -s -o /dev/null -w "%{http_code}\n" https://t.me/nonexistent_test_bot_77712
# 200
Enter fullscreen mode Exit fullscreen mode

Two hundred. No redirect, no 404, no soft-404 marker in the body that a status check
would catch.

Where the truth is

The status code is useless here, but the Open Graph title is not. I measured four
usernames — two live bots, two that do not exist:

URL og:title
t.me/BookClassBot (live) BookClass
t.me/instanavy_bot (live) StoryViewer - anonymous instagram story viewer tool
t.me/nonexistent_test_bot_77712 Telegram: Contact @nonexistent_test_bot_77712
t.me/zzz_definitely_not_a_real_bot_9182 Telegram – a new era of messaging

A live bot puts its own display name in og:title. A dead one gets one of two
Telegram placeholders: Telegram: Contact @<username>, or — if the username is not
even syntactically valid — the generic Telegram – a new era of messaging.

That is the whole signal.

curl -s https://t.me/some_bot | grep -o '<meta property="og:title" content="[^"]*"'
Enter fullscreen mode Exit fullscreen mode

The check

Standard library only, no dependencies:

import re
import urllib.request

UA = "Mozilla/5.0 (compatible; linkcheck/1.0)"

DEAD_EXACT = {"Telegram – a new era of messaging", "Telegram"}
DEAD_PREFIX = "Telegram: Contact @"


def telegram_bot_exists(url: str) -> bool:
    """True if the bot behind a t.me URL still exists.

    HTTP status is not usable here: Telegram serves 200 with a placeholder page
    for usernames that were deleted or never existed. The Open Graph title is
    what actually differs.
    """
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=20) as resp:
        html = resp.read(200_000).decode("utf-8", "replace")

    m = re.search(r'<meta\s+property="og:title"\s+content="([^"]*)"', html)
    title = m.group(1).strip() if m else ""

    if not title:
        return False
    return title not in DEAD_EXACT and not title.startswith(DEAD_PREFIX)
Enter fullscreen mode Exit fullscreen mode

Note the em dash in Telegram – a new era of messaging. It is U+2013, not a hyphen.
I lost a few minutes to that one.

It survives the URL variants

Mini Apps get linked in several shapes, and the check holds for all of them — the
title still resolves to the app's real name:

t.me/DefyTONBot/app                            -> "DefyTON"
t.me/arena_defense_bot/arena_defense           -> "ARENA DEFENSE"
t.me/lexicon_snap_bot?startapp=cat_awesome     -> "LexiCon"
t.me/playful_mind_bot/play?startapp=cat_awesome_tma -> "Playful Mind"
Enter fullscreen mode Exit fullscreen mode

So you can point the check at whatever URL someone submitted, without normalising it
to the bare username first.

Why bother

Because directories rot silently, and silent rot is the thing that kills them.

A list of bots that returns 200 across the board looks maintained. Every entry is
green. Meanwhile a chunk of them lead to a Telegram page saying nothing, and the only
person who finds out is the reader who clicks — once, and then never trusts the list
again.

I hit this while building a catalog of Telegram Mini Apps. Every entry passed a
conventional link check. Only after adding the title check could I say the bots were
actually there. It now runs weekly in CI and flags anything that has quietly
disappeared.

Caveats worth stating

  • This depends on Telegram's markup, which is not an API contract. If they change the placeholder strings, the check goes stale. Pin the strings somewhere obvious and test against a known-dead username occasionally.
  • A bot that exists but is broken, banned, or unresponsive still passes. This tells you the username resolves — nothing more.
  • Fetch politely. This is one request per entry, on a schedule, not a crawl.

The general shape

The reusable lesson is not about Telegram. It is that HTTP 200 means the server
answered, not that the thing exists.
Plenty of platforms serve a decorated
"nothing here" page rather than a 404 — it is better for humans and worse for every
automated check pointed at them.

Whenever you are checking a resource on someone else's platform, the question to ask
is: what does this host return for something that definitely is not there? Find out
first, then write the check. Assuming a 404 you never verified is how a monitor ends
up reporting green for months.


The catalog this came out of is
here — the check lives in
scripts/check.py, public domain, take it. I also build
StoriesFly, which is where the Instagram-flavoured test
usernames above came from.

Top comments (1)

Collapse
 
fromzerotoship profile image
FromZeroToShip

"HTTP 200 means the server answered, not that the thing exists" is the sentence I wish I'd had two months earlier. I hit the same shape twice this month in completely different disguises.

First: a firewall in front of my systems answers 405 for requests it rejected for unrelated reasons — a suspicious-looking body, a blocklisted path. I spent an hour debugging HTTP methods. The status code was a claim made by whatever answered first, and that wasn't my application.

Second, and worse: a script of mine fetched 473 records. Every single HTTP call returned 200. But 340 of them were rate-limit failures that my code silently dropped, and it printed confident percentages computed from the 133 that survived. Per-call 200s, aggregate fiction.

One thing I'd add to your og:title check, because it's the direction I got burned in: that placeholder string is now a load-bearing assumption owned by someone else. If Telegram changes the wording, your checker doesn't error — it goes quietly back to reporting everything alive. Worth pinning one known-dead bot as a fixture the checker must still flag, so the day the string changes, the fixture fails instead of the whole catalog going green.