<?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: Shagbaor Agber</title>
    <description>The latest articles on DEV Community by Shagbaor Agber (@aftersnow).</description>
    <link>https://dev.to/aftersnow</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3935428%2F76346138-b6c5-4ee0-95ca-56b878c32776.png</url>
      <title>DEV Community: Shagbaor Agber</title>
      <link>https://dev.to/aftersnow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aftersnow"/>
    <language>en</language>
    <item>
      <title>I Was in 340 Telegram Groups, So I Built a Cleanup Tool</title>
      <dc:creator>Shagbaor Agber</dc:creator>
      <pubDate>Tue, 04 Aug 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/aftersnow/i-was-in-340-telegram-groups-so-i-built-a-cleanup-tool-2kfl</link>
      <guid>https://dev.to/aftersnow/i-was-in-340-telegram-groups-so-i-built-a-cleanup-tool-2kfl</guid>
      <description>&lt;h2&gt;
  
  
  The problem I kept ignoring
&lt;/h2&gt;

&lt;p&gt;I was in 340 Telegram groups.&lt;/p&gt;

&lt;p&gt;Crypto project chats from 2021. Dev communities I joined once and never visited again. Random hobby groups I'd completely forgotten about. Every single one of them still sitting in my chat list muted, ignored, but never cleaned up.&lt;/p&gt;

&lt;p&gt;I knew I should fix it. But every time I started leaving groups manually open the group, scroll down, tap Leave, confirm I'd give up after about ten.&lt;/p&gt;

&lt;p&gt;The math is brutal. For 300 groups at ~45 seconds each, that's roughly 225 minutes. Nearly four hours of pure repetitive tapping.&lt;/p&gt;

&lt;p&gt;So I kept putting it off. Until I finally decided to build the tool I actually wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I found when I looked for existing solutions
&lt;/h2&gt;

&lt;p&gt;I figured this was a solved problem. Surely someone had built a bulk-leave tool already.&lt;/p&gt;

&lt;p&gt;There were a few options. Almost every single one had the same requirement buried in the setup instructions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Go to my.telegram.org, create a developer account, generate an API ID and API Hash, then paste those credentials into our tool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a 15–30 minute technical setup. For non-developers it's a complete wall. For developers who just want to clean their own Telegram, it's frustrating friction for something that should take 30 seconds.&lt;/p&gt;

&lt;p&gt;Nobody should need a developer account to leave Telegram groups. So I built the version I actually wanted to use and then kept shipping until it became a full chat cleaner, not just a leave button.&lt;/p&gt;




&lt;h2&gt;
  
  
  What TG Cleaner is today
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tgcleaner.com" rel="noopener noreferrer"&gt;&lt;strong&gt;TG Cleaner&lt;/strong&gt;&lt;/a&gt; is the tool I ended up building. It's a web app (and Telegram Mini App) that lets people clean up their Telegram chats without generating their own API credentials.&lt;/p&gt;

&lt;p&gt;You go to the site (or open it as a Telegram Mini App via &lt;a href="https://t.me/TGCUtilityBot" rel="noopener noreferrer"&gt;@TGCUtilityBot&lt;/a&gt;), enter your phone number, enter the code Telegram sends you, and you see your full list of groups and channels. From there you can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leave&lt;/strong&gt; groups and channels in bulk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mute&lt;/strong&gt; chats permanently without leaving&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Archive&lt;/strong&gt; chats to clear your main list without losing membership&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean deleted accounts&lt;/strong&gt; scan and remove ghost "Deleted Account" DMs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean bots&lt;/strong&gt; find bot DMs, then block and delete them safely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No API keys. No developer account. I handle the Telegram API layer so users never have to touch it. The goal was simply to make Telegram cleanup accessible to anyone without a complicated setup.&lt;/p&gt;

