The Problem
I wanted music in my Telegram group voice chats — not a janky "play a file" hack, but a real streaming experience: queue songs, skip tracks, see what's playing with a live progress bar, pause and resume. The kind of thing you'd expect from a proper music bot.
The catch: Telegram's Bot API can't join voice chats. Only user accounts can. And user accounts driven by automation (via libraries like Telethon) can't use the Bot API's nice inline keyboards and clean update handling in the same process. Each side has something the other lacks.
So I built two bots that work as one.
Architecture
The system splits responsibilities across two Telegram identities:
[Group Chat]
|
+-- "bunny, play <song>" (plain text message)
|
[Face Bot] (Bot API)
- sees the command (privacy mode OFF)
- renders the player UI
- relays the request
|
v
[Userbot] (Telethon, my second account)
- the "brain": parses commands, manages queue
- joins the voice chat via py-tgcalls
- streams audio with ntgcalls
|
v
[Voice Chat] -- audio stream --> listeners
Why Two Bots?
The face bot (Bot API) is the user interface. It has reliable webhook/polling updates, clean message editing for the live progress bar, and inline buttons. But it physically cannot join a voice chat — that's a hard Telegram platform limit.
The userbot (a second Telegram account driven by Telethon) is the muscle. It can join voice chats, and with py-tgcalls it can push raw audio frames into the call. But driving UI from a user account is clunky — no inline keyboards, awkward message edits.
Together: the face bot handles everything the user sees, the userbot handles everything the user hears. Commands flow face → brain; status flows brain → face.
The Streaming Stack
Audio streaming uses py-tgcalls 3.0.0 with ntgcalls 3.0.2 — the modern rewrite, not the legacy pytgcalls package. The 3.x line replaced the old FFmpeg-pipe approach with native bindings, which matters for stability: fewer moving parts between the decoded audio and the voice chat means fewer ways for the stream to desync or die silently.
Sources are resolved with yt-dlp, so YouTube, TikTok, SoundCloud, and direct audio URLs all work through the same path: resolve → download audio → feed to the voice chat.
Key Features
📖 Full command reference — all 30 commands with syntax, examples, aliases & secret handshakes:
sweet-bunny-commands →
Fallen X-Style Player UI
The "Now playing" message is a compact player: a button row (⏪ ⏸/▶ ⏩ ⏭ ☐) plus a live progress bar like 00:09 ────◉ 23:45, refreshed every 5 seconds. It feels like a real music player embedded in the chat.
Natural-Language Commands
Commands work two ways: /cmd directed at the face bot, or plain-text bunny, <command> that anyone in the group can type. The second form requires the face bot's privacy mode to be disabled — otherwise it never sees group messages that aren't commands or replies.
Gotcha: toggling privacy mode in BotFather only takes effect after removing and re-adding the bot to the group. I lost time to this.
Queue Management
play, skip, pause, resume, stop, queue — the full set. "Queued" confirmation messages auto-delete when their song starts, so the chat doesn't fill up with stale notifications.
Video Playback (vplay)
Replying to an uploaded video with bunny, vplay streams it into the voice chat as video. There's one path for resolving stream URLs: they download through the server's own endpoint using the main account, because the userbot isn't a member of every group the media comes from. Direct stream URLs (bunny, link) load near-instantly. Downloads sustain around 10–11 MB/s, so multi-hundred-megabyte files are practical now — they just weren't, before I found the real bottleneck (see below).
One honest flake: video resolved from YouTube sources is unreliable — replying to the actual uploaded video file with bunny, play is the dependable path.
Cache System
bunny, cache <URL> (or replying bunny, cache to a forwarded video) downloads media once and stores it locally, with live progress showing the real transfer rate. A later bunny, vplay on the cached item plays instantly — no re-download. bunny, cache list and bunny, clear cache manage the store.
Lyrics
Synced lyrics via the syncedlyrics package, for when the group wants to sing along.
Challenges & Solutions
1. The Frozen "Now Playing" Message
Problem: When a track ended and the next one auto-advanced, the player UI stayed frozen on the first song.
Fix: At each track end, the face bot polls the userbot for "what's playing now?" and refreshes the message. The UI is derived from actual player state, never assumed.
2. Playing to an Empty Room
Problem: If the voice chat was ended mid-playback, the bot kept "playing" with nobody listening — queue intact, UI stale.
Fix: Detect the dead call and treat it as a stop: halt playback, clear the queue, and clean up the Now playing message plus any leftover Queued confirmations. Fail into a clean state, not a zombie one.
3. The 100× Slowdown Was My Own Crypto
Problem: Downloads crawled — Pyrogram at 75 KB/s, Telethon at 0.6 MB/s. I had been documenting "Telegram throttles to ~2 MB/s" as an immutable platform ceiling and designing features around it.
Fix: The bottleneck was never Telegram. I challenged the ceiling with real-world evidence ("some bots really are fast"), and my assistant investigated instead of defending the old number — she found it in our own venv: both libraries ship fast-path crypto only when optional native libs are installed (tgcrypto for Pyrogram, cryptg for Telethon), and ours had neither. Installing them took Pyrogram to 11.3 MB/s and Telethon past 10 MB/s: a ~150× improvement from two packages.
Rule: check those libs after every venv rebuild, or speeds silently collapse back. And when someone with real-world evidence contradicts your stated ceiling, investigate on their word instead of defending it — this entry exists because of exactly that.
4. Blocking Handlers
Problem: Long-running commands (like a slow download) blocked all other commands.
Fix: Non-blocking message handlers — every command runs independently so skip works even while something else is still downloading.
5. Stale Messages After Restarts
Problem: If the process died and restarted, the old "Now playing" message pointed at a dead session.
Fix: On startup, silently delete the stale player message. The next play creates a fresh one.
6. Session Safety
The Telethon session file is load-bearing infrastructure — losing it means re-authentication on an account whose SIM I can't easily access. It is backed up, never deleted, and no tool or script touches login state without explicit instruction. My assistant once nearly wiped it mistaking it for a temp file — then recovered it intact via /proc file descriptors and put it on her permanent "never touch" list.
Lessons
1. Split Brain and Face Deliberately
The userbot=brain / facebot=face split wasn't just convenient — it made each half simpler. The brain never renders UI; the face never touches media. When something breaks, I know which half to look at.
2. Derive UI From State, Never Assume It
Every "Now playing" freeze I fixed came down to the same root cause: the UI assumed something about the player instead of asking. Polling actual state at every transition eliminated the whole class of bug.
3. State the Ceiling Honestly — Then Re-Test It
I used to document "Telegram throttles downloads to ~2 MB/s, no amount of code changes that." That was wrong — the throttle was my own missing crypto libraries, and evidence from the person actually using the system proved it. Honesty about limits is still the policy; the amendment is that a ceiling is a hypothesis, not a law. When counterevidence shows up, investigate it, don't defend the old number.
4. Clean Up After Yourself
Auto-deleting stale "Queued" messages, removing dead player UIs on restart, clearing the queue when the call dies — a bot that litters the chat is a bot people mute. Ephemeral status, persistent value.
5. Platform Quirks Are Documentation
Privacy mode needing a re-add, Bot API not joining voice chats, the 3.x tgcalls rewrite — none of this is in one place. I keep a running log of every quirk I hit, because the next bug is usually a quirk I forgot.
What's Next
The system is stable and in daily use. Possible extensions:
- Per-group queues and DJ roles.
- Smarter cache eviction (LRU instead of manual clearing).
- Playlist imports from YouTube/Spotify links.
But the core loop — type bunny, play, hear music — works, and that's what matters.
Built for myself, by myself — with my AI assistant, sweet bunny 🐰, co-building every step of the way. Where an idea on this page was hers, it's credited inline.
← Back to home