ideipro logotyp

Как научить Telegram-бота на Python не терять задачи пользователей при перезапуске. Кейс ассистента для hh.ru

Привет, Хабр!

Мы тут в свободное время пилим проект, который должен решить боль многих айтишников, — автоматизировать рутинный поиск работы. Идея выросла в Telegram-бота «Аврора» , который на «автопилоте» ищет вакансии на hh.ru и откликается на них.

Но чтобы «автопилот» был полезным, он должен быть надежным. Никому не нужен ассистент, который при первом же деплое новой версии или падении сервера забывает, что он делал, и какие вакансии уже отправил.

Сегодня я хочу рассказать не столько о самом боте, сколько о конкретной инженерной задаче, с которой мы столкнулись: как обеспечить персистентность и «бесшовное» возобновление длительных пользовательских задач при перезапуске сервиса.

Под катом — наш подход к Graceful Shutdown, восстановлению сессий и немного про то, как LLM (в нашем случае Gemini) генерирует поисковые запросы.

Проблема: «Автопилот» — это stateful-процесс

Сначала кратко о том, как работает основная логика, чтобы был понятен масштаб проблемы.

  1. Пользователь логинится через OAuth hh.ru и выбирает свое резюме.

  2. Дальше начинается магия. Мы не просим пользователя вводить python AND (django OR flask) NOT (bitrix). Вместо этого мы берем текст его резюме, скармливаем его LLM (Gemini) и просим: «На основе этого резюме, сгенерируй оптимальный поисковый custom_query для API hh.ru».

  3. Этот запрос (например, (Python OR Go) AND (backend OR developer) AND (PostgreSQL OR ClickHouse)) сохраняется в нашей PostgreSQL.

  4. Пользователь нажимает «Запустить автопилот».

И вот тут начинается самое интересное. «Автопилот» — это не разовая функция. Это длительный асинхронный процесс, который должен:

  • Периодически (раз в N минут) ходить в API hh.ru с тем самым custom_query.

  • Для каждой найденной вакансии снова обращаться к LLM, передавая ей резюме пользователя и текст вакансии, с задачей «Напиши персонализированное сопроводительное письмо».

  • Автоматически откликаться на вакансию с этим письмом.

  • Вести учет откликов, чтобы не спамить.

Этот процесс может работать часами и днями. А теперь представим, что нам нужно выкатить новую версию бота. Мы гасим systemd юнит, Docker-контейнер останавливается… и все активные поиски пользователей прерываются. При следующем запуске бот не помнит, кто что искал и на чем остановился. Это фиаско.

Решение: State-машина в PostgreSQL и Graceful Shutdown

Проблема очевидна: состояние «автопилота» должно жить не в оперативной памяти бота, а во внешней базе данных. Мы используем PostgreSQL.

1. Модель данных (упрощенно)

Нам потребовалось несколько таблиц, но ключевая — это user_sessions (или autopilot_state).

SQL

— Упрощенная схема для понимания CREATE TABLE user_sessions ( user_id BIGINT PRIMARY KEY, hh_resume_id VARCHAR(255), custom_query TEXT, — Тот самый, что сгенерил LLM session_status VARCHAR(50) DEFAULT ‘stopped’, — ‘running’, ‘paused’, ‘stopped’ last_run_time TIMESTAMP, last_processed_vacancy_id VARCHAR(100), — Для пагинации и исключения дублей — … другие поля, токены и т.д. );

Когда пользователь запускает «автопилот», мы не просто запускаем asyncio таску. Мы меняем session_status в БД на ‘running’.

2. Механизм «Graceful Shutdown» (Изящной остановки)

Мы используем aiogram 3.x. Нам нужно отловить сигнал об остановке процесса (например, SIGTERM, который посылает docker stop или systemd).

В главном файле запуска бота (__main__.py или app.py) мы вешаем обработчики на эти сигналы.

Python

