The problem I kept ignoring
I was in 340 Telegram groups.
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.
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.
The math is brutal. For 300 groups at ~45 seconds each, that's roughly 225 minutes. Nearly four hours of pure repetitive tapping.
So I kept putting it off. Until I finally decided to build the tool I actually wanted.
What I found when I looked for existing solutions
I figured this was a solved problem. Surely someone had built a bulk-leave tool already.
There were a few options. Almost every single one had the same requirement buried in the setup instructions:
Go to my.telegram.org, create a developer account, generate an API ID and API Hash, then paste those credentials into our tool.
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.
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.
What TG Cleaner is today
TG Cleaner 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.
You go to the site (or open it as a Telegram Mini App via @TGCUtilityBot), enter your phone number, enter the code Telegram sends you, and you see your full list of groups and channels. From there you can:
- Leave groups and channels in bulk
- Mute chats permanently without leaving
- Archive chats to clear your main list without losing membership
- Clean deleted accounts scan and remove ghost "Deleted Account" DMs
- Clean bots find bot DMs, then block and delete them safely
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.
Here's how it's built.
Tech stack
- Backend: Python + Flask (Gunicorn + gevent in production)
- Telegram layer: Telethon (MTProto client library)
- Background work: Celery workers with Redis broker/result backend
- Database: PostgreSQL (job state, users, donations, blog)
-
Ephemeral state: Redis, OTP
phone_code_hash, rate limits, bot-clean daily quotas -
Session encryption: Fernet (AES-128-CBC + HMAC) via the
cryptographylibrary - Auth tokens: short-lived JWTs for API access after login
- Frontend: vanilla JS, single-page app in one HTML file no React/Vue tax
- Hosting: VPS
- Optional support: card donations (Dodo) + NGN bank transfer (Bachs)
- Distribution: web + Telegram Mini App via @TGCUtilityBot
The auth flow
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.
When a user enters their phone number, Telethon sends a code request to Telegram's servers:
async def send_code(phone: str):
client = TelegramClient(
StringSession(existing_or_empty),
TG_API_ID,
TG_API_HASH,
device_model="Desktop",
system_version="Windows 10",
app_version="4.16.8 x64",
)
await client.connect()
if await client.is_user_authorized():
return {"status": "already_authorized"}
sent = await client.send_code_request(phone)
# phone_code_hash is required for sign-in store briefly in Redis
redis_client.setex(f"tg:pch:{phone}", 600, sent.phone_code_hash)
return {"status": "code_sent"}
When they submit the verification code:
async def verify_code(phone: str, code: str, password: str = None):
phone_code_hash = redis_client.get(f"tg:pch:{phone}")
client = TelegramClient(StringSession(existing_session), TG_API_ID, TG_API_HASH)
await client.connect()
try:
await client.sign_in(
phone=phone,
code=code,
phone_code_hash=phone_code_hash,
)
except SessionPasswordNeededError:
# User has Two-Step Verification enabled
if not password:
return None, "2fa_required"
await client.sign_in(password=password)
me = await client.get_me()
# Serialize session → encrypt → store
session_string = client.session.save()
await persist_session(phone, session_string)
return me, None
Telethon's StringSession 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.
Session security
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.
I use Fernet symmetric encryption AES-128 in CBC mode with HMAC authentication:
from cryptography.fernet import Fernet, InvalidToken
# Key lives in an environment variable, never in code
cipher = Fernet(os.getenv("SESSION_ENCRYPTION_KEY"))
def encrypt_session(value: str) -> str:
return cipher.encrypt(value.encode("utf-8")).decode("utf-8")
def decrypt_session(value: str) -> str:
try:
return cipher.decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken:
raise RuntimeError("Session could not be decrypted")
The encryption key is generated once with Fernet.generate_key() and stored as an environment variable. Rotating it invalidates all stored sessions (users just log in again).
Users can disconnect at any time that sets encrypted_session = NULL in the database and immediately revokes TG Cleaner's ability to act on their account.
What the product does not 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.
Background jobs: the upgrade that changed everything
The first version ran leave operations inside the HTTP request. That works for five chats. It falls apart for 200.
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.
So bulk work moved out of the request lifecycle into Celery:
- Frontend posts selected chat IDs
- API enqueues a job and returns
202with ajob_id - A Celery worker processes chats one by one with rate-limit delays
- Job state lives in PostgreSQL (
leave_jobs/bulk_jobs) - Frontend polls status and shows live progress
- User can leave the page the job keeps running in the background
That same pattern now powers leave, mute, archive, clean deleted, and clean-bots. One architecture, five features.
Worker settings matter more than people expect:
-
worker_prefetch_multiplier=1so workers don't hoard long jobs -
task_acks_late=Trueso a crashed worker can retry safely - long soft/hard time limits for bot cleaning (those jobs are intentionally slow)
- retries with backoff but not on
FloodWaitError, because retrying flood waits just makes Telegram angrier
Fetching the chat list
Once authenticated, listing groups and channels is straightforward:
from telethon.tl.types import Channel, Chat
async def list_chats(phone: str):
client = await build_client(phone)
chats = []
async for dialog in client.iter_dialogs():
entity = dialog.entity
if isinstance(entity, (Channel, Chat)):
is_channel = isinstance(entity, Channel) and entity.broadcast
is_admin = bool(getattr(entity, "admin_rights", None)) or bool(
getattr(entity, "creator", False)
)
chats.append({
"id": entity.id,
"title": dialog.name,
"type": "channel" if is_channel else "group",
"members": getattr(entity, "participants_count", None),
"username": getattr(entity, "username", None),
"is_admin": is_admin,
})
return chats
iter_dialogs() returns conversations in recent-activity order. Filtering for Channel and Chat excludes DMs and bot chats from the leave/mute/archive lists.
Two UX details that came from real usage:
- Admin badges if you're an admin or creator, the UI marks it so you don't leave a group you manage by accident
- Filters + smart select search, group/channel tabs, size filters (<50 / 50–500 / 500+), sort by name or member count, and a "select small groups" shortcut for the inactive clutter
You can also export the list as CSV or JSON if you just want a record of what you're in.
The leave flow (and the bugs that made it real)
When the user selects groups and confirms, the worker processes them sequentially:
from telethon.tl.functions.channels import LeaveChannelRequest
from telethon.tl.functions.messages import DeleteChatUserRequest
from telethon.tl.types import Channel
import asyncio
async def leave_chats(phone: str, ids: list):
client = await build_client(phone)
me = await client.get_me()
# Critical: resolve entities from dialogs first.
# Bare integer IDs often lack access_hash and fail resolution.
entity_map = {}
for dialog in await client.get_dialogs(limit=None):
entity_map[dialog.entity.id] = dialog.entity
results = []
for chat_id in ids:
try:
entity = entity_map[chat_id]
if isinstance(entity, Channel):
# Supergroups and channels
await client(LeaveChannelRequest(entity))
else:
# Legacy basic groups
await client(DeleteChatUserRequest(chat_id=entity.id, user_id=me))
results.append({"id": chat_id, "status": "left"})
except Exception as e:
results.append({"id": chat_id, "status": "error", "message": humanize(e)})
await asyncio.sleep(0.6) # rate-limit delay
return results
Three lessons I wish I'd known on day one:
1. Wrong leave method was the first real bug.
Channels/supergroups need LeaveChannelRequest. Legacy basic groups need DeleteChatUserRequest. Use the wrong one and Telethon throws a specific, confusing error.
2. Bare chat IDs are not enough.
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.
3. The 0.6s delay is not optional.
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.
Error handling is now specific instead of a generic string dump:
-
FLOOD_WAIT→ wait or stop cleanly with a readable message - banned / not a participant / private channel → explain what happened
- already left / not found → "you may have already left"
More than leave: mute, archive, clean deleted, clean bots
Leaving is permanent. People often want softer cleanup first. So the same bulk-job system grew new actions:
| Action | Still a member? | Notifications? | In main chat list? | Best for |
|---|---|---|---|---|
| Mute | Yes | No | Yes | Noisy groups you still want access to |
| Archive | Yes | Yes (if not muted) | No (Archive folder) | Decluttering without leaving |
| Leave | No | No | No | Groups you're fully done with |
| Clean Deleted | N/A | N/A | Removed | Ghost DMs with deleted users |
| Clean Bots | N/A | N/A | Removed + blocked | Spammy bot DMs from giveaways and promos |
Mute uses Telegram notify settings with a permanent mute-until. Archive moves peers into folder 1 (Telegram's archive). Clean Deleted scans for User.deleted dialogs and deletes those conversations. All of those still use a short inter-item delay and live progress.
Clean Bots: slow on purpose
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.
Clean Bots scans private chats where the peer is a bot, lets you search/select, then deletes the conversation and blocks the bot.
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":
- Random delay of roughly 14–22 seconds between Telegram writes (not a fixed cadence bots can fingerprint)
- Delete and block are spaced apart never stacked back-to-back
- Order is shuffled so we don't walk the API list top-to-bottom
- Occasional longer "human" pauses (~25–40s every few bots)
- Max 12 bots per request
- Max 20 bots per day per account
- Only one bot-clean job running per account at a time
- Long FloodWait → stop and skip remaining instead of retry-spamming
A full batch can take several minutes. That's the point. A quiet chat list is better than a restricted account.
Tip: 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.
The 2FA edge case
Two-Step Verification is the edge case that breaks most Telegram tools and it hit a surprising number of early testers.
When a user has 2FA enabled, Telethon's sign_in() raises SessionPasswordNeededError after accepting the OTP. You catch it and ask for the cloud password before calling sign_in(password=...).
The flow:
- User enters phone → code sent
- User enters code → either success or
2fa_required - Frontend shows the password step
- User submits password (with the same OTP flow context)
- Backend calls
sign_in(password=password)→ success
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.
What the tool can and can't do
This is still the most common concern, so I'll be explicit.
Can do:
- List groups and channels you're in
- Leave groups/channels you select
- Mute or archive selected chats
- Find and remove deleted-account conversations
- Find bot DMs and block + delete the ones you select
- Export your group/channel list
- Disconnect the stored session instantly
Cannot do (and does not implement):
- Read message contents in groups/channels/DMs
- Access your contacts book for outbound spam
- Send messages as you
- Browse media galleries or files as a product feature
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.
Things I changed after the first version shipped
1. Background workers instead of request-thread Telethon.
asyncio.run() inside Flask requests works until concurrency and long jobs show up. Celery + Redis + durable job rows was the real fix.
2. Redis for OTP state from day one (eventually).
In-memory phone_code_hash breaks the moment you have more than one worker. Redis was always correct.
3. Human-readable Telegram errors.
FLOOD_WAIT_X, CHAT_ID_INVALID, banned-in-channel, private channel users don't care about RPC names. Map them.
4. Entity resolution before leave.
Prefetch dialogs, map IDs to real entities, then leave. Random "could not find entity" failures mostly vanished.
5. Softer cleanup modes.
Not everyone wants to leave. Mute and archive cover the middle ground. Clean deleted and clean bots clean the DM side of the mess.
7. Ship where Telegram users already are.
The Mini App via @TGCUtilityBot means you don't even need a separate browser tab if you don't want one.
What I'd do differently today
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.
- Separate worker queues earlier. 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.
-
Use WebSockets or Server-Sent Events for job progress. Polling with a
job_idworked well and kept the frontend simple, but pushing progress updates would reduce unnecessary requests and make the UI feel more responsive. -
Add better observability from day one. Logging was enough at first, but metrics around queue times,
FloodWaits, retries, and task duration would have made diagnosing production issues much easier. - Abstract the Telegram layer sooner. As more cleanup features were added, common Telethon operations naturally converged into reusable helpers. Extracting those earlier would have reduced duplication.
- Invest in automated integration tests. 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.
Try it
TG Cleaner completely free, no usage limits on leave/mute/archive/clean-deleted. Bot cleaning is intentionally rate-limited for account safety.
Or open it inside Telegram: @TGCUtilityBot
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.
Top comments (0)