HOW NOT TO KILL
A TELEGRAM BOT
IN SIX YEARS
A postmortem of ERTB - a pet project that grew to 278 thousand users without a cent of advertising, lost 90% of its activity, and taught me that stability matters more than features.
- One developer, one VPS, one flag in the profile.
- PATH
- ~/notes/01-ertb-postmortem.md
- DATE
- 2026-05-19
- READ
- ~16 MIN · ~3,500 WORDS
- AUTHOR
- Andrii Volkov · @volkovskey
- TAGS
- telegram · python · postmortem · ertb
- SERIES
- /notes · 01
ERTB - Exchange Rates Telegram Bot - started on June 9, 2020 as a one-evening project. Eleven months later we had 100,000 users. Nineteen months later - 200,000. Now in its sixth year, it processes 2,500 conversions a day - ten times less than at the 2024 peak. As of writing, I maintain it alone.
This is an article about six years. About what kept the bot alive, and about what slowly killed it. The second wasn't growing load, wasn't Telegram API changes, wasn't competitors. The second was us.
K/DAY
27 ┤ █
23 ┤ █ █ █
20 ┤ █ █ █ █
17 ┤ █ █ █ █ █
13 ┤ █ █ █ █ █ █ █
10 ┤█ █ █ █ █ █ █ █
5 ┤█ █ █ █ █ █ █ █ █ █
1 ┤█ █ █ █ █ █ █ █ █ █ █ █ █ █
└─────────────────────────────
'23 '24 '25 '260x01WHERE IT IS NOW
Numbers as of May 2026 (statistics collection started in March 2023):
- ≈7,970,000 messages processed all time
- ≈278,000 unique users
- ≈14,500 groups have added the bot at least once
- ≈2,500 conversions per day. At peak it was 25,000.
- 13 interface languages, 6 for word-to-number recognition
- Hosting - €15 per month plus €10 per year for the domain. 8 GB RAM, 4 CPU.
The team - formally me and two others. In practice, for the last year on ERTB I work alone. I shipped 5.0 solo, in pair-programming mode with Claude Code. Releases have become rarer: once a quarter, sometimes less.
This article is written at a time when the bot is shrinking rather than growing. It's not a funeral. It's an inventory. As long as €15 a month is something the team can carry, ERTB will keep running.
0x02HOW IT GREW · 2020-2022
June 9, 2020 - first commit. I wrote the bot for one group chat with friends from different countries where "how much is that in hryvnia" kept coming up. June 10 - first working version. July 13 - release 1.0.
The stack at that point: Python, aiogram via long polling, six currencies (UAH, USD, EUR, RUB, BYN, PLN), rates from NBU, user settings - in text files. Unencrypted. Running in `screen` on a cheap VPS.
Six months later - 37,000 users. Eleven months - a hundred thousand. Nineteen - two hundred. Zero advertising. All of it - group chats: someone added the bot to one group, then wrote in another group of friends, the bot got added there too. Word of mouth in an environment where Ukrainian- and Russian-speaking diaspora live across Europe, the post-Soviet space, and Canada.
None of these numbers were planned. Nothing in the 2020 code had been designed for that scale. In December 2020 I wrote in the channel:
I'm ashamed to admit it, but all bot settings are stored as plain text files, unencrypted.
On February 24, 2022, at 5 a.m. Kyiv time, Russia launched its full-scale invasion. The bot kept working. The channel posted the Armed Forces of Ukraine donation details. "Offended" reactions from Russian accounts started showing up under our posts - we stopped reacting. The Ukrainian flag stayed in the bot's profile. It's still there.
0x03FIRST CRISIS · DDoS, OCT 2020
On October 22, 2020, someone discovered that the bot didn't limit the number of currencies in its reply. If you sent a message that matched 300 currencies, the bot would diligently convert all 300 and send back an enormous reply. Telegram doesn't love that - bot rate limit, then temporary blocking.
First it was DoS - one account. Closed quickly. The next day - DDoS, from a dozen accounts. The first fix didn't hold. I sat there writing a patch and, in parallel, was DM'ing the person doing it. Eventually she wrote out how she'd been bypassing the first version of the defense. I'm grateful to her - without her, we'd have closed this differently, and later.
Out of that hotfix grew a defense system called StopDDoS. Three years later it was rewritten from scratch as Mjolnir. The logic is simple - and that is the whole point.
LIMIT_OF_CURRENCIES_PER_MESSAGE = 50 ACTIVITY_WINDOW_SECONDS = 1800 # 30 minutes COEF_PER_MESSAGE = 20 # per-message trigger COEF_FOR_AVERAGE = 5 # cumulative trigger TIER_LADDER_HOURS = [1, 2, 12, 24, 72, 168, 720, 8760, 100000] QUIET_PERIOD_DAYS = 30
A sliding 30-minute window holds a deque of events (user_id, count_of_cur, time). On top - a dict with per-user stats (how many times the bot was used, how many currencies in total).
For each message we compute averages - currencies per message and uses per user - excluding the current user from the calculation. This is the key nuance: otherwise a heavy spammer skews the baseline they themselves are being checked against.
Triggers:
- Hard cap. Message contains more than 50 currencies - instant ban.
- Per-message ratio. Currencies in a single message exceed the average × 20 - instant ban.
- Cumulative. Over 30 minutes, the user has both currencies × 5 over average and uses × 5 over average - ban.
The ban isn't forever. It escalates on a ladder: 1 hour → 2 → 12 → 24 → 72 (3 days) → 168 (a week) → 720 (a month) → 8,760 (a year) → 100,000 (≈11 years) → permanent. Between bans sits a 30-day quiet period: if there's no active ban for 30 days, the next ban resets to 1 hour. This kills most false positives - someone clicked something weird, got an hour, came back next month, nothing happened.
Mjolnir uses neither ML nor anomaly detection. It's a counter with an adaptive average that compares you against yesterday-you and against everyone-else-today. Over five years it has automatically banned hundreds of accounts and has not banned a single real user whose complaint reached us.
Defense doesn't need ML. Defense needs a counter you can explain line by line.
0x04REGEX, NOT NLP
The heart of ERTB isn't the Telegram part. The heart is the parser: the algorithm that takes an arbitrary group-chat message and answers "is there a number with a currency here, and which". Without it the bot is a hollow shell.
The parser lives separately from the bot's body. Since June 2024 it and rate-fetching have been carved out into their own .NET 8 API - ExRates Connect. ERTB calls it. So does ERDB (the Discord version). So will anything we ever build.
Pipeline - 8 steps.
- Short-circuit. If the input is shorter than 2 characters, return an empty response immediately. Cuts load by an order of magnitude.
- Clean. Lowercase. Strip @mentions and URLs. Normalize thousand separators: 1,000 / 1 000 / 1.000.000 - collapse into one format. Decimal comma → dot. Collapse whitespace. \n → " , ". All regexes statically compiled.
- Belarusian context flag. If the message looks Belarusian - set a flag so that RUB later gets promoted to BYN. A small detail without which Belarusians would get Russian rubles instead of their own.
- Split. A hand-written tokenizer on letter↔non-letter and digit↔non-digit boundaries. Tokens with two or more dots are dropped - this catches IP addresses and version numbers that used to land in the pipeline.
- Convert. MathToNumber detects 25*35, 150/3, 69-47 in tokens and normalizes -/-/÷ to standard operators. WordToNumber handles German via the ported Zahlwort2Num and other languages via Levenshtein fuzzy-match over a w2nTokens table, with an LRU cache of 32,768 entries - so "twinty five" and "двадцять пять" both turn into a number.
- Search. For each numeric token we search currencies in a trie in four directions, in this order: forward 1 word, forward 2, backward 1, backward 2. Take the deepest matched CurrencyCode. Fiat vs crypto routing - by the code's membership.
- Promote RUB → BYN. If step 3 set the Belarusian flag, override.
- De-dup. Group results by Pair.Equals(value, code), return without duplicates.
That's about 300 lines of production C#. Six languages for word-numbers. Stable sub-millisecond per message. Parser logs are readable by eye, errors localize down to a specific step.
In spring 2026 I ran a couple of weeks of shadow logging: for every real message we ran a local LLM at the Gemma 4 E2B level alongside the main parser, then Gemma 4 E4B. Same task: extract number + currency.
- On simple cases, models and parser agreed in 99% of cases.
- On complex cases, models and parser agreed in 75%. Models returned less than the parser found (24% miss), and only in 1% returned more - and that was wrong.
Conclusion: for this task, sub-8B LLMs improve nothing. Maybe better models would, but the compute gets too expensive.
A separate note - about languages. I'm not a linguist, and I'm not Belarusian, Polish, Portuguese, or Uzbek. The parser knows six languages because rotlir added Belarusian, jpzex - Brazilian Portuguese, рис - the Uzbek interface, Abviol - Polish recognition. Open source as a growth model didn't work for us - more on that below. But as a way to bring in four specialists who did things we wouldn't have done ourselves - it worked perfectly.
0x05THE FLAG AND THE SCAMMERS
The Ukrainian flag has been in the bot's profile since February 24, 2022. Ukrainian is one of the two languages always guaranteed (the other is English). The first messages in the channel after the war began were AFU donation details. The first replies - angry reactions from Russian accounts under our posts.
It cost us part of the audience. Russian groups with Russian users were using the bot. Some stopped after seeing the flag. The rest stopped after January 2024.
On January 23, 2024, we declared war on scam chats. For six months before that, we'd been collecting statistics - which chats were heavily using the bot for "send me $500 and get $50,000" or "send crypto, we'll return fiat" schemes. We put all the ones we found onto an internal blocklist. The bot stopped replying in those chats. We didn't read messages - we just set triggers on suspicious phrases and known scammer vocabulary.
Until January 2024 the repository was public. Occasionally stars showed up, a few forks - nothing notable. We thought openness would bring contributors in. It didn't. What it did was prepare the ground for alternatives that took our code and added things we absolutely couldn't endorse. At some point a fork appeared with a Russian flag and donations to the Russian armed forces instead of the Ukrainian ones. That was the second reason we closed GitHub.
On January 1, 2024 we stopped maintaining the public repo. What's at Lanasys/exchange-rates-tg-bot-api-powered now is README, releases, navigational info - no active parser or API code. The public bot stayed at version 3.0, and even that version we eventually pulled - it's no longer public either.
I don't regret the flag for a second. I don't regret closing GitHub for a second. It cost us users and it cost us the "open-source enthusiast" identity. I'm ready to pay that price.
0x06MIGRATIONS
For the first three years, the bot stored everything in SQLite. Up to a point that worked. Up to the point where, under concurrent writes across a few connections, the database started breaking - sometimes we couldn't read or write at all. Index corruption, locking timeouts, occasional plain hangs.
On March 18, 2024, we migrated to MySQL 8.
"Why MySQL and not Postgres" - I have an honest answer. I didn't know Postgres yet. MySQL I did, because they taught it in our university labs. That's the entire strategic decision. Two years on: MySQL easily handles the current load, replication is set up, backups run, and aiomysql's connection pool gives me no surprises. Postgres would have been more elegant in some places. But it would have been a choice I'd have spent weeks on instead of two days.
In June 2024 - the second major migration. The parser and rate-fetching get carved out of the bot's body into their own HTTP service in .NET 8 - ExRates Connect. Three reasons:
- ERDB (Discord) launched in March on the same parser as ERTB. Duplicating logic across two codebases is a recipe for slow drift.
- I'd rather write the parser in the language I find easiest to keep in production - for me that's C#, not Python.
- An API gives me one place for logging, metrics, parser A/B tests. Including the LLM shadow logging I ran in 2026.
The bot's body stayed on Python 3.10 and aiogram 3.27. Deploy - GitHub Actions: on push to main, a worker spins up, runs tests (pytest, pytest-asyncio, pytest-benchmark), deploys over SSH to the VPS, restarts the screen session. Trivial, a few dozen lines of YAML, zero Kubernetes.
A boring stack isn't a conservative choice. It's an investment in not spending attention on infrastructure at the moment attention is needed elsewhere.
0x07PRO AND OTHER ATTEMPTS
On August 24, 2024, we launched ERTB Pro. A subscription through Telegram Stars, 150⭐ per month. What was exclusive: inline mode, math operations, Word-To-Number, no ads in the bot's replies.
Month one - about five subscribers.
On September 16, we dropped the price to 100⭐. Added 3, 6, and 12-month options. Brought Word-To-Number back into the free tier - it had become obvious that people would migrate to a competitor before they'd pay for something that used to be free.
Over a year, ERTB Pro in any form was bought by about a dozen people. 2,550⭐ in total all-time revenue. In Telegram Stars that covers maybe two months of hosting.
The problem wasn't the money. The problem was that we'd put features behind a paywall and then stopped getting feedback on them. Inline mode - the top-requested feature from 2021 through 2023 - stopped sending us a signal about what was wrong with it once it went paid. A rich test for parser stability on complex expressions - same thing, locked behind a paywall where almost no one used it.
In 5.0 I closed Pro. Everyone who'd ever bought a subscription moved to the donor list - the one shown in /about. All features are open to everyone. I understood: there's no paid tier here now, and there won't be one.
In parallel with Pro, we tried two more monetization branches.
ERDB - Exchange Rates Discord Bot. Open beta in March 2024. The word-of-mouth that worked in Telegram groups didn't work in Discord. Discord is either voice chat or topic channels, where "a bot that reacts to messages about exchange rates" doesn't have the same natural audience as a diaspora group on Telegram.
Ads in the bot. The admin interface has a /money command - three ad campaigns: a link in the response footer, a button under the response, a separate donation reminder. Optional, with a whitelist for those who don't want to see them. The campaigns sold a few times to ethical advertisers. Proposals came in often - most from online casinos, gambling, scam projects. To all of those, one answer: no, thanks. We sit at a point where ads in the bot could pay for it, but they would be ads of a kind we'd rather not sleep next to.
0x08THE BIG MISTAKE
This section is the shortest. And the most important.
I look at the activity graph for the last three years. Peak - June 2024, 25-27 thousand conversions a day. Six months later - collapse. By early 2025 we were back down to 5 thousand in a week. Then - a steady slide to today's 2.5.
Honestly, this part is simple - sporadic outages lasting a day or two, overdue rate refreshes, support-bot complaints we answered once every three months. I saw it. The team saw it. We knew. And in the same period we kept shipping new features. Launching the subscription. Drawing a new logo.
Users didn't betray us. They got a signal from us that we were unreliable. The bot stops refreshing rates for a day - a person stops using it. Once. Twice. The third time they remove it from the group. And the bot doesn't come back - even if we fix it a month later.
All the pretty arguments about competition, about Telegram groups becoming mobile, about Telegram changes - noise. The real reason - when we should have been putting out fires, we were building.
If you have a project that has hit a peak, that isn't a signal to add features. It's a signal to stabilize, monitor, answer support within 24 hours instead of 24 days. It's boring. You can't post it in the channel as "we added…". And it's what holds the product together.
0x095.0 · ACCEPTED IT
On May 14, 2026, I released ERTB 5.0.
The release notes came out long, but if you look honestly at the arc - it's a release that draws a line. We dropped Pro. We repainted the interface. We added /card with rare-collectible mechanics - more of a nostalgic genre than a functional feature. We added /privacy as an explicit statement: "we're not a commercial product, we don't trade your messages, we don't care about you beyond returning a rate as fast as we can." We added 6 new interface languages. We moved all DB work to async.
I did all of it alone. Without Claude Code in pair-programming mode it would have taken a month or two instead of two weeks. That's my new reality - and it underscores again why Pro had to be closed, because everything is now coming down to AI. Why did the bot blow up in the first place? Because people were spending a lot of time at home (COVID-19), talking to each other, and instead of being distracted by a browser or a separate currency-conversion app, they got the answer right there in the chat. And these days it's easier to ask an AI than to rely on an unreliable bot.
The most important change in 5.0 isn't in the patch notes. It's in the tone. Before, we used to write in the channel "we'll also add…". In the 5.0 release we wrote "everything is available to everyone." This isn't marketing. It's a position.
2020 - 1.0 · 2021 - 2.0 · 2023 - 3.0 + 4.0 · 2024 - 4.4 + Premium · … · 2026 - 5.0
0x0ALESSONS
Short lines for what I'd tell my 2020 self.
- Stability matters more than features when you're at the peak. This is the one lesson worth remembering even if you forget the rest. When users are many and active - invest in keeping the bot up, refreshing rates, answering support. Everything new comes after.
- A parser on a narrow task beats an LLM threefold. Disciplined rules + lookup + fuzzy-match + LRU give you millisecond latency and zero operational surface. An 8B model in shadow logging didn't beat that. When the domain is clearly bounded - don't waste time on neural nets.
- One backend service for all bots. Parser and rates in a shared API save you from rewriting at every new platform. Implement once - call it from every Lanasys bot. We should have done it earlier.
- Open source isn't a growth model for a small B2C bot. It gives you four good translators and zero core contributors. It also gives your detractors material to fork. A small project without clear community resources doesn't get from openness what it expects.
- Donation > Subscription for a bot service. People are willing to chip in because they like you. People aren't willing to subscribe because that's a different psychological frame - they expect guarantees, documentation, an SLA. If you're one developer with a VPS, donation mode is more honest and more effective. Even if there are three of you.
- A political stance is expensive. Hold it only if you're willing to pay. The flag in the profile, banning scam chats, refusing the Russian market - that cost us users. And I'd do it all again. Because some things aren't for sale.
0x0BWHAT I'D BUILD TODAY
Almost the same thing, honestly. aiogram 3, Postgres instead of MySQL, .NET API separate. It's a boring stack that works. I don't believe that swapping it for something fashionable would have helped ERTB.
What I'd do differently:
- Alerting on rates older than a day - from day one. The single biggest lever we didn't have for five years.
- Parser as a separate repo from scratch. Not inside the bot. Not "we'll carve it out later when we need to." Separate immediately, because it's the core of the product, and the bot's body is just an adapter to Telegram.
- No Pro. Direct donation from day one, no subscription. If you want - pay, get your name in /donors. That's it.
The rest - as it was. One developer, one VPS, one flag in the profile. On year six this feels like a reasonable measure of success.