import asyncio import signal from aiogram import Bot, Dispatcher # … импорты наших сервисов (db_service, autopilot_service) async def on_shutdown(dp: Dispatcher, bot: Bot): «»» Вызывается при получении сигнала SIGTERM или SIGINT. «»» logging.warning(«Получен сигнал остановки. Переводим активные сессии в ‘paused’…») # Здесь мы идем в нашу БД и все сессии со статусом ‘running’ # атомарно переводим в статус ‘paused’. await db_service.pause_all_active_autopilots() logging.warning(«Все активные задачи приостановлены. Завершение работы…») # Даем aiogram штатно завершить обработку текущих апдейтов await dp.storage.close() await bot.session.close() async def main(): # … инициализация бота, диспатчера, роутеров … # Добавляем обработчики сигналов loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, lambda: asyncio.create_task(on_shutdown(dp, bot))) # Запускаем автопилот для тех, кто был ‘paused’ await autopilot_service.resume_paused_sessions() # Запускаем поллинг await dp.start_polling(bot) if __name__ == «__main__»: asyncio.run(main())

Что здесь происходит:

  1. При получении SIGTERM, asyncio создает задачу on_shutdown.

  2. on_shutdown не останавливает поиск, а просто идет в PostgreSQL и всем, у кого session_status = ‘running’, ставит session_status = ‘paused’.

  3. После этого бот штатно завершает работу.

Сам воркер «автопилота» в своем цикле while True должен после каждой итерации (например, после обработки одной вакансии) проверять свой статус в БД. Если он стал ‘paused’ или ‘stopped’, воркер должен корректно завершить свою работу (return или break).

3. Механизм «Auto-Resume» (Авто-возобновления)

Теперь самое главное. Когда бот стартует, нам нужно поднять все «уснувшие» задачи. Для этого в main() перед запуском поллинга мы вызываем специальную функцию resume_paused_sessions().

Python

# Внутри нашего autopilot_service async def resume_paused_sessions(self): «»» Вызывается один раз при старте бота. Ищет всех пользователей со статусом ‘paused’ и запускает для них воркеры. «»» paused_users = await self.db.get_users_by_status(‘paused’) if not paused_users: logging.info(«Приостановленных сессий не найдено.») return logging.info(f»Найдено {len(paused_users)} приостановленных сессий. Возобновление…») tasks = [] for user_session in paused_users: # Важно: Сначала меняем статус в БД на ‘running’ await self.db.update_session_status(user_session.user_id, ‘running’) # И только потом создаем асинхронную задачу на запуск tasks.append( asyncio.create_task( self.run_autopilot_for_user(user_session.user_id) ) ) await asyncio.gather(*tasks) logging.info(«Все приостановленные сессии успешно возобновлены.»)

Итог этого блока

Мы получили «пуленепробиваемую» систему:

  • Деплой: Мы спокойно перезапускаем бота. SIGTERM -> задачи в БД переводятся в ‘paused’ -> бот гаснет.

  • Старт: Бот запускается -> читает БД -> видит все ‘paused’ задачи -> меняет их на ‘running’ и запускает воркеры.

  • Крэш: Если бот упал без Graceful Shutdown (OOM Killer, kill -9), задачи остаются в БД со статусом ‘running’. Нам нужен отдельный watchdog-механизм, который при старте проверяет задачи ‘running’ и, если они «подвисли», тоже их перезапускает (например, по last_run_time).

Что еще под капотом (и над чем работаем)

Описанный механизм — это лишь один из кубиков. Вокруг него построено много всего:

  • Keep-Alive: Отдельный механизм, который восстанавливает сессию пользователя (токены hh.ru), если его почему-то нет в нашей БД, но он пишет боту (например, если мы чистили кэши).

  • Динамические ReplyKeyboardMarkup: Чтобы не просить пользователей писать /start после каждого обновления, мы сделали механизм, который при изменении «версии» кнопок в коде автоматически обновляет клавиатуру всем пользователям при их следующем сообщении.

  • Роли Admin/User: Админы видят расширенный интерфейс прямо в Telegram, могут смотреть статистику, статусы сессий и т.д.

