<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maxim Velli</title>
    <description>The latest articles on DEV Community by Maxim Velli (@kommunizm01).</description>
    <link>https://dev.to/kommunizm01</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3917647%2Fa88370c4-4d9d-4311-bf6d-7a6c6a324034.jpg</url>
      <title>DEV Community: Maxim Velli</title>
      <link>https://dev.to/kommunizm01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kommunizm01"/>
    <language>en</language>
    <item>
      <title>I built my own Google Reader, but for Telegram channels</title>
      <dc:creator>Maxim Velli</dc:creator>
      <pubDate>Thu, 07 May 2026 10:09:20 +0000</pubDate>
      <link>https://dev.to/kommunizm01/i-built-my-own-google-reader-but-for-telegram-channels-1nge</link>
      <guid>https://dev.to/kommunizm01/i-built-my-own-google-reader-but-for-telegram-channels-1nge</guid>
      <description>&lt;p&gt;We are still grieving Google Reader.&lt;/p&gt;

&lt;p&gt;Not the product specifically — the idea behind it. A single chronological surface where everything you subscribed to showed up, in order, without anyone deciding for you what was important. Twitter killed RSS in the 2010s, then algorithms killed Twitter, and the rest of the decade has been a slow rediscovery of the fact that humans actually do read better when nothing is being optimized at them.&lt;/p&gt;

&lt;p&gt;A lot of people drifted to Telegram for this reason. Telegram is honest in a way most platforms forgot how to be — chronological order, no ranking, you read what you subscribed to. But there's one thing it doesn't do: a unified feed across channels. Twenty channels means twenty separate inboxes. Every morning you tap through them one by one, and by the tenth you've already forgotten what was in the first.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;&lt;a href="https://televizor.click" rel="noopener noreferrer"&gt;Televizor&lt;/a&gt;&lt;/strong&gt; to fix exactly that. You log in with your Telegram account, pick the channels you follow, and their messages get forwarded into a single destination chat inside your own Telegram. You read it like a feed. Chronological, no algorithm, lives in a regular chat — no separate client, no extension, no new app to install.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/s0larpunk/televizor" rel="noopener noreferrer"&gt;github.com/s0larpunk/televizor&lt;/a&gt; (GPL-3)&lt;/li&gt;
&lt;li&gt;Hosted: &lt;a href="https://televizor.click" rel="noopener noreferrer"&gt;televizor.click&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What follows is a tour of the architectural decisions, with a section at the end about how this was actually built (it was vibe-coded with Claude Code, and that matters for how you read the code).&lt;/p&gt;

&lt;h2&gt;
  
  
  Telethon vs Bot API: the decision that shaped everything else
&lt;/h2&gt;

&lt;p&gt;The first instinct when you start building anything that reads Telegram channels is to reach for the Bot API. It's well-documented, the SDKs are good, and Telegram itself recommends it for most automation. The problem is that it's structurally useless for aggregation.&lt;/p&gt;

&lt;p&gt;Bots in Telegram exist as a separate identity from users. A bot can only read messages in channels where it has been added as an administrator. To aggregate a public news channel through a bot, you'd need to convince the channel owner to grant your bot admin rights. Nobody is going to do that for a third-party tool. So bot-based aggregators are a dead end for any channel you don't already control.&lt;/p&gt;

&lt;p&gt;Telethon takes the other path: it's an MTProto client that authenticates as the user. Same protocol the official Telegram apps use, same protocol Unigram and every other third-party client uses. When you log in via Telethon, you have access to every channel your account is subscribed to — exactly the same access the official client has, because it is functionally the same client.&lt;/p&gt;

&lt;p&gt;The tradeoff is real: this is the user API, and it sits in a grey area of the ToS. Telegram doesn't ban third-party MTProto clients (doing so would kill its own third-party ecosystem overnight), but they do flag automation that looks like spam or mass scraping. The mitigations that have kept my instance clean for months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;15-second forward delay (configurable, intentionally non-instant)&lt;/li&gt;
&lt;li&gt;Per-source and per-feed rate limiting via Redis&lt;/li&gt;
&lt;li&gt;Event-driven handlers, not polling&lt;/li&gt;
&lt;li&gt;Graceful handling of &lt;code&gt;SessionRevokedError&lt;/code&gt; and &lt;code&gt;AuthKeyError&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Event-driven vs polling: why this isn't optional
&lt;/h2&gt;

