The problem
People send each other video that will not upload, voice notes nobody wants to listen to, and
clips that need to be a GIF. Every one of those is a job for ffmpeg — and almost
nobody is going to install ffmpeg.
The first version did the obvious thing: run the conversion right there in the update handler. It worked until two people used it at once. A ten-minute transcription blocked the thread that receives updates, and the bot stopped answering everyone — including people who only wanted to say hello.
Constraints and tradeoffs
The update thread must never do work
Receiving and processing are separated by a queue. The handler validates, enqueues and answers immediately; workers pull jobs and do the slow part. This is the whole architecture, and it is the only decision that actually mattered.
256 MB of heap, on purpose
The target is the cheapest VPS that exists. That rules out loading media into memory: every
operation streams through temporary files, and the worker count is small and fixed. The
constraint is enforced in the launcher (-Xmx256m) so that a change which breaks
it fails here rather than on the server at 3 a.m.
Two transcription engines behind one interface
WhisperCppTranscriber runs locally — free, private, slow on weak hardware.
ApiTranscriber is fast and costs money. Both implement Transcriber
and the choice is config, not code, so the same build runs on a laptop and on a server with a
budget.
What I traded away
- Telegram's 20 MB download limit stays. Working around it means running a local Bot API server — a second piece of infrastructure to operate, for a minority of files. The bot explains the limit instead of pretending.
- No distributed queue. The queue is in-process; a restart loses in-flight jobs. For a personal-scale bot, Redis would be more moving parts than the problem deserves.
- No progress bars. Editing a message every second to animate a percentage burns rate limit for decoration. One “working on it”, then the result.
Architecture
Screens and states
Compression. The user gets a position immediately, not silence.
Transcription. Long results go as a file — a wall of text in a chat is unreadable.
Limit reached. Explaining the platform limit beats a generic failure.
Housekeeping. Temporary files are deleted after every job; the disk is the real limit.
Results and lessons
What I would tell myself at the start
- A queue is not premature optimisation for a bot. It is the difference between a toy and something two people can use at the same time. I should have started there.
- Delete temporary files in a
finally. The disk filled up first, well before memory ever became a concern. Cleanup on the happy path only is not cleanup. - Put the memory ceiling in the launcher. A limit that only exists on the production server is a limit you discover by breaking production.
- Name the platform limit in the error text. Support questions dropped to nearly nothing once the message said why, not just failed.