Реклама
Меморина
Меморина
Меморина

Как я написал AI-бота на aiogram 3 с помощью нейросетей, выжил при 2500+ пользователей и почему SQLite "всё ещё торт"

Постмортем разработки Telegram-бота Щёлк-ГДЗ. Как я боролся с ошибками, оптимизировал SQLite и делал стриминг от ИИ на дешевом VPS. Честный технический опыт соло-разработчика.

Обложка: Как я написал AI-бота на aiogram 3 с помощью нейросетей, выжил при 2500+ пользователей и почему SQLite "всё ещё торт"

На дворе 2026 год. Очередной постмортем микро-SaaS'а в Телеге.

Без маркетинга и успешного успеха. Только боль, костыли и суровый прод! Мой пет-проект - бот «Щёлк-ГДЗ». Это ИИ-помощник, который решает школьные задачки по фоткам, видео, гс, тг-кружкам и PDF. Под капотом Python 3.12.3, aiogram 3, aiosqlite, апи OpenRouter (модель Gemini 3.0 Flash) и Робокасса. Крутится всё это на дешёвом VPS в Нидерландах. Держит 2500+ пользователей и не падает.

В этой статье расскажу, как за 9 месяцев построил логичную архитектуру, победил ошибку FloodWait при стриминге ответа ИИ и почему в условиях 1 гига RAM обычная SQLite - отличное решение.

Нейронка вместо джуна и Уроборос багов

Сразу признаюсь... С нуля я это не писал :) Синтаксис мне генерили LLM-ки. Начинал с Gemini 2.5 Pro, затем перешел на 3.0 Pro, а сейчас использую 3.1 Pro.

Многие думают, что нейронка сама напишет проект "под ключ", но это миф. Я никогда не доверял ИИ проектирование архитектуры и использовал его как продвинутый StackOverflow (скармливал конкретную задачу (например, написать SQL-миграцию) и получал кусок кода).

ИИ — это не архитектор, а джун на спидах.

Как только логика усложнялась - гемини ловил «Уроборос багов». Кидаешь баг А - он его фиксит, но появляется ошибка Б. Скармливаешь и её - фиксит, но возвращается баг А. Цикл замкнулся. Лечилось только созданием новых чатов, в которые я кидал код и писал запросы типа "Найди критические ошибки, логические дыры и баги".

Про продуктовую логику нейронка вообще не слышала. В первой версии рефералки был баг, где любой(если его аккаунта ещё нет в БД) мог написать в конце ссылки что любые 9 цифр (?start=1234567890) и получить бонусы.

Также были ошибки с гонкой состояний при оплатах и активациях промокодов, которые тоже фиксил запросами в новые чаты с ИИ. Так я исправил около 20 архитектурных дыр.

Немного про архитектуру.

Чтобы код не превратился в нечитаемую лапшу на 5000 строк, я жестко разбил всё на модули. Архитектура бота выглядит так:

Как я написал AI-бота на aiogram 3 с помощью нейросетей, выжил при 2500+ пользователей и почему SQLite "всё ещё торт"_12

main.py - это точка входа с настройкой логгеров, коннетами aiohttp и запуском APScheduler

database.py - вся работа с БД

neiro.py - логика общения с апи OpenRouter (стриминг, сжатие фоток через Pillow, JSON-контекст)

states.py - классы состояний aiogram.fsm.state (например, PaymentProcess, GdzMode)

utils.py - легковесные утилиты. Например лок пользователей (защита от спама запросами):

			processing_users = set()

def is_user_locked(user_id):
    return user_id in processing_users

def lock_user(user_id):
    processing_users.add(user_id)

def unlock_user(user_id):
    processing_users.discard(user_id)

		

Хэндлеры вынесены в отдельную папку handlers/:

handlers/common_handlers.py - главное меню с обработкой /start (рефералки, utm-метки).

handlers/gdz_handlers.py - сам процесс ИИ-решения (вход в GdzMode, прием фото, видео, кружочков).

handlers/pay_handlers.py - это логика платежей (Robokassa) и "Умная корзина".

handlers/tasks_handlers.py - квесты (выдача премиума за подписку на каналы спонсоров).