&lt;p&gt;When you read about Telethon for the first time, the natural impulse is to write a loop that asks each channel for new messages every N seconds. This is polling, and it has two structural problems for this use case.&lt;/p&gt;

&lt;p&gt;First, latency. With polling at interval N, the average latency for a new message is N/2. At a 30-second poll interval, your median message lands 15 seconds late. For news channels, fine. For trading signals or breaking news, useless.&lt;/p&gt;

&lt;p&gt;Second, scaling. Polling N channels for M users is N×M API calls per cycle. Telegram rate-limits you per account, so this hits a wall quickly as you add users. The math doesn't work past a few dozen accounts.&lt;/p&gt;

&lt;p&gt;Telethon supports a real event-driven model via &lt;code&gt;events.NewMessage&lt;/code&gt; — a persistent connection that receives updates the moment they happen. One connection per user, regardless of channel count. Forward latency is then determined entirely by the deliberate 15-second delay, not by polling interval.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@client.on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewMessage&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;normalized_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_peer_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;peer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;add_mark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;normalized_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source_to_feeds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;valid_feeds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;source_to_feeds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;normalized_id&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;check_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;check_rate_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;valid_feeds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dest_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feeds&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;group_by_destination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_feeds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;forward_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dest_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;source_to_feeds&lt;/code&gt; dict is the in-memory routing table. It maps a source channel ID to all feeds that have subscribed to it. Updated via PostgreSQL &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; so config changes propagate instantly without restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Albums: the bug that took a week to notice
&lt;/h2&gt;

&lt;p&gt;Telegram delivers albums (grouped photos or videos in a single post) as N separate messages sharing a &lt;code&gt;grouped_id&lt;/code&gt;. If you forward each message independently, the album falls apart at the destination — they arrive as unrelated standalone media, no caption.&lt;/p&gt;

&lt;p&gt;The fix is a 2-second debounce buffer keyed on &lt;code&gt;(source_id, grouped_id)&lt;/code&gt;. Every new part of the album resets the timer. After 2 seconds of quiet, flush the whole batch via &lt;code&gt;forward_messages(messages=[ids])&lt;/code&gt;, and the destination reconstructs the album as a single grouped post.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;grouped_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_channel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grouped_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;message_ids&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feeds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;valid_feeds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;source_peer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_input_chat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;message_ids&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;pending_albums&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;wait_and_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush_album&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, album parts arrive within 500ms–1.5s. Two seconds is conservative but I've never seen a split album.&lt;/p&gt;

&lt;h2&gt;
  
  
  Referral concurrency
&lt;/h2&gt;

&lt;p&gt;The referral flow updates two rows — referrer and referred — and grants both a week of Premium. Done naively, two concurrent referrals using the same code can double-count.&lt;/p&gt;

&lt;p&gt;Fix: &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; with a deterministic lock order (always lock the user row first, then the referrer). This is the standard way to avoid ABBA deadlocks where two transactions lock the same rows in opposite order and wait for each other forever.&lt;/p&gt;

&lt;p&gt;The increment is done at the SQL level — &lt;code&gt;User.referral_count = User.referral_count + 1&lt;/code&gt; — not as a read-modify-write through the ORM. ORM caches read stale values during concurrent transactions, and Python-side arithmetic silently loses updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting via Redis: tumbling windows
&lt;/h2&gt;

&lt;p&gt;All anti-flood logic lives in Redis. For each user and action type, two keys: an hourly counter and a daily counter. Keys auto-expire via TTL, so no cleanup job is needed.&lt;/p&gt;

&lt;p&gt;The "sliding" window is implemented by dividing the unix timestamp by the window length: &lt;code&gt;now // 3600&lt;/code&gt; is the current hour as an integer, &lt;code&gt;now // 86400&lt;/code&gt; is the current day. When the hour rolls over, the old key stops being incremented and dies via TTL. Technically a tumbling window, not a true sliding one, but close enough for rate-limiting at these granularities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat model — the unredacted version
&lt;/h2&gt;

&lt;p&gt;Session strings are functionally equivalent to Telegram auth tokens. This is the most sensitive thing in the architecture, and I describe it directly rather than burying it in fine print.&lt;/p&gt;

