diff --git a/app/bots/bot_runner.py b/app/bots/bot_runner.py new file mode 100644 index 0000000..3622adb --- /dev/null +++ b/app/bots/bot_runner.py @@ -0,0 +1,124 @@ +import logging +import asyncio +import signal +from contextlib import suppress +from typing import Optional, Any + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from telegram.ext import Application + +logger = logging.getLogger(__name__) + +class BotApplication: + """Класс для управления жизненным циклом бота и планировщика.""" + + def __init__(self): + self.app: Optional[Application] = None + self.scheduler: Optional[AsyncIOScheduler] = None + self.loop: Optional[asyncio.AbstractEventLoop] = None + self._shutdown: bool = False + + def signal_handler(self, signum: int, frame: Any) -> None: + """Обработчик сигналов для корректного завершения.""" + logger.info("Received shutdown signal") + self._shutdown = True + if self.loop and self.loop.is_running(): + self.loop.create_task(self.shutdown()) + + async def shutdown(self) -> None: + """Корректное завершение работы всех компонентов.""" + logger.info("Starting graceful shutdown") + + # Останавливаем планировщик + if self.scheduler and self.scheduler.running: + logger.debug("Shutting down scheduler") + with suppress(Exception): + self.scheduler.shutdown() + + # Останавливаем приложение + if self.app: + logger.debug("Shutting down application") + with suppress(Exception): + await self.app.stop() + with suppress(Exception): + await self.app.shutdown() + + # Ждем завершения всех задач + if self.loop: + tasks = [t for t in asyncio.all_tasks(self.loop) + if t is not asyncio.current_task(self.loop)] + if tasks: + logger.debug(f"Cancelling {len(tasks)} pending tasks") + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + async def run_bot(self, app: Application, cleanup_job) -> None: + """Запуск бота и планировщика.""" + try: + self.app = app + if not self.app: + logger.critical("Failed to initialize application") + return + + # Настройка планировщика + self.scheduler = AsyncIOScheduler() + self.scheduler.add_job(cleanup_job, 'interval', minutes=30) + + # Инициализация приложения + await self.app.initialize() + await self.app.start() + + # Запуск планировщика + self.scheduler.start() + + # Запуск бота и ожидание завершения + stop_event = asyncio.Event() + + def stop_polling(): + stop_event.set() + + # Устанавливаем обработчик для остановки + self.app.stop_signals = None # Отключаем встроенную обработку сигналов + + try: + # Запускаем поллинг + await self.app.updater.start_polling(drop_pending_updates=True) + + # Ждем сигнала остановки + await stop_event.wait() + except Exception as e: + if not self._shutdown: + logger.error(f"Polling error: {e}") + await asyncio.sleep(1) + + except Exception as e: + logger.critical(f"Critical error: {e}") + finally: + await self.shutdown() + +def run_bot(app: Application, cleanup_job) -> None: + """Функция запуска бота с правильной обработкой сигналов и циклом событий.""" + bot_app = BotApplication() + + # Настройка обработчиков сигналов + signal.signal(signal.SIGINT, bot_app.signal_handler) + signal.signal(signal.SIGTERM, bot_app.signal_handler) + + # Создание и настройка цикла событий + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + bot_app.loop = loop + + try: + loop.run_until_complete(bot_app.run_bot(app, cleanup_job)) + except KeyboardInterrupt: + pass + finally: + try: + if not loop.is_closed(): + # Очистка + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + except Exception as e: + logger.error(f"Error during loop cleanup: {e}") diff --git a/app/bots/editor_bot.py b/app/bots/editor_bot.py index 332a4f0..7b36beb 100644 --- a/app/bots/editor_bot.py +++ b/app/bots/editor_bot.py @@ -992,95 +992,87 @@ async def _dispatch_with_eta(uid: int, when: datetime) -> None: logger.error(f"Error in _dispatch_with_eta: {e}") raise -def main(): - """Инициализация и запуск бота.""" - try: - # Настройка логирования - logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO - ) - - # Инициализация планировщика - scheduler = AsyncIOScheduler() - scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30) - scheduler.start() - - app = Application.builder().token(settings.editor_bot_token).build() - - # Регистрация обработчиков - post_conv = ConversationHandler( - entry_points=[CommandHandler("newpost", newpost)], - states={ - CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")], - CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")], - CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")], - ENTER_TEXT: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), - CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"), - ], - SELECT_TEMPLATE: [ - CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"), - CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"), - CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], - PREVIEW_VARS: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) - ], - PREVIEW_CONFIRM: [ - CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), - CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), - ], - ENTER_MEDIA: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media) - ], - EDIT_KEYBOARD: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) - ], - CONFIRM_SEND: [ - CallbackQueryHandler(confirm_send, pattern=r"^send:") - ], - ENTER_SCHEDULE: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) - ], - }, - fallbacks=[CommandHandler("start", start)], - ) +def init_application(): + """Инициализация приложения и регистрация обработчиков.""" + # Настройка логирования + logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO + ) + + # Инициализация бота + app = Application.builder().token(settings.editor_bot_token).build() + + # Создание обработчика постов + post_conv = ConversationHandler( + entry_points=[CommandHandler("newpost", newpost)], + states={ + CHOOSE_CHANNEL: [CallbackQueryHandler(choose_channel, pattern=r"^channel:")], + CHOOSE_TYPE: [CallbackQueryHandler(choose_type, pattern=r"^type:")], + CHOOSE_FORMAT: [CallbackQueryHandler(choose_format, pattern=r"^fmt:")], + ENTER_TEXT: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, enter_text), + CallbackQueryHandler(choose_template_open, pattern=r"^tpl:choose$"), + ], + SELECT_TEMPLATE: [ + CallbackQueryHandler(choose_template_apply, pattern=r"^tpluse:"), + CallbackQueryHandler(choose_template_preview, pattern=r"^tplprev:"), + CallbackQueryHandler(choose_template_navigate, pattern=r"^tplpage:"), + CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), + ], + PREVIEW_VARS: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, preview_collect_vars) + ], + PREVIEW_CONFIRM: [ + CallbackQueryHandler(preview_confirm, pattern=r"^pv:(use|edit)$"), + CallbackQueryHandler(choose_template_cancel, pattern=r"^tpl:cancel$"), + ], + ENTER_MEDIA: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, enter_media) + ], + EDIT_KEYBOARD: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, edit_keyboard) + ], + CONFIRM_SEND: [ + CallbackQueryHandler(confirm_send, pattern=r"^send:") + ], + ENTER_SCHEDULE: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, enter_schedule) + ], + }, + fallbacks=[CommandHandler("start", start)], + ) - tpl_conv = ConversationHandler( - entry_points=[CommandHandler("tpl_new", tpl_new_start)], - states={ - TPL_NEW_NAME: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name) - ], - TPL_NEW_TYPE: [ - CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:") - ], - TPL_NEW_FORMAT: [ - CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:") - ], - TPL_NEW_CONTENT: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content) - ], - TPL_NEW_KB: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) - ], - }, - fallbacks=[CommandHandler("start", start)], - ) + # Создание обработчика шаблонов + tpl_conv = ConversationHandler( + entry_points=[CommandHandler("tpl_new", tpl_new_start)], + states={ + TPL_NEW_NAME: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_name) + ], + TPL_NEW_TYPE: [ + CallbackQueryHandler(tpl_new_type, pattern=r"^tpltype:") + ], + TPL_NEW_FORMAT: [ + CallbackQueryHandler(tpl_new_format, pattern=r"^tplfmt:") + ], + TPL_NEW_CONTENT: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_content) + ], + TPL_NEW_KB: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, tpl_new_kb) + ], + }, + fallbacks=[CommandHandler("start", start)], + ) - app.add_handler(CommandHandler("start", start)) - app.add_handler(post_conv) - app.add_handler(tpl_conv) - app.add_handler(CommandHandler("tpl_list", tpl_list)) - - # Запуск бота - app.run_polling(allowed_updates=Update.ALL_TYPES) - - except Exception as e: - logger.critical(f"Critical error in main: {e}") - raise + # Регистрация всех обработчиков + app.add_handler(CommandHandler("start", start)) + app.add_handler(post_conv) + app.add_handler(tpl_conv) + app.add_handler(CommandHandler("tpl_list", tpl_list)) + + return app # -------- Вспомогательные функции для шаблонов --------- @@ -1511,5 +1503,132 @@ async def choose_template_cancel(update: Update, context: CallbackContext) -> in ) return ENTER_TEXT +import asyncio +import signal +from contextlib import suppress + +class BotApplication: + """Класс для управления жизненным циклом бота и планировщика.""" + + def __init__(self): + self.app = None + self.scheduler = None + self.loop = None + self._shutdown = False + + def signal_handler(self, signum, frame): + """Обработчик сигналов для корректного завершения.""" + self._shutdown = True + if self.loop: + self.loop.stop() + + async def shutdown(self): + """Корректное завершение работы всех компонентов.""" + if self.scheduler: + with suppress(Exception): + self.scheduler.shutdown() + + if self.app: + with suppress(Exception): + await self.app.stop() + with suppress(Exception): + await self.app.shutdown() + + async def run_bot(self): + """Запуск бота и планировщика.""" + try: + # Инициализация приложения + self.app = init_application() + if not self.app: + logger.critical("Failed to initialize application") + return + + # Настройка планировщика + self.scheduler = AsyncIOScheduler() + self.scheduler.add_job(cleanup_old_sessions, 'interval', minutes=30) + + # Инициализация приложения + await self.app.initialize() + await self.app.start() + + # Запуск планировщика + self.scheduler.start() + + # Запуск бота + while not self._shutdown: + try: + await self.app.updater.start_polling() + except Exception as e: + if not self._shutdown: + logger.error(f"Polling error: {e}") + await asyncio.sleep(1) + else: + break + + except Exception as e: + logger.critical(f"Critical error: {e}") + finally: + await self.shutdown() + +async def init_bot(): + """Инициализация и настройка бота.""" + try: + application = init_application() + if not application: + logger.critical("Не удалось инициализировать приложение") + return None + return application + except Exception as e: + logger.critical(f"Ошибка при инициализации бота: {e}") + return None + +def start_bot(): + """Запуск бота с правильным управлением циклом событий.""" + loop = None + try: + # Создаем и настраиваем цикл событий + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Инициализируем бота + application = loop.run_until_complete(init_bot()) + if not application: + return + + # Запускаем бота с правильным управлением циклом событий + from app.bots.bot_runner import run_bot + run_bot(application, cleanup_old_sessions) + except Exception as e: + logger.critical(f"Критическая ошибка при запуске бота: {e}") + raise + finally: + if loop: + try: + loop.close() + except Exception as e: + logger.error(f"Ошибка при закрытии цикла событий: {e}") + try: + # Создаем и настраиваем цикл событий + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # Инициализируем бота + application = loop.run_until_complete(init_bot()) + if not application: + return + + # Запускаем бота с правильным управлением циклом событий + from app.bots.bot_runner import run_bot + run_bot(application, cleanup_old_sessions) + except Exception as e: + logger.critical(f"Критическая ошибка при запуске бота: {e}") + raise + finally: + if loop: + try: + loop.close() + except Exception as e: + logger.error(f"Ошибка при закрытии цикла событий: {e}") + if __name__ == "__main__": - main() + start_bot() diff --git a/app/models/channel.py b/app/models/channel.py index 1697b50..446941c 100644 --- a/app/models/channel.py +++ b/app/models/channel.py @@ -3,6 +3,7 @@ from datetime import datetime from sqlalchemy import ForeignKey, String, BigInteger, Boolean, UniqueConstraint, func, DateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.session import Base +from app.models.user import User # Добавляем импорт User class Channel(Base): __tablename__ = "channels"