
Привет, Хабр!
В январе (26) я начал пет-проект. Выбрал ТГ бот. Удобно, что есть норм приложение, на чьей платформе можно делать, с нормальными либами. А главное, что можно в одного сделать довольно много и сразу в прод, без сложностей со сторами и прочими площадками. Да, я вижу. что происходит с тг и отдаю себе отчет.
Сразу скажу, пишу с ии. Хотя вайбкодеров сейчас куча, каждый второй, и не все блещут профессионализмом. Как и я. В разработке я понимаю только на уровне продакта, т.е. знаю примерно многое (но не только лишь все), кроме самого кода.
Вот и решил сделать все сам.
Поэтому и пишу тут, чтобы настоящие профессионалы взглянули и помогли, подсказали, конструктивно покритиковали. Мне важно, что люди пользуются и я просто хочу развивать продукт и получать обратную связь.
Текста много, заранее спасибо, что дочитали.
А кто в технину лезть не хочет — вот сам бот
А в канале описываю «кухню» и просто делюсь идеями
Я уже писал тут статьи "на заре" самого бота (перечитываю и диву даюсь)
Ссылка на первую статью о том, как всё начиналось.
Ссылка на вторую статью о переходе (и подходе) к продукту.
В итоге прошло два месяца. Кодовая база перевалила за 5к строк, пользователей (это те, кто в принципе хоть раз зашли) под 120, функционал расширился: появились челленджи, квесты, тотемные животные, реферальная программа, стили общения и прочие плюшки. Но главное я научился не ломать прод (почти). Тут я хочу подробно разобрать архитектурные решения, которые позволили проекту расти, и показать реальные куски кода, отвечающие за ключевую логику. Будет как и само изложение фактически проделанной работы, так и спорные ситуации, а также открытые вопросы, без которых масштабирование сложно будет.
О чём это вообще? (Философия дисциплины)
Дисциплина для меня - это не про «вставать в 5 утра и обливаться холодной водой». Это про умение делать маленькие шаги к тому, что действительно важно, даже когда не хочется.
И суть здесь именно в регулярности, а не в ситуативных подвигах.
Мой бот не мотивирует, не кричит и не заставляет. Он просто напоминает: «Эй, сегодня у тебя был фокус. Как успехи?» И даёт честную обратную связь: серию, статистику и награды.
Для пользователей это способ не бросать начатое на третий день. Когда ты видишь, что серия уже 14 дней, рука не поднимается нажать «не сделано». А если сорвался - бот не добивает, а говорит: «бывает, завтра новый день». Это и есть та самая «мягкая сила», которая работает лучше любого наказания или угрозы, хоть и самому себе.
И я постарался сделать так, чтобы бот не раздражал. Хочешь тишины - два касания в день максимум, хочешь развлечения, пожалуйста - включай фичи и развлекайся. Все просто.
Лирика закончилась, переходим к делу.
1. Структура проекта (как есть)
Вот реальная структура проекта на данный момент:
discipline_bot/
├── bot.py # все хендлеры, FSM, планировщик, клавиатуры
├── db.py # слой работы с базой данных (SQLite)
├── config.py # константы, токены, пути
├── models.sql # схема БД
├── messages.json # тексты для разных стилей общения
├── facts.txt # факты о дисциплине
├── quests.txt # ежедневные квесты
├── templates.txt # шаблоны целей
├── tests/ # папка с pytest-тестами
└── requirements.txt # зависимости
Хотел бы я сказать, что это прям осознанный выбор структуры, но нет. Все пошло как пошло и получилось так. Но такая структура мне позволяет быстро находить нужный код, не прыгая по 100500 файлам. Когда проект вырастет до 50к строк что-то нужно будет придумывать.
2. Работа с таймзонами и планировщик
Одна из самых нетривиальных задач - отправлять уведомления в локальное время каждого пользователя. Ох и долго я с этим возился, наивно полагая, что можно как-то автоматически определить таймзону юзера. В итоге использую APScheduler, который каждую минуту запускает функции send_morning_focus и send_daily_checkins.
2.1. Получение пользователей для утренней рассылки
Функция get_all_users_with_morning_time (из db.py) возвращает всех, у кого задано время утра. В send_morning_focus мы проходим по ним, конвертируем UTC в локальное время и сравниваем текущее время с заданным.
Ключевой момент - сравнение времени как чисел, а не строк, допер до этого много позже. Для этого используется функция time_to_minutes:
def time_to_minutes(t: str) -> int: h, m = map(int, t.split(':')) return h * 60 + m
Теперь сравнение current_minutes < morning_minutes работает корректно для всех часовых поясов, включая случаи, когда локальное время перевалило за полночь.
2.2. Защита от повторной отправки
В каждой итерации проверяется if user["last_morning_sent"] == today_local_str: continue. После успешной отправки (или если чек-ин уже есть) мы добавляем пользователя в список to_mark, а в конце фу��кции обновляем last_morning_sent для всех из этого списка.
Важно: обновление происходит после всех отправок, но перед выходом из функции. Это гарантирует, что даже если во время отправки произойдёт ошибка, пользователь не будет помечен как получивший уведомление.
2.3. Обработка ошибки 403 (пользователь заблокировал бота)
Раньше при блокировке бота мы получали ошибку при каждой попытке отправить сообщение и засоряли логи. Теперь в send_morning_focus и send_daily_facts добавлена обработка исключения:
except Exception as e: error_str = str(e) if "Forbidden: bot was blocked by the user" in error_str or "403" in error_str: # Отключаем уведомления async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE users SET morning_time = NULL WHERE tg_id = ?", (tg_id,) ) await db.commit() logging.warning(f"Пользователь {tg_id} заблокировал бота. Уведомления отключены.") continue else: logging.error(f"Ошибка отправки: {e}")
Для фактов аналогично отключаем facts_enabled. Это не только чистит логи, но и снижает нагрузку на БД и API Telegram.
3. Реферальная система и feature flags - свежие фичи.
Рефералка центральная механика для открытия дополнительных функций. Выбор такого хода - осознанный. Кратко, если интересно, об этом писал в тг канале про бота.
Как она реализована:
3.1. Модель данных
В users добавлены поля:
referral_code- уникальный код для приглашения.referrer_id- кто пригласил.referrals_completed- сколько друзей выполнили условия.feature_flags- JSON-поле со всеми открытыми фичами.
Таблица referrals хранит связи и прогресс каждого реферала.
3.2. Процесс приглашения
При старте бота по ссылке ?start=ref_XXXXXX мы передаём код в create_user:
await create_user( message.from_user.id, name=None, username=message.from_user.username, referrer_code=referrer_code )
Внутри create_user в db мы находим пригласившего по referral_code и создаём запись в referrals.
3.3. Фоновая проверка прогресса
Каждые 30 минут джоба check_referrals_job вызывает update_referrals_progress. Эта функция обходит всех рефералов, считает количество уникальных дней с чекинами (исключая дни отдыха) и обновляет checkins_count. Когда счётчик достигает 3, увеличивается referrals_completed у пригласившего и, если становится 3, выдаются фичи.
(о я понимаю, что на больших объемах это нагрузит сильно, но пока так)
Баг с ключом message_style: Изначально я использовал один ключ message_style и для флага доступа, и для хранения текущего стиля. В update_referrals_progress при достижении 3 я писал: flags["message_style"] = True. А в команде смены стиля: await set_feature_flag(tg_id, "message_style", "neutral")
Второй вызов затирал True, и доступ к стилям пропадал. Теперь разделено на styles_unlocked (булевый) и current_style (строка). Вот исправленный фрагмент из update_referrals_progress:
flags["challenges"] = True flags["styles_unlocked"] = True flags["current_style"] = "neutral" flags["templates"] = True
А в командах стилей проверяем styles_unlocked и устанавливаем current_style.
3.4. Хранение фича флагов
Был выбран вариант JSON-поля в таблице users. Это гибко, не требует миграций при добавлении новой фичи. Однако есть минус: нельзя сделать быстрый SQL-запрос типа «найти всех, у кого открыты челленджи». Для текущего масштаба это приемлемо, но при росте придётся либо дублировать ключи в отдельные колонки, либо выносить в отдельную таблицу, в целом пока хз, посмотрим.
4. Челленджи
Челленджи - это добровольное усложнение с наградами, если скучно и хочется разнообразия. Реализованы через отдельную таблицу challenges и FSM в bot.py.
4.1. Модель данных (models.sql)
CREATE TABLE challenges ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, focus_id INTEGER NOT NULL, level TEXT NOT NULL, start_date TEXT NOT NULL, current_day INTEGER DEFAULT 0, partial_used INTEGER DEFAULT 0, status TEXT DEFAULT 'active', future_message TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (focus_id) REFERENCES focuses(id) );
Ограничение UNIQUE на user_id гарантирует, что у пользователя может быть только один активный вызов.
4.2. Логика обработки чек-ина
После каждого чекина вызывается process_challenge_checkin. Эта функция получает активный вызов, уровень, и в зависимости от статуса обновляет прогресс или завершает вызов с наградой. Вот ключевой фрагмент (из bot.py):
async def process_challenge_checkin(tg_id: int, status: str) -> tuple[bool, str | None]: challenge = await get_active_challenge(tg_id) if not challenge: return False, None level_info = CHALLENGE_LEVELS[challenge['level']] if status == "done": new_day = challenge['current_day'] + 1 if new_day >= level_info['days']: # завершение await complete_challenge(tg_id, 'completed') focus = await get_active_focus_for_user(tg_id) if focus: await grant_challenge_rewards(tg_id, challenge['level'], focus['id'], future_message=challenge.get('future_message')) return True, f"🎉 Поздравляю! Выполнен вызов {level_info['name']}!" else: await update_challenge_progress(tg_id, new_day, challenge['partial_used']) return False, None # ... аналогично для partial и fail
А еще есть future_message - для уровня hard пользователь оставляет сообщение, которое приходит после успешного завершения.
4.3. Награды (ну или ачивки, кому как)
Награды выдаются в grant_challenge_rewards:
Ачивка (в таблицу
achievementsдобавляется запись сlevel=0иdaysравным длительности вызова).Бонусные дни отдыха (
rest_bonus_days).Для medium - открывается стиль «Легенда» (флаг
style_legend).Для hard - отправляется сохранённое сообщение.
5. Тотемные животные (типа дата драйвен классификация)
Рейтинг (/ranking) показывает пользователю его тотемное животное на основе статистики. Алгоритм находится в calculate_totem_animal (из bot.py):
def calculate_totem_animal(stats: dict) -> str: best_streak = stats.get("best_streak", 0) total_green = stats.get("total_green", 0) if best_streak >= 30: if total_green > 100: return "орёл" return "лев" if best_streak >= 14: if total_green > 50: return "лев" return "волк" if best_streak >= 7: if total_green > 20: return "волк" return "бизон" if total_green > 10: return "бизон" if total_green > 5: return "леопард" return "черепаха"
Метрики:
best_streak - лучшая серия за всё время (по любой цели).
total_green - общее количество дней со статусом
done.Позже добавится стабильность и восстановление (уже есть функции
calculate_stability_percentиcalculate_recovery_score, но пока не используются в тотеме).
Для каждого животного есть описание (get_totem_description). Прогресс-бары строятся на основе реальных данных пользователя относительно максимальных значений (30 дней для серии, 100 дней для объёма).
6. Тестирование: 43 зелёных и почти спокойный сон
Когда кода стало дофига строк, я перестал понимать хотя бы примерно, что сломается после очередного коммита. Ручное тестирование занимало часы, а пользователи иногда жаловались на баги, которые я не замечал. А самый прикол .что локально то не протестируешь. Токен на бот один, и телега ругается, когда запускаешь две версии бота и все падает сразу.
В итоге заставил шайтан машину написать автотесты, чтобы и прогонять локально и автоматом проходили на гитхабе.
6.1. Инфраструктура тестов
async def test_db(tmp_path): db_file = tmp_path / "test.db" import db, bot original_db_path = db.DB_PATH original_bot_path = bot.DB_PATH db.DB_PATH = str(db_file) bot.DB_PATH = str(db_file) await db.init_db() await db.create_user(123456789, "Тестовый Пользователь") yield str(db_file) db.DB_PATH = original_db_path bot.DB_PATH = original_bot_path
6.2. Тестирование моделей и базовых операций
Файл test_db.py проверяет создание пользователя и фокуса. Простой, но важный тест:
@pytest.mark.asyncio async def test_create_user(test_db): tg_id = 123456 name = "Тестовый Юзер" await create_user(tg_id, name) user = await get_user_by_tg_id(tg_id) assert user is not None assert user["name"] == name assert user["tg_id"] == tg_id
6.3. Тестирование реферальной системы
async def test_referral_progress_update(test_db): # ... создание пользователей и чекины ... newly = await update_referrals_progress() # проверяем, что фичи выданы правильно assert await get_feature_flag(100600, "styles_unlocked") is True assert await get_feature_flag(100600, "current_style") == "neutral"
Это фрагмент test_referral.py. Без этого теста ошибка с затиранием ключа могла бы жить в проде гораздо дольше.
6.4. Тестирование челленджей
Челленджи - сложная логика с разными статусами и лимитами. В test_challenge.py используется monkeypatch для подмены функций БД, чтобы изолированно проверить все ветки process_challenge_checkin.
async def test_challenge_done_victory(monkeypatch): challenge_data = {'level': 'easy', 'current_day': 13, 'partial_used': 0} async def mock_get_active(*args): return challenge_data async def mock_complete(user_id, status): nonlocal completed_status; completed_status = status monkeypatch.setattr('bot.get_active_challenge', mock_get_active) monkeypatch.setattr('bot.complete_challenge', mock_complete) # ... другие подмены ... ended, msg = await process_challenge_checkin(123, "done") assert ended is True assert completed_status == 'completed'
Аналогично проверяются продолжение, частичные дни, превышение лимита, недопустимость частичных и срыв.
На этом тесте я словил жуткое раздражение от того, что фичу я делал часа 4 .а тесты сами правил все 5.
6.5. Тестирование квестов
test_quest.py и test_quest_db.py покрывают как загрузку квестов из файла, так и логику включения/выключения, отметки выполнения. Например, проверка, что повторная отметка без активного квеста не увеличивает очки:
async def test_mark_quest_completed_no_quest(test_user): points_before = (await get_quest_status(tg_id))["quest_points"] await mark_quest_completed(tg_id) points_after = (await get_quest_status(tg_id))["quest_points"] assert points_after == points_before
6.6. Тестирование рейтинга и тотемных животных (ни одно животное не пострадало)
def test_calculate_totem_animal(): assert calculate_totem_animal({"best_streak": 2, "total_green": 3}) == "черепаха" assert calculate_totem_animal({"best_streak": 3, "total_green": 7}) == "леопард" assert calculate_totem_animal({"best_streak": 10, "total_green": 25}) == "волк" # и так далее
6.7. Покрытие и что дальше
Сейчас у меня 43 теста, и они дают немного уверенность при рефакторинге. Однако покрытие ещё неполное: например, не все хендлеры покрыты, да и вообще все пока очень поверхностно, но уже процентов 20 багов не доходит до прода, в моем случае это прям много часов экономии и снижение репутационного риска.
Что планирую добавить:
E2E-тесты с эмуляцией диалогов пользователя (можно использовать
aiogramтестовые клиенты).Тесты на ошибки API Telegram (например, обработка 403).
Интеграционные тесты с реальной БД, но на изолированных данных.
7. Из последнего - внезапные баги
7.1. Дублирование вечерних сообщений (хотя тесты то зеленые были)
После рефакторинга в функции send_daily_checkins отсутствовало обновление поля last_checkin_reminder_sent. В результате для пользователей, у которых наступало время вечернего напоминания, сообщение отправлялось каждую минуту - жесть полная! Исправлено добавлением UPDATE сразу после отправки в каждом из трёх мест (день отдыха, есть чек-ин, нет чек-ина).
7.2. Сравнение времени как строк
При сравнении current_time_local_str < checkin_time_local для времени, например, 23:59 и 08:00, строковое сравнение давало False, хотя логически 23:59 позже. Переведено на сравнение в минутах с помощью time_to_minutes.
7.3. Конфликт ключей в feature flags
Использование одного ключа message_style и для флага доступа, и для хранения значения приводило к потере доступа при смене стиля. Разделено на styles_unlocked и current_style.
8. Главное - вопросы сообществу
Feature flags: JSON-поле в SQLite - приемлемо для масштаба 500–1000 пользователей? Что может всплыть при росте? Стоит ли заранее переходить на отдельную таблицу user_features или вообще упарываться в фича ветки в репозитории?
Планировщик: Сейчас каждую минуту мы опрашиваем всех пользователей с включёнными уведомлениями. При увеличении числа пользователей это создаст избыточную нагрузку на БД. Есть ли более элегантный способ - например, хранить next_run_time в БД и ставить задачи точно на это время, используя apscheduler с date-триггерами? Пока вопрос открыт.
Тестирование: Как вы покрываете асинхронный код с внешними зависимостями (Telegram API, БД)? Используете ли моки (как я в test_challenge.py) или все таки лучше интеграционные тесты с реальными запросами к тестовому боту? Какие инструменты для E2E-тестирования ботов посоветуете? Как добиться покрытия большей части и не сдохнуть?
Конечно у меня куча продуктовых вопросов про метрики и прочее, но об этом в другой статье напишу.
И еще вопрос (сорян, но он на вайбкодерском) - щас использую все стандартные варианты, перплексити, клод, дипсик, грок. Все это в бесплатном варианте пока. Была подписка перплексити, но он меня забанил навсегда (я много матерно ругался на него. может из-за этого).
Пишу все в vs code. Но конечно же тупо это копипаст все. А что предложите, чтобы как-то автоматизировать? Понятно, что это платно. Курсор? Клод код? Я в таком не работал, насколько это норм тем, кто как, я по сути в код только с иишкой и погрузился и освоил его пока на 1%.
В итоге всем большое спасибо.
Если что-то сможете посоветовать в принципе по проекту или по тех части - будет круто!
Если сможете затестить бота - ваще пушка бомба, вдруг вам понравится.
Проект чисто для души, а также для профессионального развития.
А сам бот живёт тут: @focuscompanion_bot. Заходите, пробуйте.
А тут про развитие проекта ежедневно пишу.