&lt;p&gt;Stored in the DB: phone number, Telegram ID, Telethon session string, feed configs, subscription tier and expiry. &lt;strong&gt;Not&lt;/strong&gt; stored: message content, chat history, contacts, media, usage analytics.&lt;/p&gt;

&lt;p&gt;If the database leaks, an attacker gets session strings and can connect to Telegram as the affected users. Mitigation: any user can revoke all sessions from Telegram settings instantly, which deactivates the string. Televizor doesn't request the 2FA password, so an attacker with a session string alone cannot change the password or delete the account.&lt;/p&gt;

&lt;p&gt;Session strings are encrypted at rest using &lt;strong&gt;Fernet (AES-128-CBC + HMAC-SHA256)&lt;/strong&gt;. Encryption is gated on &lt;code&gt;SESSION_ENCRYPTION_KEY&lt;/code&gt; in the environment — if the key is set, ciphertext goes to the DB; if not, plaintext (kept for backwards compatibility with legacy rows). For self-hosters: generate a key, drop it in &lt;code&gt;.env&lt;/code&gt;, all new sessions are encrypted.&lt;/p&gt;

&lt;p&gt;If the threat model is still uncomfortable, self-host. The session never leaves your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this was built: Claude Code agents
&lt;/h2&gt;

&lt;p&gt;I'm going to be honest about this section because it's relevant to how you should read the code.&lt;/p&gt;

&lt;p&gt;I didn't start with an architecture document and decompose tasks into sprints. I opened Claude Code, described the problem, looked at what came back, fixed it, iterated. Most of the codebase was written that way.&lt;/p&gt;

&lt;p&gt;Different parts of the project benefited from different kinds of agent interaction. Backend logic was a conversation around concrete problems — "here's Telethon, here's a list of channels, I need event-driven forwarding with filters." The agent proposed structures, I asked questions about concurrency or rate-limiting edge cases, we iterated. Architectural decisions like &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; for the referral flow or the album debounce came out of these conversations — not because I knew the answer in advance, but because the agent surfaced the failure modes of the naive solution.&lt;/p&gt;

&lt;p&gt;The Next.js frontend with i18n and RTL Farsi support was a different kind of work. There the agent functioned more like a pair programmer fluent in the ecosystem. The four-language localization with proper RTL would have taken me weeks to build manually.&lt;/p&gt;

&lt;p&gt;Where agents are bad: holding context across multi-day sessions, noticing conflicts between modules built in separate sessions, generating tests for code they don't fully model. Refactoring across separate-session output to enforce consistency is manual work. Tests are still a debt — writing tests for code you didn't fully internalize turns out to be harder than I expected.&lt;/p&gt;

&lt;p&gt;I open-sourced this from the first commit specifically &lt;em&gt;because&lt;/em&gt; it was vibe-coded. When you're building with agents, external review becomes more important, not less. The fewer decisions that pass through your head as a complete model, the more you need a second pair of eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-host
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/s0larpunk/televizor
&lt;span class="nb"&gt;cd &lt;/span&gt;televizor
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env  &lt;span class="c"&gt;# TELEGRAM_API_ID / TELEGRAM_API_HASH from my.telegram.org&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four containers: frontend, backend, postgres, redis. Idle ~180MB RAM, active ~350MB. Set &lt;code&gt;SESSION_ENCRYPTION_KEY&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; for at-rest encryption (generate with &lt;code&gt;python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"&lt;/code&gt;). Reverse proxy with TLS for production. Back up &lt;code&gt;postgres_data&lt;/code&gt; — losing it means every user has to log in again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worker sharding&lt;/strong&gt; — single process today, fine for hundreds of users. Bottleneck is per-account &lt;code&gt;FloodWait&lt;/code&gt;, not CPU. Becomes relevant past 1000+ active users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-feed dedup&lt;/strong&gt; — if two feeds share a source channel, the same message is forwarded twice. Fixable with a fingerprint table, but adds a write per delivery to fix a problem nobody's reported. Deferred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test coverage&lt;/strong&gt; — thin, honest debt of vibe-coding. The hardest one to retrofit because writing tests for code you don't fully model is harder than it sounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PRs welcome, especially on tests, sharding, and the dedup problem.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/s0larpunk/televizor" rel="noopener noreferrer"&gt;github.com/s0larpunk/televizor&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
      <category>telegram</category>
      <category>rss</category>
    </item>
  </channel>
</rss>