Сборку интерфейсов вынес в all_def.py. Не люблю, когда в хэндлерах генерится полотно текста с кнопками. Там же лежат функции склонения слов (1 запрос, 2 запроса, 5 запросов). А в settings.py лежат списки с рандомными ответами бота, чтоб казался живым.

			WAIT_FOR_COMPLETION = [
    "Подожди, я ещё думаю над твоим предыдущим запросом! 😅",
    "Воу-воу, не так быстро! 🏎️\nЯ еще перевариваю твою прошлую задачу. Дай мне пару секунд.",
    "Секундочку... ⏳\nЯ ещё пишу ответ на твой предыдущий запрос. Сейчас всё будет!",
    "Эй, не спамь! ✋\nЯ еще решаю твою прошлую задачу. Дай пару секунд! 😉"
]
		

Так же в settings.py я сделал кэширование картинок, тоесть при первом запуске бот грузит фото меню как BufferedInputFile, сохраняет file_id от Телеграма и дальше шлет картинки моментально по ID. Сервак говорит спасибо за сэкономленный трафик)

В handlers/common_handlers.py находится первичная маршрутизация. Вот так обрабатываются рефералки, переходы с сайта, с рекламы и другое при старте:

			@common_router.message(CommandStart())
async def start(message: Message, state: FSMContext, bot: Bot = None, **kwargs):
    try:
        await state.clear()
        user_id = message.from_user.id
        username = message.from_user.username

        referrer_id = None
        partner_code = None
        web_source = None
        from_adv = 0

        args = message.text.split()

        if len(args) > 1:
            potential_arg = args[1]
            if potential_arg == "adv":
                from_adv = 1

            elif potential_arg.startswith("fromweb"):
                web_source = potential_arg

            elif potential_arg.isdigit() and int(potential_arg) != user_id:
                ref_check = await db.get_user(int(potential_arg))
                if ref_check:
                    referrer_id = int(potential_arg)
            else:
                valid_partner = await db.check_partner_code(potential_arg)
                if valid_partner:
                    partner_code = valid_partner
		

Диета по токенам

Хранить бесконечную историю диалогов дорого и бессмысленно. В бесплатной версии храню 10 последних сообщений (5 пар вопрос-ответ), а в преме — 30.

Но фотки весят большое кол-во токенов. Если премиум-юзер закинет 30 фоток, OpenRouter выставит мне огромный счет. В итоге я прикрутил ограничение: Из 30 сообщений ИИ видит только 10 последних картинок.

Старые фотки тупо вырезаю из JSON. Подменяю на системный промпт:

			"[СИСТЕМНОЕ ПРИМЕЧАНИЕ: Часть старых фото скрыта для экономии памяти. Ты БОЛЬШЕ НЕ ВИДИШЬ их содержимое. Если пользователь просит уточнить что-то из этих фото — ТЕБЕ КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выдумывать задания. Вежливо объясни, что ты помнишь только 10 последних фото, и попроси прислать фото заново.]".
		

С довольно неплохой моделью (gemini 3.0 flash) это работает как часы (она честно признается, что забыла картинку, а не выдумывает что-то из воздуха).

Стриминг, FloodWait и защита баланса

Чтобы бот не выглядел тормозом, я сделал стриминг ответа от ИИ в neiro.py. Я обновляю сообщение в Телеграме чанками по мере получения их от ОпенРоутера.

Но если делать message.edit_text слишком часто, ловишь FloodWait. В итоге я выставил интервал в 0.7 секунд и обернул всё в жесткий try/except:

			                except TelegramRetryAfter as e:
                    sleep_time = e.retry_after + 1
                    logger.info(f"user_id={user_id} event=FLOOD_WAIT. Спим {sleep_time} сек. Генерация не прервана.")
                    await asyncio.sleep(sleep_time)

		

Стрим при этом не прерывается. Поспали и погнали дальше :) Если на этапе обработки файла или стриминга падает критическая ошибка - честно возвращаю юзеру запрос на баланс.

В handlers/gdz_handlers.py это выглядит так:

			                try:
                    logger.error(f"user_id={user_id} event=!!! ОШИБКА в process_gdz_text : {e}. ВОЗВРАЩАЮ ЗАПРОС.", exc_info=True)
                    await db.add_requests(user_id, 1)
                except Exception as db_err:
                    logger.critical(f"Не удалось вернуть запрос пользователю {user_id}: {db_err}")
		

SQLite тащит

Почему не Postgre? Потому что для микро-SaaS с 2,5к пользователей SQLite хватает за глаза. Но в асинхронной среде она любит кидать ошибку database is locked.

Чтобы этого избежать, я включил WAL-режим при инициализации пула, разделил коннекты на db_writer и db_reader, а сложные операции доверил самому SQL.