&lt;p&gt;Here's how it's built.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Python + Flask (Gunicorn + gevent in production)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram layer:&lt;/strong&gt; Telethon (MTProto client library)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background work:&lt;/strong&gt; Celery workers with Redis broker/result backend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL (job state, users, donations, blog)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ephemeral state:&lt;/strong&gt; Redis, OTP &lt;code&gt;phone_code_hash&lt;/code&gt;, rate limits, bot-clean daily quotas&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session encryption:&lt;/strong&gt; Fernet (AES-128-CBC + HMAC) via the &lt;code&gt;cryptography&lt;/code&gt; library&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth tokens:&lt;/strong&gt; short-lived JWTs for API access after login&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; vanilla JS, single-page app in one HTML file no React/Vue tax&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting:&lt;/strong&gt; VPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional support:&lt;/strong&gt; card donations (Dodo) + NGN bank transfer (Bachs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distribution:&lt;/strong&gt; web + Telegram Mini App via &lt;a href="https://t.me/TGCUtilityBot" rel="noopener noreferrer"&gt;@TGCUtilityBot&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The auth flow
&lt;/h2&gt;

&lt;p&gt;The core architectural decision is still the same as day one: instead of asking each user to generate their own Telegram API credentials, I registered a single Telegram application under my developer account and authenticate users through the standard Telegram login flow.&lt;/p&gt;

&lt;p&gt;When a user enters their phone number, Telethon sends a code request to Telegram's servers:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TelegramClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;StringSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing_or_empty&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;TG_API_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TG_API_HASH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Desktop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;system_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Windows 10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;app_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4.16.8 x64&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_user_authorized&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;already_authorized&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_code_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# phone_code_hash is required for sign-in store briefly in Redis
&lt;/span&gt;    &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tg:pch:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;phone_code_hash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;code_sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When they submit the verification code:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;phone_code_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tg:pch:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TelegramClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing_session&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;TG_API_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_API_HASH&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;try&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;phone_code_hash&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;phone_code_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SessionPasswordNeededError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# User has Two-Step Verification enabled
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&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;2fa_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;me&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_me&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Serialize session → encrypt → store
&lt;/span&gt;    &lt;span class="n"&gt;session_string&lt;/span&gt; &lt;span class="o"&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;persist_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_string&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;me&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Telethon's &lt;code&gt;StringSession&lt;/code&gt; serializes the authenticated session into a string. I encrypt that string and store it in PostgreSQL. On later requests, I decrypt and resume the session without re-authenticating. The API then issues a JWT so the browser doesn't keep sending the raw session around.&lt;/p&gt;




&lt;h2&gt;
  
  
  Session security
&lt;/h2&gt;

&lt;p&gt;A Telegram session string is essentially a login token. If it leaked, someone could authenticate as that user. Encrypting it at rest was non-negotiable from day one.&lt;/p&gt;

&lt;p&gt;I use Fernet symmetric encryption AES-128 in CBC mode with HMAC authentication:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptography.fernet&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Fernet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvalidToken&lt;/span&gt;

&lt;span class="c1"&gt;# Key lives in an environment variable, never in code
&lt;/span&gt;&lt;span class="n"&gt;cipher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fernet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SESSION_ENCRYPTION_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encrypt_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decrypt_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&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;cipher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;InvalidToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Session could not be decrypted&lt;/span&gt;&lt;span class="sh"&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 encryption key is generated once with &lt;code&gt;Fernet.generate_key()&lt;/code&gt; and stored as an environment variable. Rotating it invalidates all stored sessions (users just log in again).&lt;/p&gt;

&lt;p&gt;Users can disconnect at any time that sets &lt;code&gt;encrypted_session = NULL&lt;/code&gt; in the database and immediately revokes TG Cleaner's ability to act on their account.&lt;/p&gt;

&lt;p&gt;What the product does &lt;em&gt;not&lt;/em&gt; do with that session is as important as what it does. The code paths only list dialogs and perform explicit cleanup actions the user requested. There is no message-reading UI, no contact export for spam, no send-message feature.&lt;/p&gt;




&lt;h2&gt;
  
  
  Background jobs: the upgrade that changed everything
&lt;/h2&gt;

&lt;p&gt;The first version ran leave operations inside the HTTP request. That works for five chats. It falls apart for 200.&lt;/p&gt;

&lt;p&gt;Browsers time out. Load balancers time out. Workers get stuck. Users stare at a spinner with no idea whether anything is happening. And if you process leaves synchronously under concurrent load, one big cleanup blocks everything else.&lt;/p&gt;

&lt;p&gt;So bulk work moved out of the request lifecycle into &lt;strong&gt;Celery&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Frontend posts selected chat IDs&lt;/li&gt;
&lt;li&gt;API enqueues a job and returns &lt;code&gt;202&lt;/code&gt; with a &lt;code&gt;job_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A Celery worker processes chats one by one with rate-limit delays&lt;/li&gt;
&lt;li&gt;Job state lives in PostgreSQL (&lt;code&gt;leave_jobs&lt;/code&gt; / &lt;code&gt;bulk_jobs&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Frontend polls status and shows live progress&lt;/li&gt;
&lt;li&gt;User can leave the page the job keeps running in the background&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That same pattern now powers leave, mute, archive, clean deleted, and clean-bots. One architecture, five features.&lt;/p&gt;

&lt;p&gt;Worker settings matter more than people expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;worker_prefetch_multiplier=1&lt;/code&gt; so workers don't hoard long jobs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;task_acks_late=True&lt;/code&gt; so a crashed worker can retry safely&lt;/li&gt;
&lt;li&gt;long soft/hard time limits for bot cleaning (those jobs are intentionally slow)&lt;/li&gt;
&lt;li&gt;retries with backoff but &lt;strong&gt;not&lt;/strong&gt; on &lt;code&gt;FloodWaitError&lt;/code&gt;, because retrying flood waits just makes Telegram angrier&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Fetching the chat list
&lt;/h2&gt;

&lt;p&gt;Once authenticated, listing groups and channels is straightforward:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;telethon.tl.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Chat&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;list_chats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;build_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;chats&lt;/span&gt; &lt;span class="o"&gt;=&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;for&lt;/span&gt; &lt;span class="n"&gt;dialog&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter_dialogs&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="n"&gt;is_channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;broadcast&lt;/span&gt;
            &lt;span class="n"&gt;is_admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin_rights&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="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;creator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;chats&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entity&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&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;channel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_channel&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;group&lt;/span&gt;&lt;span class="sh"&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;members&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;participants_count&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;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&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;is_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;,&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;chats&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;iter_dialogs()&lt;/code&gt; returns conversations in recent-activity order. Filtering for &lt;code&gt;Channel&lt;/code&gt; and &lt;code&gt;Chat&lt;/code&gt; excludes DMs and bot chats from the leave/mute/archive lists.&lt;/p&gt;

&lt;p&gt;Two UX details that came from real usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Admin badges&lt;/strong&gt; if you're an admin or creator, the UI marks it so you don't leave a group you manage by accident&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters + smart select&lt;/strong&gt; search, group/channel tabs, size filters (&amp;lt;50 / 50–500 / 500+), sort by name or member count, and a "select small groups" shortcut for the inactive clutter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can also export the list as CSV or JSON if you just want a record of what you're in.&lt;/p&gt;




&lt;h2&gt;
  
  
  The leave flow (and the bugs that made it real)
&lt;/h2&gt;

&lt;p&gt;When the user selects groups and confirms, the worker processes them sequentially:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;telethon.tl.functions.channels&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LeaveChannelRequest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;telethon.tl.functions.messages&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DeleteChatUserRequest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;telethon.tl.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&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;leave_chats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;build_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;me&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_me&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Critical: resolve entities from dialogs first.
&lt;/span&gt;    &lt;span class="c1"&gt;# Bare integer IDs often lack access_hash and fail resolution.
&lt;/span&gt;    &lt;span class="n"&gt;entity_map&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;dialog&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_dialogs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;entity_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity&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="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;

    &lt;span class="n"&gt;results&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;chat_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;entity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entity_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="c1"&gt;# Supergroups and channels
&lt;/span&gt;                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LeaveChannelRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# Legacy basic groups
&lt;/span&gt;                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DeleteChatUserRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;entity&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;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

            &lt;span class="n"&gt;results&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;results&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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;error&lt;/span&gt;&lt;span class="sh"&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;humanize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# rate-limit delay
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lessons I wish I'd known on day one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Wrong leave method was the first real bug.&lt;/strong&gt;&lt;br&gt;
Channels/supergroups need &lt;code&gt;LeaveChannelRequest&lt;/code&gt;. Legacy basic groups need &lt;code&gt;DeleteChatUserRequest&lt;/code&gt;. Use the wrong one and Telethon throws a specific, confusing error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Bare chat IDs are not enough.&lt;/strong&gt;&lt;br&gt;
Telegram peers need an access hash. Early leave jobs failed randomly until the worker prefetched all dialogs and built an entity map before processing selections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The 0.6s delay is not optional.&lt;/strong&gt;&lt;br&gt;
Early testing without delays got test accounts temporarily restricted. Leaving groups rapidly looks like automated abuse. The delay makes the traffic pattern closer to a human doing it carefully. For FloodWait responses longer than ~60 seconds, the job bails and skips the rest instead of hammering the account.&lt;/p&gt;

&lt;p&gt;Error handling is now specific instead of a generic string dump:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FLOOD_WAIT&lt;/code&gt; → wait or stop cleanly with a readable message&lt;/li&gt;
&lt;li&gt;banned / not a participant / private channel → explain what happened&lt;/li&gt;
&lt;li&gt;already left / not found → "you may have already left"&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  More than leave: mute, archive, clean deleted, clean bots
&lt;/h2&gt;

&lt;p&gt;Leaving is permanent. People often want softer cleanup first. So the same bulk-job system grew new actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Still a member?&lt;/th&gt;
&lt;th&gt;Notifications?&lt;/th&gt;
&lt;th&gt;In main chat list?&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Noisy groups you still want access to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Archive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (if not muted)&lt;/td&gt;
&lt;td&gt;No (Archive folder)&lt;/td&gt;
&lt;td&gt;Decluttering without leaving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Leave&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Groups you're fully done with&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clean Deleted&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Removed&lt;/td&gt;
&lt;td&gt;Ghost DMs with deleted users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clean Bots&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Removed + blocked&lt;/td&gt;
&lt;td&gt;Spammy bot DMs from giveaways and promos&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Mute&lt;/strong&gt; uses Telegram notify settings with a permanent mute-until. &lt;strong&gt;Archive&lt;/strong&gt; moves peers into folder 1 (Telegram's archive). &lt;strong&gt;Clean Deleted&lt;/strong&gt; scans for &lt;code&gt;User.deleted&lt;/code&gt; dialogs and deletes those conversations. All of those still use a short inter-item delay and live progress.&lt;/p&gt;




&lt;h2&gt;
  
  
  Clean Bots: slow on purpose
&lt;/h2&gt;

&lt;p&gt;Bot DMs are a different problem from groups. People accumulate dozens of bots from giveaways, crypto promos, "support" scams, and random mini-apps. Telegram doesn't give you a bulk leave-bots button either.&lt;/p&gt;

&lt;p&gt;Clean Bots scans private chats where the peer is a bot, lets you search/select, then &lt;strong&gt;deletes the conversation and blocks the bot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This path is intentionally conservative. Blocking and deleting peers quickly is one of the easiest ways to trip account restrictions. So the worker does the opposite of "as fast as possible":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Random delay of roughly &lt;strong&gt;14–22 seconds&lt;/strong&gt; between Telegram writes (not a fixed cadence bots can fingerprint)&lt;/li&gt;
&lt;li&gt;Delete and block are spaced apart never stacked back-to-back&lt;/li&gt;
&lt;li&gt;Order is shuffled so we don't walk the API list top-to-bottom&lt;/li&gt;
&lt;li&gt;Occasional longer "human" pauses (~25–40s every few bots)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Max 12 bots per request&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max 20 bots per day&lt;/strong&gt; per account&lt;/li&gt;
&lt;li&gt;Only one bot-clean job running per account at a time&lt;/li&gt;
&lt;li&gt;Long FloodWait → stop and skip remaining instead of retry-spamming&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full batch can take several minutes. That's the point. A quiet chat list is better than a restricted account.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Deselect payment bots, 2FA bots, and anything you still need. Scan first, then clean in small batches over a few days if you have a lot.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The 2FA edge case
&lt;/h2&gt;

&lt;p&gt;Two-Step Verification is the edge case that breaks most Telegram tools and it hit a surprising number of early testers.&lt;/p&gt;

&lt;p&gt;When a user has 2FA enabled, Telethon's &lt;code&gt;sign_in()&lt;/code&gt; raises &lt;code&gt;SessionPasswordNeededError&lt;/code&gt; after accepting the OTP. You catch it and ask for the cloud password before calling &lt;code&gt;sign_in(password=...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User enters phone → code sent&lt;/li&gt;
&lt;li&gt;User enters code → either success or &lt;code&gt;2fa_required&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Frontend shows the password step&lt;/li&gt;
&lt;li&gt;User submits password (with the same OTP flow context)&lt;/li&gt;
&lt;li&gt;Backend calls &lt;code&gt;sign_in(password=password)&lt;/code&gt; → success&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Missing this means roughly 20–30% of users can't log in, depending on audience. Security-conscious people exactly the people who care about cleaning and disconnecting sessions are more likely to have 2FA on.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the tool can and can't do
&lt;/h2&gt;

&lt;p&gt;This is still the most common concern, so I'll be explicit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;List groups and channels you're in&lt;/li&gt;
&lt;li&gt;Leave groups/channels you select&lt;/li&gt;
&lt;li&gt;Mute or archive selected chats&lt;/li&gt;
&lt;li&gt;Find and remove deleted-account conversations&lt;/li&gt;
&lt;li&gt;Find bot DMs and block + delete the ones you select&lt;/li&gt;
&lt;li&gt;Export your group/channel list&lt;/li&gt;
&lt;li&gt;Disconnect the stored session instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cannot do (and does not implement):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read message contents in groups/channels/DMs&lt;/li&gt;
&lt;li&gt;Access your contacts book for outbound spam&lt;/li&gt;
&lt;li&gt;Send messages as you&lt;/li&gt;
&lt;li&gt;Browse media galleries or files as a product feature&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An MTProto session is powerful in theory. TG Cleaner's application code only exercises the cleanup paths above. Session encryption, disconnect, JWT auth, rate limits, and conservative bot-clean caps are all part of keeping that power narrowly aimed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Things I changed after the first version shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Background workers instead of request-thread Telethon.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;asyncio.run()&lt;/code&gt; inside Flask requests works until concurrency and long jobs show up. Celery + Redis + durable job rows was the real fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Redis for OTP state from day one (eventually).&lt;/strong&gt;&lt;br&gt;
In-memory &lt;code&gt;phone_code_hash&lt;/code&gt; breaks the moment you have more than one worker. Redis was always correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Human-readable Telegram errors.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;FLOOD_WAIT_X&lt;/code&gt;, &lt;code&gt;CHAT_ID_INVALID&lt;/code&gt;, banned-in-channel, private channel users don't care about RPC names. Map them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Entity resolution before leave.&lt;/strong&gt;&lt;br&gt;
Prefetch dialogs, map IDs to real entities, then leave. Random "could not find entity" failures mostly vanished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Softer cleanup modes.&lt;/strong&gt;&lt;br&gt;
Not everyone wants to leave. Mute and archive cover the middle ground. Clean deleted and clean bots clean the DM side of the mess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Ship where Telegram users already are.&lt;/strong&gt;&lt;br&gt;
The Mini App via &lt;a href="https://t.me/TGCUtilityBot" rel="noopener noreferrer"&gt;@TGCUtilityBot&lt;/a&gt; means you don't even need a separate browser tab if you don't want one.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently today
&lt;/h2&gt;

&lt;p&gt;No project survives first contact with real users without a few architectural regrets. Looking back, there are a few things I'd change if I were starting TG Cleaner from scratch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separate worker queues earlier.&lt;/strong&gt; Leave, mute, archive, and bot cleaning all started on the same queue. Dedicated queues would have made it easier to prioritize short jobs over long-running ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use WebSockets or Server-Sent Events for job progress.&lt;/strong&gt; Polling with a &lt;code&gt;job_id&lt;/code&gt; worked well and kept the frontend simple, but pushing progress updates would reduce unnecessary requests and make the UI feel more responsive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add better observability from day one.&lt;/strong&gt; Logging was enough at first, but metrics around queue times, &lt;code&gt;FloodWait&lt;/code&gt;s, retries, and task duration would have made diagnosing production issues much easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Abstract the Telegram layer sooner.&lt;/strong&gt; As more cleanup features were added, common Telethon operations naturally converged into reusable helpers. Extracting those earlier would have reduced duplication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invest in automated integration tests.&lt;/strong&gt; Most edge cases only appeared after real users started using the product. A larger suite of tests around authentication, entity resolution, and long-running jobs would have caught several regressions before deployment.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tgcleaner.com" rel="noopener noreferrer"&gt;&lt;strong&gt;TG Cleaner&lt;/strong&gt;&lt;/a&gt; completely free, no usage limits on leave/mute/archive/clean-deleted. Bot cleaning is intentionally rate-limited for account safety.&lt;/p&gt;

&lt;p&gt;Or open it inside Telegram: &lt;a href="https://t.me/TGCUtilityBot" rel="noopener noreferrer"&gt;@TGCUtilityBot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the session security model, the Celery job design, FloodWait handling, or the Clean Bots delay strategy. Those are the areas that took the most iteration to get right.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>telegram</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
