<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Salaheldinaz</title><link>https://salaheldinaz.com/</link><description>OSINT · Intelligence · Cyber Security · Automation · Technologist · Investigator</description><language>en-US</language><lastBuildDate>Thu, 29 Oct 2026 09:53:22 +0000</lastBuildDate><atom:link href="https://salaheldinaz.com/tags/telegram/" rel="self" type="application/rss+xml"/><item><title>Monitoring Telegram at scale</title><link>https://salaheldinaz.com/blog/monitoring-telegram-at-scale/</link><guid isPermaLink="true">https://salaheldinaz.com/blog/monitoring-telegram-at-scale/</guid><pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate><description>The same 6 stages as a one-file watcher, except every one of them has grown into a component: continuous collection, cross-posting detection, machine translation, an AI pass, a map and a dashboard.</description><category>osint</category><category>automation</category><category>ai</category><category>telegram</category><category>monitoring</category><category>project</category><content:encoded><![CDATA[<span class="hero-label not-prose">The Analyst&#39;s Stack · Showcase</span>

<p>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.</p>
<h2 id="why-i-built-it">Why I built it</h2>
<p>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.</p>
<p>Two things break at that size, and neither is the thing people expect.</p>
<p>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.</p>
<p>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.</p>
<p>Underneath both sits the ordinary problem: channels delete things, and a message you did
not collect is a message you cannot cite.</p>
<h2 id="what-it-solves">What it solves</h2>
<p>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.</p>
<p>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.</p>
<p><img src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-1-message-list_hu_71919aecda0b455b.webp" width="1210" height="1400" alt="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." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>Where the work happens.</strong> One row per message, and a <code>cross_posted</code> 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.</div>

<h2 id="how-it-works">How it works</h2>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-pipeline_light.svg" width="1160" height="190" alt="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." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-pipeline_dark.svg" width="1160" height="190" alt="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." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The same spine as <a href="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/">the one-file watcher</a>.</strong> Every stage has grown into a component.</figcaption>
</figure>

<p><strong>Collect.</strong> 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.</p>
<p><strong>Normalize.</strong> 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.</p>
<p><strong>Store.</strong> 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.</p>
<p><strong>Analyze.</strong> Two passes. Translation first, into the desk&rsquo;s working languages — the
platform&rsquo;s own where it is available, a model as fallback. Then, on a set you choose, an analysis pass
against whichever provider is configured.</p>
<p><strong>Alert.</strong> 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.</p>
<p><strong>Present.</strong> 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.</p>
<p><img src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-2-message-detail_hu_6c0e9fa789072849.webp" width="1004" height="390" alt="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.”" loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The original is never replaced.</strong> 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.</div>

<h2 id="how-i-built-it">How I built it</h2>
<p>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.</p>
<p>The stack is ordinary. Three decisions inside it are not.</p>
<p><strong>The queue is a database table.</strong> 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 &ldquo;running&rdquo; 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.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-queue_light.svg" width="840" height="300" alt="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." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-queue_dark.svg" width="840" height="300" alt="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." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The whole queue is a status column.</strong> The timeout is what keeps a stuck job from holding up the rest.</figcaption>
</figure>

<p><strong>The session is the scarce resource, not the CPU.</strong> One login does all the talking,
subject to the platform&rsquo;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.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-session_light.svg" width="880" height="320" alt="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&#39;s own translation goes through the login, the model fallback does not. The login, not the processor, sets the pace." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-session_dark.svg" width="880" height="320" alt="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&#39;s own translation goes through the login, the model fallback does not. The login, not the processor, sets the pace." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Only the jobs that need the login wait for it.</strong> Everything else runs alongside instead of queueing behind a scrape.</figcaption>
</figure>