Например, 00:00 запускается крон-таска, которая собирает огромную аналитику, начисляет всем активным юзерам +1 ежедневный запрос и сбрасывает просроченные подписки. И это всё это работает атомарно внутри database.py:

			async def update_daily_bonus_cron():
    global db_writer
    logger.info("⏳ CRON: Запуск ночного обновления и сбор аналитики...")

    expired_users_ids = []
    stats = {}

    now_msk = datetime.utcnow() + timedelta(hours=3)
    target_date_msk = now_msk - timedelta(days=1)
    target_date_str = target_date_msk.strftime('%Y-%m-%d')
    stats['today'] = target_date_str

    start_of_day_utc = (target_date_msk.replace(hour=0, minute=0, second=0) - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')
    end_of_day_utc = (target_date_msk.replace(hour=23, minute=59, second=59) - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')

    async with db_lock:
        try:
            # === 1. АУДИТОРИЯ ===

            # Всего пользователей
            async with db_writer.execute("SELECT COUNT(*) FROM users") as cursor:
                stats['total_users'] = (await cursor.fetchone())[0]

            stats['new_organic'] = 0
            stats['new_site'] = 0
            stats['new_ads'] = 0
            stats['new_referrals'] = 0
            stats['new_partners'] = 0

            start_msk = f"{target_date_str} 00:00:00"
            end_msk = f"{target_date_str} 23:59:59"

            async with db_writer.execute("SELECT from_adv, web_source, referred_by, partner_source FROM users WHERE reg_date >= ? AND reg_date <= ?",(start_msk, end_msk)) as cursor:
                rows = await cursor.fetchall()
                stats['new_users'] = len(rows)
                for r in rows:
                    if r['from_adv']:
                        stats['new_ads'] += 1
                    elif r['web_source']:
                        stats['new_site'] += 1
                    elif r['partner_source']:
                        stats['new_partners'] += 1
                    elif r['referred_by']:
                        stats['new_referrals'] += 1
                    else:
                        stats['new_organic'] += 1

            # Активность (1 - фри, 2 - прем)
            stats['active_free'] = 0
            stats['active_prem'] = 0
            async with db_writer.execute("SELECT was_active_today, COUNT(*) FROM users WHERE was_active_today >= 1 GROUP BY was_active_today") as cursor:
                active_rows = await cursor.fetchall()
                for r in active_rows:
                    if r[0] == 1:
                        stats['active_free'] = r[1]
                    elif r[0] == 2:
                        stats['active_prem'] = r[1]
                stats['active_today'] = stats['active_free'] + stats['active_prem']

            async with db_writer.execute("SELECT COUNT(*) FROM users WHERE requests_left <= 0") as cursor:
                stats['limit_hitters'] = (await cursor.fetchone())[0]

            async with db_writer.execute("SELECT COUNT(*) FROM users WHERE subscription_status = 'active'") as cursor:
                stats['active_subs'] = (await cursor.fetchone())[0]

            # === 2. ДЕНЬГИ ===
            stats['success_count'] = 0
            stats['revenue'] = 0
            stats['new_clients'] = 0
            stats['regular_clients'] = 0
            stats['abandoned_carts'] = 0
            stats['lost_money'] = 0

            async with db_writer.execute("""SELECT user_id, amount, status FROM payments WHERE created_at >= ? AND created_at <= ?""", (start_of_day_utc, end_of_day_utc)) as cursor:
                payments_today = await cursor.fetchall()

            # Ищем, кто из сегодняшних "успешных" платил раньше, чтобы определить постоянников
            success_user_ids = [p['user_id'] for p in payments_today if p['status'] in ('success', 'succeeded')]
            regular_users_set = set()

            if success_user_ids:
                placeholders = ','.join('?' for _ in success_user_ids)
                query = f"""SELECT DISTINCT user_id FROM payments WHERE status IN ('success', 'succeeded') AND created_at < ? AND user_id IN ({placeholders})"""
                async with db_writer.execute(query, [start_of_day_utc] + success_user_ids) as cursor:
                    prev_users = await cursor.fetchall()
                    regular_users_set = {r['user_id'] for r in prev_users}

            # Распределяем платежи
            for p in payments_today:
                if p['status'] in ('success', 'succeeded'):
                    stats['success_count'] += 1
                    stats['revenue'] += p['amount']
                    if p['user_id'] in regular_users_set:
                        stats['regular_clients'] += 1
                    else:
                        stats['new_clients'] += 1
                        # Если этот же юзер купит сегодня еще раз, второй платеж пойдет как от постоянника
                        regular_users_set.add(p['user_id'])
                elif p['status'] in ('pending', 'canceled'):
                    stats['abandoned_carts'] += 1
                    stats['lost_money'] += p['amount']

            # === 3. ЕЖЕДНЕВНЫЙ СБРОС И ВЫДАЧА БОНУСА ===
            async with db_writer.execute("UPDATE users SET requests_left = requests_left + 1 WHERE was_active_today = 1 OR requests_left <= 0"): pass
            async with db_writer.execute("UPDATE users SET was_active_today = 0"): pass

            # === 4. ПРОВЕРКА ИСТЕКШИХ ПОДПИСОК ===
            check_time = (datetime.utcnow() + timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')

            async with db_writer.execute("""
                    SELECT user_id FROM users 
                    WHERE subscription_status = 'active' AND subscription_expiry_date < ?
                """, (check_time,)) as cursor:
                rows = await cursor.fetchall()
                expired_users_ids = [row['user_id'] for row in rows]

            async with db_writer.execute("""
                    UPDATE users SET subscription_status = 'expired' 
                    WHERE subscription_status = 'active' AND subscription_expiry_date < ?
                """, (check_time,)) as cursor:
                expired_count = cursor.rowcount

            await db_writer.commit()
            logger.info(f"✅ CRON: Обновление завершено. Истекло подписок: {expired_count}")
            return expired_users_ids, stats

        except Exception as e:
            await db_writer.rollback()
            logger.error(f"Ошибка при ночном обновлении CRON: {e}")
            raise e
		

Умная корзина на APScheduler

Когда дело дошло до монетизации, всплыли две проблемы:

Первая: юзер оплатил, но забыл нажать кнопку «✅ Я оплатил» в боте. Бот ждет, юзер ждет, товар не выдается, поддержка кипит.

Вторая: юзер сформировал счёт и передумал ("брошенная корзина").

Вместе с LLM я с нуля изучил apscheduler и убил двух зайцев фоновыми задачами. Теперь в handlers/pay_handlers.py при генерации ссылки на оплату я создаю две отложенные таски:

			    run_time_5m = datetime.now(ZoneInfo("Europe/Moscow")) + timedelta(minutes=5)
    scheduler.add_job(
        auto_check_payment,
        trigger='date',
        run_date=run_time_5m,
        jobstore='memory',
        args=[bot, str(inv_id), user_id, username, False, None, None]
    )

    run_time_30m = datetime.now(ZoneInfo("Europe/Moscow")) + timedelta(minutes=30)
    scheduler.add_job(
        auto_check_payment,
        trigger='date',
        run_date=run_time_30m,
        jobstore='memory',
        args=[bot, str(inv_id), user_id, username, True, payment_url, desc],
        misfire_grace_time=3600
    )
		

Как это работает?

Через 5 минут срабатывает async def auto_check_payment(), которая тихо стучится в робокассу. Если статус success - бот сам начисляет запросы на баланс и радует клиента. Если статус pending - таска умирает, и в дело вступает 30-минутная таска.

Но она не шлёт спам вслепую, а лезит в БД и проверяет 3 бизнес-правила:

1. await db.has_successful_payment_recently - Не купил ли он другой товар за последние 3 часа?

2. await db.get_latest_invoice_id - А это точно самый последний сгенерированный им счет?

3. await db.can_send_agitation - Не присылали ли мы ему агитацию недавно?

Если проверки пройдены, то юзер получает сообщение: "⏳ Домашка сама себя не решит! Ты начал оформлять покупку, но оплата так и не прошла...". Это поднимает конверсию оплат.

Финал

Почему я не использую Redis для стейтов? Ответ банален: мой дешевый VPS имеет всего 1 гб оперативки. Пул aiohttp, In-Memory стейты и асинхронные таски и так жрут 70% RAM. Редис тупо не влезет.

Как говорится, работает — не трогай.

Сейчас проект обзавелся сайтом-витриной и продолжает развиваться. В планах - искать и чинить новые баги. Перееду на более мощное железо, когда сервак начнет физически задыхаться.

Готов ответить на вопросы по архитектуре и послушать советы в комментариях \(^^)/

P.s. вот ссылка на первую статью о моём проекте:

P.s. Если кто-то хочет потестить вживую(не реклама), то юз бота в тг @gdzshchelk_bot

Рекомендуем