ЯК НЕ ВБИТИ
TELEGRAM-БОТ
ЗА ШІСТЬ РОКІВ
Постмортем ERTB - пет-проєкту, що зібрав 278 тисяч користувачів без жодної реклами, втратив 90 % активності і навчив мене, що стабільність важить більше за фічі.
- Один розробник, один VPS, один прапор у профілі.
- ШЛЯХ
- ~/notes/01-ertb-postmortem.md
- ДАТА
- 2026-05-19
- ЧИТАННЯ
- ~17 ХВ · ~3 000 СЛІВ
- АВТОР
- Андрій Волков · @volkovskey
- ТЕГИ
- telegram · python · postmortem · ertb
- СЕРІЯ
- /notes · 01
ERTB - Exchange Rates Telegram Bot - стартував 9 червня 2020 як проєкт на один вечір. Через 11 місяців нас було 100 000 користувачів. Через 19 місяців - 200 000. На шостий рік існування він обробляє 2 500 конвертацій на день - у десять разів менше, ніж у піку 2024-го. На момент написання я підтримую його сам.
Це стаття про шість років. Про те, що тримало бот живим, і про те, що його повільно вбило. Друге було не зростанням навантаження, не змінами Telegram API, не конкурентами. Друге - це ми самі.
К/ДЕНЬ
27 ┤ █
23 ┤ █ █ █
20 ┤ █ █ █ █
17 ┤ █ █ █ █ █
13 ┤ █ █ █ █ █ █ █
10 ┤█ █ █ █ █ █ █ █
5 ┤█ █ █ █ █ █ █ █ █ █
1 ┤█ █ █ █ █ █ █ █ █ █ █ █ █ █
└─────────────────────────────
'23 '24 '25 '260x01ДЕ ВІН ЗАРАЗ
Цифри на травень 2026 (статистика почала збиратись з березня 2023 року):
- ≈7 970 000 повідомлень оброблено за весь час
- ≈278 000 унікальних користувачів
- ≈14 500 груп, які хоч раз додали бота
- ≈2 500 конвертацій на день. У піку було 25 000.
- 13 мов інтерфейсу, 6 - для розпізнавання слів-чисел
- Хостинг - €15 на місяць плюс €10 на рік за домен. 8 GB RAM, 4 CPU.
Команда - формально я і ще двоє. Реально по ERTB останній рік працюю сам. 5.0 я зробив у соло, у форматі парного програмування з Claude Code. Релізи стали рідшими: раз на квартал, інколи рідше.
Стаття написана у час, коли бот скоріше зменшується, ніж росте. Це не похорон. Це інвентаризація. Поки €15 на місяць - підйомна сума для команди, ERTB буде жити.
0x02ЯК ВОНО ВИРОСЛО · 2020-2022
9 червня 2020 - перший commit. Я писав бот для одного групового чату з друзями з різних країн, де постійно треба було переводити «скільки це в гривнях». 10 червня - перша робоча версія. 13 липня - реліз 1.0.
Стек на той момент: Python, aiogram через long polling, шість валют (UAH, USD, EUR, RUB, BYN, PLN), курси з НБУ, налаштування користувачів - у текстових файлах. Незашифровано. Через screen на дешевому VPS.
Через шість місяців - 37 000 користувачів. Через 11 - сто тисяч. Через 19 - двісті. Нуль реклами. Усе - груп-чати: люди додавали бот в одну групу, хтось писав цю саму групу друзям, додавали туди теж. Сарафанне радіо у середовищі, де є україно- та російськомовна діаспора у Європі, СНД, Канаді.
Жодне з цих чисел не було запланованим. Ніщо в коді 2020 року не було спроєктовано під такий розмір. У грудні 2020 я писав у канал:
Мені соромно зізнаватись, але всі налаштування бота зберігаються у вигляді текстових файлів у незашифрованому вигляді.
24 лютого 2022 о пʼятій ранку РФ почала повномасштабне вторгнення. Бот продовжив працювати. Канал додав реквізити для донатів ЗСУ. На пости стали приходити «ображені реакції» з російських акаунтів - ми перестали на них реагувати. У боті у профілі залишився український прапор. Він там і досі.
0x03ПЕРША КРИЗА · DDoS, ЖОВТ 2020
22 жовтня 2020 хтось знайшов, що бот ніяк не обмежує кількість валют у відповіді. Якщо написати повідомлення, де матчиться 300 валют, бот старанно конвертував усі 300 і відправляв величезне повідомлення. Telegram цього не любить - bot rate limit, потім тимчасове блокування бота.
Спочатку був DoS - один акаунт. Швидко закрили. Через день - DDoS, із десяткою акаунтів. Перший фікс не тримав. Сидів писав заплатку, паралельно у DM спілкувався з людиною, що це робила. Зрештою вона написала, як саме обходила перший варіант захисту. Я дякую їй - без неї ми б це закрили інакше і пізніше.
З того хотфіксу виросла система захисту, що називалась StopDDoS. Через три роки її повністю переписали у Mjolnir. Логіка проста - і це принципово важливо.
LIMIT_OF_CURRENCIES_PER_MESSAGE = 50 ACTIVITY_WINDOW_SECONDS = 1800 # 30 хвилин COEF_PER_MESSAGE = 20 # триггер на одне повідомлення COEF_FOR_AVERAGE = 5 # триггер на кумулятивну активність TIER_LADDER_HOURS = [1, 2, 12, 24, 72, 168, 720, 8760, 100000] QUIET_PERIOD_DAYS = 30
Ковзне 30-хвилинне вікно тримає deque подій (user_id, count_of_cur, time). Поверх - dict із per-user статистикою (скільки разів використано, скільки валют сумарно).
На кожне повідомлення обчислюються середні - кількість валют на повідомлення і кількість використань на користувача - виключаючи поточного користувача з розрахунку. Це ключовий нюанс: інакше масивний спамер сам спотворює baseline, проти якого його ж і перевіряють.
Триггери:
- Hard cap. Повідомлення містить більше 50 валют - миттєвий бан.
- Per-message ratio. Кількість валют у повідомленні більша за середню × 20 - миттєвий бан.
- Cumulative. За 30 хвилин користувач має і валют × 5 від середнього, і використань × 5 від середнього - бан.
Бан не назавжди. Він ескалюється по сходах: 1 година → 2 → 12 → 24 → 72 (3 доби) → 168 (тиждень) → 720 (місяць) → 8 760 (рік) → 100 000 (≈11 років) → permanent. Між банами стоїть 30-денний quiet period: якщо 30 днів немає активного бану, наступний бан скидається на 1 годину. Це знімає більшість false positive - людина натиснула щось дивне, отримала годину бану, прийшла наступного місяця - наче нічого не було.
Mjolnir не використовує ні ML, ні anomaly detection. Це лічильник з адаптивним середнім, який порівнює тебе з тобою-вчорашнім і з усіма іншими-сьогоднішніми. За пʼять років він автоматично забанив сотні людей і не побанив жодної реальної людини, скарга якої б до нас дійшла.
Захист не потребує ML. Захист потребує лічильника, який ти вмієш пояснити по рядку коду.
0x04REGEX, А НЕ NLP
Серце ERTB - не Telegram-частина. Серце - це парсер: алгоритм, який бере довільне повідомлення з групи і відповідає на питання «чи є тут число з валютою, і яке». Бот без нього - пуста оболонка.
Парсер живе окремо від тіла бота. З червня 2024 він і отримання курсів винесені у власне API на .NET 8 - ExRates Connect. Туди ходить ERTB. Туди ж ходить ERDB (Discord-варіант). Туди ж ходитиме все, що ми колись зробимо.
Pipeline - 8 кроків.
- Short-circuit. Якщо вхідний рядок коротший за 2 символи - повертається порожня відповідь негайно. Зменшує load на порядок.
- Clean. Lowercase. Видалити @mentions і URL. Нормалізувати роздільники тисяч: 1,000 / 1 000 / 1.000.000 - звести до одного формату. Дробова кома → крапка. Сколапсувати пробіли. \n → " , ". Усі regex компільовані статично.
- Belarusian context flag. Якщо повідомлення виглядає білоруським - виставити прапорець, щоб пізніше RUB промотати у BYN. Маленька деталь, без якої білоруси отримували б російські рублі замість своїх.
- Split. Ручний токенізатор по межах літера↔не-літера і цифра↔не-цифра. Токени з двома і більше крапками викидаються - це ловить IP-адреси і номери версій, що раніше прилітали у пайплайн.
- Convert. MathToNumber виявляє 25*35, 150/3, 69-47 у токенах і нормалізує -/-/÷ у стандартні оператори. WordToNumber: німецькі - через портований Zahlwort2Num; решта мов - Levenshtein fuzzy-match по таблиці w2nTokens з LRU-кешем 32 768. «twinty five» і «двадцять пять» однаково перетворюються на число.
- Search. Для кожного числового токена шукаємо валюту у trie у чотирьох напрямках, у такому порядку: forward 1 слово, forward 2, backward 1, backward 2. Беремо найглибший знайдений CurrencyCode. Маршрутизація fiat vs crypto - за приналежністю коду.
- Promote RUB → BYN. Якщо крок 3 виставив білоруський прапорець - перебиваємо.
- De-dup. Групуємо результати по Pair.Equals(value, code), повертаємо без дублів.
Це ≈300 рядків продакшен-коду на C#. Шість мов підтримки слів-чисел. Працює стабільно під мілісекунду на повідомлення. Логи парсера читаються очима, помилка локалізується по конкретному кроку.
Весною 2026 я провів пару тижнів shadow-логування: на кожне реальне повідомлення паралельно з основним парсером запускалась локальна LLM рівня Gemma 4 E2B, потім Gemma 4 E4B. Завдання - той самий extract: число + валюта.
- На простих кейсах моделі і парсер сходились у 99 % випадків.
- На складних кейсах моделі і парсер сходились у 75 % випадків. Моделі давали менше знайденого (24 %) і лише в 1 % давали більше, але і те було помилково.
Висновок: для цієї задачі < 8B LLM нічого не покращує. Можливо потрібні кращі моделі, але compute стає занадто дорогим.
Окрема нота - про мови. Я не лінгвіст і не білорус, не поляк, не португалець, не узбек. Парсер знає шість мов тому, що rotlir додав білоруський, jpzex - португальський (бразильський), рис - узбецький інтерфейс, Abviol - польське розпізнавання. Open source як модель розвитку не зайшов нам - про це далі. Але як спосіб залучити чотирьох спеціалістів, які зробили те, чого ми б самі не зробили, - він спрацював ідеально.
0x05ПРАПОР І СКАМЕРИ
Український прапор у профілі бота стоїть з 24 лютого 2022. Українська - одна з двох мов, що завжди гарантовано підтримується (друга - англійська). Перші повідомлення на каналі після початку війни - реквізити ЗСУ. Одразу - реакції розгніваних російських акаунтів на наші пости.
Це коштувало нам частини аудиторії. Російські групи з нашою аудиторією - використовували бот. Частина з них перестала після того, як побачила прапор. Інша частина перестала після січня 2024.
23 січня 2024 ми оголосили скам-чатам війну. Шість місяців до цього збиралась статистика - які чати масово використовують бота під схеми «переведи мені 500 $ і отримай 50 000 $», «відправ криптою - повернемо фіатом». Ми внесли всі знайдені у внутрішній blocklist. Бот перестав відповідати у цих чатах. Ми не читали повідомлення - просто налаштували тригер на підозрілі фрази і слова, якими користуються скамери.
До січня 2024 репозиторій був публічний. У ньому час від часу зʼявлялись зірки, кілька форків - нічого визначного. Ми думали, що відкритість допоможе залучити допомогу. Не допомогла. Натомість підготувала ґрунт для альтернатив, які брали наш код і додавали туди те, що нам категорично не підходило. У якийсь момент зробили форк нашого бота - з російським прапором і донатами на ЗС РФ замість української армії. Це і була друга причина, чому ми закрили GitHub.
1 січня 2024 ми перестали підтримувати публічний repo. Поточна гілка на Lanasys/exchange-rates-tg-bot-api-powered - це README, релізи, лоцманська інформація, без активного коду парсера і API. Бот лишився на версії 3.0, але навіть таку версію ми прикрили потім, і вона більше не є публічною.
Про прапор я не шкодую ні секунди. Про закриття GitHub - ні секунди. Це коштувало нам користувачів і коштувало «ідеї open source ентузіаста». Я готовий платити цю ціну.
0x06МІГРАЦІЇ
Перші три роки бот зберігав усе у SQLite. До певного моменту це працювало. До моменту, коли під одночасними записами в кілька з'єднань база починала ламатись - інколи неможливо було ні зчитати, ні записати. Корупція індексів, locking timeouts, інколи просто залипання.
18 березня 2024 ми мігрували на MySQL 8.
Питання «чому MySQL, а не Postgres» - у мене є чесна відповідь. Postgres я тоді ще не вмів. MySQL - вмів, бо нас його вчили на лабах в універі. Це і весь стратегічний вибір. Через два роки бачу: MySQL легко тримає поточне навантаження, реплікація налаштована, бекапи їдуть, з пулом зʼєднань через aiomysql ніяких сюрпризів. Postgres був би елегантнішим у деяких місцях. Але це був би вибір, на який я б витратив тижні замість двох днів.
У червні 2024 - друга велика міграція. Парсер і отримання курсів виносяться з тіла бота у власний HTTP-сервіс на .NET 8 - ExRates Connect. Причини три:
- ERDB (Discord) у березні запустили на тому ж парсері, що й ERTB. Дублювати логіку у двох кодових базах - рецепт повільного розходження.
- Хочеться писати парсер тією мовою, де мені простіше тримати продакшен - у моєму випадку це C#, а не Python.
- API дає одне місце для логування, метрик, A/B-тестів парсингу. Включно з shadow-логуванням LLM, яким я скористався у 2026-му.
Тіло бота лишилось на Python 3.10 і aiogram 3.27. Деплой - GitHub Actions: на push у main піднімається worker, тестує (pytest, pytest-asyncio, pytest-benchmark), SSH-ом дойдеплоює на VPS, перезапускає screen-сесію. Тривіально, кілька десятків рядків YAML, нуль Kubernetes.
Boring stack - це не консервативний вибір. Це інвестиція у те, щоб не витрачати уваги на інфраструктуру у момент, коли увага потрібна десь ще.
0x07PRO ТА ІНШІ СПРОБИ
24 серпня 2024 ми запустили ERTB Pro. Підписка через Telegram Stars, 150⭐ на місяць. Що було ексклюзивним: inline-режим, мат-операції, Word-To-Number, відсутність реклами у відповідях бота.
За перший місяць - близько п'яти підписників.
16 вересня знизили ціну до 100⭐. Зробили підписки на 3, 6, 12 місяців. Word-To-Number повернули у безкоштовну версію - стало очевидно, що люди готові міняти бота на конкурента, але не платити за те, що раніше було вільним.
За рік ERTB Pro у будь-якій формі купив десяток людей. За весь час 2 550⭐ виторгу. У Telegram Stars це покриває хіба пару місяців хостингу.
Проблема була не у грошах. Проблема була у тому, що ми ексклюзивізували функції, по яких потім не отримували фідбеку. Inline-режим (топ-фіча по запитах у 2021-2023) перестав давати сигнал, що з ним не так. Багатий тест на стабільність парсера у складних виразах - теж замкнувся за paywall, де його дуже мало користувачів.
У 5.0 я Pro закрив. Усі, хто хоч раз купив підписку, перейшли у наш список донорів - це список у /about. Усі функції доступні всім. Я зрозумів: тут зараз немає платної гілки і не буде.
Паралельно з Pro ми пробували ще дві монетизаційні гілки.
ERDB - Exchange Rates Discord Bot. Запустили відкритий бета-тест у березні 2024. Сарафанне радіо, що працювало у Telegram-групах, у Discord не зайшло. Discord - це або голосовий чат, або тематичний канал, де «бот, який реагує на повідомлення про курси», не має тої ж природньої аудиторії, як Telegram-група діаспори.
Реклама в боті. В адмінському інтерфейсі живе команда /money - три рекламні кампанії: посилання у footer відповіді, кнопка під відповіддю, окреме повідомлення про донати. Працюють опціонально, з whitelist для тих, хто не хоче бачити. Кампанії продавались декілька разів етичним рекламодавцям. Пропозицій було багато - більшість від онлайн-казино, гемблінгу, скам-проєктів. На всі такі відповідь одна: ні, дякую. Ми сидимо у точці, де реклама в боті могла б його окупити, але це була б реклама не та, з якою хочеться засинати спокійно.
0x08ВЕЛИКА ПОМИЛКА
Цей розділ найкоротший. І найважливіший.
Дивлюсь на графік активності за останні три роки. Пік - червень 2024, 25-27 тисяч конвертацій на день. Через шість місяців - обвал. На початку 2025 ми відкатились до 5 тисяч за тиждень. Далі - плавний спад до сьогоднішніх 2.5.
Насправді все дуже просто - спонтанні падіння бота на день-два, протерміновані оновлення курсів, скарги у support-боті, на які ми відповідали раз на три місяці. Я це бачив. Команда це бачила. Ми це знали. І в той же час ми додавали нові фічі. Запускали підписку. Малювали логотип.
Користувачі не зрадили. Вони отримали від нас сигнал, що ми ненадійні. Бот не оновлює курси добу - людина перестає його використовувати. Раз. Двічі. На третій раз вона видаляє його з групи. І бот вже не повертається - навіть якщо ми його через місяць полагодили.
Усі красиві аргументи про конкуренцію, про мобільність груп у Telegram, про Telegram-зміни - це шум. Головна причина - коли треба було гасити, ми будували.
Якщо у вас є проєкт, який досяг піка - це не сигнал додавати фічі. Це сигнал стабілізувати, моніторити, відповідати на support за 24 години, а не за 24 дні. Це нудно. Це неможливо опублікувати у канал як «ми додали…». Це і є те, що тримає продукт.
0x095.0 · ЗМИРИЛИСЬ
14 травня 2026 я випустив ERTB 5.0.
Список новацій вийшов довгий, але якщо чесно дивитись на дугу: це реліз, який підбиває риску. Pro прибрали. Інтерфейс пофарбували. Додали /card з рідкісною колекційною механікою - більше як ностальгічний жанр, ніж функціональна фіча. Додали /privacy як явну заяву «ми не комерційний продукт, ми не торгуємо вашими повідомленнями, нам нічого про вас не цікаво крім того, як швидко повернути курс». Додали 6 нових мов інтерфейсу. Перевели всю роботу з БД на async.
Все це я зробив сам. Без Claude Code у форматі pair programming це б займало місяць-два замість двох тижнів. Це і є моя нова реальність - і вона ще раз підкреслює, чому Pro мав бути закритий: все зводиться до ШІ. Чому бот вистрілив? Бо люди багато часу проводили вдома (ковід-19) і спілкувались, і замість того аби відволікатись на браузер чи окремий застосунок для конвертації валют, отримували відповідь одразу у чаті. А зараз людині легше запитати у ШІ, ніж покладатись на ненадійного бота.
Найважливіша зміна у 5.0 не у списку патчнотів. Вона у тоні. Раніше у канал ми писали «ми ще додамо…». У релізі 5.0 ми написали «усе доступне для всіх». Це не маркетинг. Це позиція.
2020 - 1.0 · 2021 - 2.0 · 2023 - 3.0 + 4.0 · 2024 - 4.4 + Premium · … · 2026 - 5.0
0x0AУРОКИ
Підбиваю короткими рядками те, що написав би молодшому собі 2020-го.
- Стабільність важить більше за фічі, коли ти на піку. Це єдиний урок, який варто запамʼятати, навіть якщо забути всі інші. Коли користувачів багато і вони активні - вкладай у те, щоб бот не падав, оновлював курси і відповідав на support. Усе нове - після.
- Парсер на вузькій задачі переграє LLM утричі. Дисципліновані правила + lookup + fuzzy-match + LRU тримають мілісекундну latency і нульову операційну поверхню. 8B-модель у shadow-логуванні цього не побила. Коли область чітко обмежена - не витрачай час на нейронки.
- Один backend-сервіс на всі боти. Парсер і курси у спільному API економлять тобі переписування при кожній новій платформі. Реалізував один раз - користуйся з усіх ботів Lanasys. Треба було робити це раніше.
- Open source - це не модель росту для маленького B2C-бота. Він дає тобі чотирьох якісних перекладачів і нуль contributors-у-core. Він також дає твоїм недоброзичливцям матеріал для форків. Маленький проєкт без чітких комʼюніті-ресурсів не отримує від відкритості того, що очікує.
- Donation > Subscription для бота-сервісу. Люди готові скинутися, бо люблять. Люди не готові підписуватись, бо це психологічно інший формат - вони очікують гарантії, документації, SLA. Якщо ти один розробник із VPS - donation-режим чесніший і ефективніший. Навіть якщо вас троє.
- Політична позиція коштує дорого. Тримай її лише якщо готовий платити. Прапор у профілі, бан скамерських чатів, відмова від російського ринку - це коштувало нам користувачів. І я б знову зробив усе те саме. Бо дещо не продається.
0x0BЩО Я БУДУВАВ БИ СЬОГОДНІ
Майже те саме, чесно. aiogram 3, замість MySQL був би Postgres, .NET API окремо. Це boring stack, який працює. Я не вірю, що його заміна на щось модне допомогла б ERTB.
Що зробив би інакше:
- Alerting на курси старші за день - з першого дня. Це найбільший важіль, якого ми не мали п'ять років.
- Парсер як окремий repo з нуля. Не у тілі бота. Не «ми потім винесемо коли треба». Зразу окремо, бо це ядро продукту, а тіло бота - просто адаптер до Telegram.
- Не Pro. Direct donation з самого початку, без підписки. Якщо хочеш - заплати, отримай імʼя в /donors. Це все.
Решта - як і було. Один розробник, один VPS, один прапор у профілі. На шостий рік це здається нормальним мірилом success.