<p><strong>Translate before you analyze.</strong> Obvious in hindsight. The model sees text in a language
it handles well, the analyst sees the original, and the two are never confused.</p>
<p>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.</p>
<p>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.</p>
<h2 id="a-look-at-the-code">A look at the code</h2>
<p>Cross-posting detection, rewritten to stand alone. A fingerprint of the normalized text is
the identity of a claim:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">hashlib</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">unicodedata</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">content_key</span><span class="p">(</span><span class="n">text</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;A fingerprint of what was said, ignoring who said it and how it was spaced.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="n">unicodedata</span><span class="o">.</span><span class="n">normalize</span><span class="p">(</span><span class="s2">&#34;NFC&#34;</span><span class="p">,</span> <span class="p">(</span><span class="n">text</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span><span class="o">.</span><span class="n">lower</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="n">t</span> <span class="o">=</span> <span class="s2">&#34; &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">split</span><span class="p">())</span>          <span class="c1"># runs of whitespace and newlines collapse</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">t</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span> <span class="k">if</span> <span class="n">t</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Two channels, one claim:</span>
</span></span><span class="line"><span class="cl"><span class="n">content_key</span><span class="p">(</span><span class="s2">&#34;Reports of an incident near the depot.&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">content_key</span><span class="p">(</span><span class="s2">&#34;  reports of an INCIDENT   near the depot. &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># -&gt; both &#39;126c798d9314...&#39;  (same 64-character key)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SEEN_ELSEWHERE</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">SELECT 1 FROM messages
</span></span></span><span class="line"><span class="cl"><span class="s2"> WHERE content_key = ? AND channel &lt;&gt; ?
</span></span></span><span class="line"><span class="cl"><span class="s2"> LIMIT 1
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span></code></pre></div><figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-crosspost_light.svg" width="1000" height="340" alt="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." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-crosspost_dark.svg" width="1000" height="340" alt="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." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The key is worked out before anything knows which channel it came from.</strong> A channel added to the list tomorrow falls into the same group with no configuration at all.</figcaption>
</figure>

<p>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.</p>
<p>The translation fallback — try the cheap route, then the model, and record which one
answered:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">translate</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">to_lang</span><span class="p">,</span> <span class="n">routes</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;`routes` is an ordered list of (name, async_fn). First usable answer wins.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">last_error</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">routes</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">out</span> <span class="o">=</span> <span class="k">await</span> <span class="n">fn</span><span class="p">(</span><span class="n">text</span><span class="p">,</span> <span class="n">to_lang</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">last_error</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">exc</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">out</span> <span class="ow">and</span> <span class="n">out</span><span class="o">.</span><span class="n">strip</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">out</span><span class="p">,</span> <span class="s2">&#34;via&#34;</span><span class="p">:</span> <span class="n">name</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="kc">None</span><span class="p">,</span> <span class="s2">&#34;via&#34;</span><span class="p">:</span> <span class="kc">None</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="n">last_error</span><span class="p">}</span>
</span></span></code></pre></div><p>Recording <code>via</code> 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.</p>
<p><img src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-3-map_hu_58b21e93fdd327f0.webp" width="860" height="806" alt="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." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>Only messages that carried a real location attachment.</strong> 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.</div>

<h2 id="what-it-cant-do">What it can&rsquo;t do</h2>
<div class="limitation not-prose"><p class="limitation-title">Duplicate detection catches copy-paste, not paraphrase</p><div class="limitation-body">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.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">Machine translation is for triage only</p><div class="limitation-body">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.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">The account is the ceiling</p><div class="limitation-body">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.</div>
</div>

<p>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.</p>
<p><img src="https://salaheldinaz.com/blog/monitoring-telegram-at-scale/images/d2-4-jobs_hu_44157dc71bc15b98.webp" width="1600" height="1029" alt="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." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>Stage 6 includes the boring screen.</strong> 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.</div>

<h2 id="where-this-sits-in-the-series">Where this sits in the series</h2>
<p>This is the <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">6-stage spine</a> with every
stage grown to full size — the same shape as the
<a href="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/">one-file watcher</a>, except each
stage there is a function and here it is a component with its own failure modes.</p>
<p>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
<a href="https://salaheldinaz.com/blog/where-ai-actually-fits-in-osint/">where AI actually fits</a>: translation for
skimming, and an analysis pass over a set you chose. Neither decides anything.</p>
<p>Two harder pieces get their own posts. <span class="link-pending" title="Coming soon in this series">Does your AI step actually
work?</span> is how to measure whether the
cross-posting tag earns its place, and <span class="link-pending" title="Coming soon in this series">storing and searching what you
collect</span> is the identity question
underneath it.</p>
]]></content:encoded></item></channel></rss>