Salaheldinaz

Search posts

Table of contents
AI-generated illustration: Dozens of pale cables running down into an old switchboard on a desk, lit by a desk lamp beside a rotary telephone.

The Analyst's Stack Part 10 of 22

Monitoring Telegram at scale

8 min read osint automation ai telegram monitoring project
The Analyst's Stack · Showcase

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.

The dashboard’s message stream: 18 invented messages from 3 fictional channels ending in _demo, each row with a date, channel, the start of its text, a geo marker where a location was attached, translation status, and tags such as shipping, navigation and port. Five rows carry a cross_posted tag: one notice copied into all 3 channels and another into 2.

Where the work happens. One row per message, and a 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

The 6 series stages, left to right: Collect walks every channel with a 60-second pause between passes, Normalize tags, hashes and dedupes, Store is Postgres with one row per message, Analyze translates then runs a model, Alert sends a message in chat, Present is a dashboard and map. Analyze is highlighted. The 6 series stages, left to right: Collect walks every channel with a 60-second pause between passes, Normalize tags, hashes and dedupes, Store is Postgres with one row per message, Analyze translates then runs a model, Alert sends a message in chat, Present is a dashboard and map. Analyze is highlighted.
The same spine as the one-file watcher. Every stage has grown into a component.

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.

A single message’s detail view: an invented channel name and date, the original text in Russian, and below it the English translation, “The bulk carrier Veresk-3 reports shifted cargo and asks permission to anchor in the outer harbour. The crew is safe.”

The original is never replaced. The translation sits with the text it came from, so the thing you quote is still the thing that was posted. Captured from the real dashboard running on a separate demo database: every channel and message is invented.

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 job queue is a database table with one status column. A job row starts as pending, a worker claims a few at a time and it becomes running, and a finished job becomes done. A job still running after 2 hours is marked failed instead, so the queue keeps moving. The job queue is a database table with one status column. A job row starts as pending, a worker claims a few at a time and it becomes running, and a finished job becomes done. A job still running after 2 hours is marked failed instead, so the queue keeps moving.
The whole queue is a status column. The timeout is what keeps a stuck job from holding up the rest.

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.

The worker routes each job to one of 2 lanes. Jobs that need the platform login, such as scrape and backfill, queue in single file behind one session that makes one request at a time. Jobs that do not need the login, such as analysis, run alongside them against the analysis provider. Translate jobs are not drawn: the platform's own translation goes through the login, the model fallback does not. The login, not the processor, sets the pace. The worker routes each job to one of 2 lanes. Jobs that need the platform login, such as scrape and backfill, queue in single file behind one session that makes one request at a time. Jobs that do not need the login, such as analysis, run alongside them against the analysis provider. Translate jobs are not drawn: the platform's own translation goes through the login, the model fallback does not. The login, not the processor, sets the pace.
Only the jobs that need the login wait for it. Everything else runs alongside instead of queueing behind a scrape.

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
"""
The same sentence posted in 4 channels, differing only in capitalisation and spacing. Normalizing and hashing each one produces the same content key, 126c798d9314. The first copy is the one you read; copies 2, 3 and 4 are tagged cross-posted and cost a glance. The same sentence posted in 4 channels, differing only in capitalisation and spacing. Normalizing and hashing each one produces the same content key, 126c798d9314. The first copy is the one you read; copies 2, 3 and 4 are tagged cross-posted and cost a glance.
The key is worked out before anything knows which channel it came from. A channel added to the list tomorrow falls into the same group with no configuration at all.

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.

A map of open sea with 11 red points, one per message that carried a location. One point’s popup is open, showing an invented channel, a date, the message “The cargo ship Lumen Arrow reports a rudder failure and requests an escort to the outer anchorage.” and its coordinates.

Only messages that carried a real location attachment. The sparseness is the honest part. Captured from the real dashboard running on a separate demo database: every channel and message is invented. Base map © OpenStreetMap contributors.

What it can’t do

Duplicate detection catches copy-paste, not paraphrase

The key is an exact fingerprint of normalized text. A reworded repost, a translated repost, or the same claim with one emoji added is a different key and a separate row. It catches the easy half and misses the harder one.

Machine translation is for triage only

Wrong often enough about dialect, idiom and numbers that quoting from it is a mistake. When it fails outright the message is flagged and left untranslated rather than silently skipped — but the alarm for a provider going down only fires after an hour of continuous failure, which is a long time to be quietly producing nothing.

The account is the ceiling

Collection needs an account with access to the channels, so anything private stays invisible. The analysis pass is rate limited and asks you to confirm anything above 20 messages, because an unattended bulk run against a paid provider is how you learn what a mistake costs.

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.

The jobs tracker: a table of queued work with columns for ID, type, session, status, progress, created and completed. Rows mix scrape, translate and analyze jobs; several are completed with full progress bars, some are running part-way, and some are pending.

Stage 6 includes the boring screen. Knowing what is queued, running and done is worth a page. Captured from the real dashboard running on a separate demo database: every channel and message is invented.

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.