Текущая задача: Мы продолжаем отладку механизма Graceful Shutdown и авто-возобновления, так как при больших нагрузках всплывают нюансы с гонкой состояний (race conditions) в asyncio. Параллельно добавляем новые админские функции для управления пользователями.

Заключение

Создание stateful-бота, который выполняет длительные фоновые задачи, — это всегда вызов. Нельзя просто положить все в RAM и надеяться на лучшее. Использование PostgreSQL как «единого источника правды» о состоянии сессий и реализация механизмов GraceEOF

  • Название: «Аврора»

  • Логин в Telegram: @AuroraCareer

  • Стек: Python, aiogram, PostgreSQL, Gemini API, hh.ru API.

Буду рад услышать в комментариях, с какими проблемами вы сталкивались при создании подобных «долгоживущих» ботов. Как вы решаете задачи персистентности? Может, мы где-то изобрели велосипед?

Спасибо за внимание!

Источник: habr.com

✅ Найденные теги: Как, новости

ОСТАВЬТЕ СВОЙ КОММЕНТАРИЙ

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Каталог бесплатных опенсорс-решений, которые можно развернуть локально и забыть о подписках

галерея

Фото сгенерированных лиц: исследование показывает, что люди не могут отличить настоящие лица от сгенерированных
Нейросети построили капитализм за трое суток: 100 агентов Claude заперли…
Скетч: цифровой осьминог и виртуальный мир внутри компьютера с человечком.
Сцена с жестами пальцами, где один жест символизирует "VPN", а другой "KHP".
‼️Paramount купила Warner Bros. Discovery — сумма сделки составила безумные…
Скриншот репозитория GitHub "Claude Scientific Skills" AI для научных исследований.
Структура эффективного запроса Claude с элементами задачи, контекста и референса.
Эскиз и готовая веб-страница платформы для AI-дизайна в современном темном режиме.
ideipro logotyp
Image Not Found
Звёздное небо с галактиками и туманностями, космос, Вселенная, астрофотография.

Система оповещения обсерватории Рубина отправила 800 000 сигналов в первую ночь наблюдений.

Астрономы будут получать оповещения о небесных явлениях в течение нескольких минут после их обнаружения. Теренс О'Брайен, редактор раздела «Выходные». Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной…

Мар 2, 2026
Женщина с длинными тёмными волосами в синем свете, нейтральный фон.

Расследование в отношении 61-фунтовой машины, которая «пожирает» пластик и выплевывает кирпичи.

Обзор компактного пресса для мягкого пластика Clear Drop — и что будет дальше. Шон Холлистер, старший редактор Публикации этого автора будут добавляться в вашу ежедневную рассылку по электронной почте и в ленту новостей на главной странице вашего…

Мар 2, 2026
Черный углеродное волокно с текстурой плетения, отражающий свет.

Материал будущего: как работает «бессмертный» композит

Учёные из Университета штата Северная Каролина представили композит нового поколения, способный самостоятельно восстанавливаться после серьёзных повреждений.  Речь идёт о модифицированном армированном волокном полимере (FRP), который не просто сохраняет прочность при малом весе, но и способен «залечивать» внутренние…

Мар 2, 2026
Круглый экран с изображением замка и горы, рядом электронная плата.

Круглый дисплей Waveshare для креативных проектов

Круглый 7-дюймовый сенсорный дисплей от Waveshare создан для разработчиков и дизайнеров, которым нужен нестандартный экран.  Это IPS-панель с разрешением 1 080×1 080 пикселей, поддержкой 10-точечного ёмкостного сенсора, оптической склейкой и защитным закалённым стеклом, выполненная в круглом форм-факторе.…

Мар 2, 2026

Впишите свой почтовый адрес и мы будем присылать вам на почту самые свежие новости в числе самых первых