
The Analyst's Stack Part 10 of 22
Monitoring Telegram at scale
A monitoring platform for a messaging network: it watches a channel list without stopping, notices when the same text appears in several of them, translates what it collects, and puts the result on a map.
Why I built it
A verification desk ends up watching a lot of channels. Not because anyone decided to — each investigation adds two or three, nobody ever removes one, and within a year the list is longer than the working day.
Two things break at that size, and neither is the thing people expect.
The first is that most of what you read is the same message. Channels copy each other constantly. You read a claim, you read it again 40 minutes later under a different name, and you spend the second reading working out whether it is corroboration or an echo. Almost always it is an echo, and you cannot know that without reading it.
The second is language. A desk covers more languages than any one person on it reads, and pasting messages into a translation box one at a time guarantees you only look at what you already suspected was interesting.
Underneath both sits the ordinary problem: channels delete things, and a message you did not collect is a message you cannot cite.
What it solves
The collector walks the whole channel list with 8 seconds between one channel and the next, then pauses 60 seconds before the next pass; both are adjustable while it runs. The gap between something being posted and it being a row in a database you own is bounded and, more usefully, known.
Every message is stored once, with its original text, its media, and its platform identifier. Text that has already appeared in another channel is tagged as cross-posted rather than hidden, so the fifteenth copy costs a glance instead of a read.

cross_posted tag on every copy
of something already seen elsewhere. Captured from the real dashboard running on a separate demo database: every channel and message is invented.How it works
Collect. A client logged in as a user account, not a bot, walking the channel list on a loop. Albums arrive as several messages sharing a group identifier and are kept together. Media downloads only for channels marked for it.
Normalize. Text is stripped, lowercased, Unicode-normalized and whitespace-collapsed, then hashed. Keyword and place-name lists are matched against the text and become tags. A hash already seen in another channel becomes a cross-posted tag.
Store. PostgreSQL, one row per message, unique on the pair of channel and message identifier, so re-reading a channel writes nothing new. Tags live in a JSON column so a new tag does not need a schema change.
Analyze. Two passes. Translation first, into the desk’s working languages — the platform’s own where it is available, a model as fallback. Then, on a set you choose, an analysis pass against whichever provider is configured.
Alert. When an analysis finishes, a message goes into a chat channel linking back to the result. Workers report a heartbeat, because a stopped collector and a quiet channel list look identical.
Present. The half that took longest. A message browser with filters and saved queries, a map of the messages carrying real coordinates, collections for grouping a thread, and an export.

How I built it
Python 3.12 on the back end — FastAPI, SQLAlchemy in async mode, Telethon for the platform client, Alembic for migrations — and a React front end. PostgreSQL 16 outside the application. Three containers: an API, a worker, and the front end.
The stack is ordinary. Three decisions inside it are not.
The queue is a database table. Jobs — scrape, translate, analyze, backfill — are rows with a status, claimed by the worker a bounded number at a time. No broker, no second piece of infrastructure to keep alive. A job stuck in “running” past 2 hours is marked failed so the queue keeps moving. I would choose this again; the queue has never been the thing that broke.
The session is the scarce resource, not the CPU. One login does all the talking, subject to the platform’s own rate limiting. So the worker routes jobs: anything needing that login is serialized behind it, anything else runs alongside. Most of the concurrency work was about protecting one connection, not about going faster.
Translate before you analyze. Obvious in hindsight. The model sees text in a language it handles well, the analyst sees the original, and the two are never confused.
The hard part was coordinates. The first version pulled them out of message text with a pattern match, which produced a map covered in places nothing had happened — numbers in text look like coordinates constantly. Now only a message with an actual location attachment gets a position. That loses real hits and is still the better trade: a map you can trust with gaps beats a map that is mostly noise.
What I would do differently is media. Downloaded files accumulate with no retention policy, and a media-heavy channel list becomes tens of gigabytes without announcing itself. Cleanup is still a human with a disk usage tool. It should have been a job in the queue from the first week.
A look at the code
Cross-posting detection, rewritten to stand alone. A fingerprint of the normalized text is the identity of a claim:
import hashlib
import unicodedata
def content_key(text):
"""A fingerprint of what was said, ignoring who said it and how it was spaced."""
t = unicodedata.normalize("NFC", (text or "").strip().lower())
t = " ".join(t.split()) # runs of whitespace and newlines collapse
return hashlib.sha256(t.encode()).hexdigest() if t else None
# Two channels, one claim:
content_key("Reports of an incident near the depot.")
content_key(" reports of an INCIDENT near the depot. ")
# -> both '126c798d9314...' (same 64-character key)
SEEN_ELSEWHERE = """
SELECT 1 FROM messages
WHERE content_key = ? AND channel <> ?
LIMIT 1
"""
A match adds a tag. It never deletes a row, and that is deliberate: which copy came first, and how fast the rest followed, is often the more interesting question.
The translation fallback — try the cheap route, then the model, and record which one answered:
async def translate(text, to_lang, routes):
"""`routes` is an ordered list of (name, async_fn). First usable answer wins."""
last_error = None
for name, fn in routes:
try:
out = await fn(text, to_lang)
except Exception as exc:
last_error = f"{name}: {exc}"
continue
if out and out.strip():
return {"text": out, "via": name}
return {"text": None, "via": None, "error": last_error}
Recording via matters more than it looks. When a translation reads oddly 6 weeks
later, the first question is which route produced it, and without that field the answer
is a shrug.

What it can’t do
Duplicate detection catches copy-paste, not paraphrase
Machine translation is for triage only
The account is the ceiling
The conceptual limit has not moved since the foundation posts. Nothing here verifies anything. Every claim that leaves the building was read by a person in the original language.

Where this sits in the series
This is the 6-stage spine with every stage grown to full size — the same shape as the one-file watcher, except each stage there is a function and here it is a component with its own failure modes.
Stage 4 is the comparison worth making. The watcher has nothing in it. This has 2 AI steps, and both do the narrowing job described in where AI actually fits: translation for skimming, and an analysis pass over a set you chose. Neither decides anything.
Two harder pieces get their own posts. Does your AI step actually work? is how to measure whether the cross-posting tag earns its place, and storing and searching what you collect is the identity question underneath it.