import asyncio import logging import os import sys import threading from typing import TYPE_CHECKING import discord from discord.ext import commands from discord.ext.commands import CommandNotFound from dotenv import load_dotenv from commands import ALL_COMMANDS from utils.morning_runner import Scheduler if TYPE_CHECKING: from utils.morning_runner import Scheduler as SchedulerType logger = logging.getLogger(__name__) load_dotenv() intents = discord.Intents.default() intents.message_content = True class BotRunner: """Управляет жизненным циклом бота.""" def __init__(self) -> None: import time self.bot = commands.Bot(command_prefix="!", intents=intents) self.bot._start_time = time.time() self.stop_event = threading.Event() self.bot_ready = threading.Event() self.scheduler: SchedulerType | None = None self._setup_events() def _setup_events(self) -> None: """Настроить обработчики событий бота.""" @self.bot.event async def on_ready() -> None: logger.info("Бот вошёл как %s", self.bot.user) # on_ready может сработать несколько раз (reconnect) — защита от дублирования cogs if self.bot.cogs: logger.info("Cog-модули уже загружены, пропускаю") return for cog_class in ALL_COMMANDS: cog = cog_class() await self.bot.add_cog(cog) for cog in self.bot.cogs: logger.info(" Загружен: %s", cog) self.bot_ready.set() @self.bot.event async def on_guild_available(guild: discord.Guild) -> None: """Запуск планировщика после загрузки кэша сервера. on_ready срабатывает до полной загрузки guild-кэша, из-за чего get_channel() возвращает None. on_guild_available гарантирует, что данные сервера в кэше. """ # Запускаем планировщик только один раз if self.scheduler is not None: return morning_time = os.getenv("MORNING_TIME", "07:00") self.scheduler = Scheduler(self.bot, morning_time) self.bot._scheduler = self.scheduler logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name) @self.bot.event async def on_command_error(ctx: commands.Context, error: Exception) -> None: if isinstance(error, CommandNotFound): return # Терминал — детали для разработчика cmd_name = ctx.command.name if ctx and ctx.command else "?" logger.error( "Ошибка команды %s: %s", cmd_name, error, exc_info=True, ) # Discord — только если команда не ответила сама # ctx.interaction есть только у slash-команд (AutoshardedInteractionContext) # Для текстовых команд (!prefix) атрибута нет — используем hasattr if ( ctx and hasattr(ctx, "interaction") and ctx.interaction and ctx.interaction.response.is_done() ): return try: await ctx.send("Не удалось выполнить команду. Попробуйте позже.") except (discord.NotFound, discord.Forbidden): pass # Бот не может писать в канал — игнорируем def _on_shutdown(self) -> None: """Остановить планировщик при завершении работы бота.""" if self.scheduler: self.scheduler.stop() logger.info("Планировщик остановлен") async def _on_shutdown_async(self) -> None: """Асинхронный хук завершения (discord.py on_shutdown).""" self._on_shutdown() def run(self, token: str) -> None: """Запустить бота с graceful shutdown.""" logger.info("Запуск бота...") async def main() -> None: try: async with self.bot: # Регистрируем on_shutdown внутри контекста бота self.bot.add_listener(self._on_shutdown_async, "on_shutdown") await self.bot.start(token, reconnect=True) except discord.LoginFailure as e: logger.critical("Ошибка авторизации бота: %s", e, exc_info=True) logger.error("Токен неверный или бот отключён. Код ошибки: %s", e) sys.exit(1) except discord.HTTPException as e: logger.critical( "HTTP ошибка при подключении к Discord: %s", e, exc_info=True ) logger.error( "Сбой соединения с Discord API. Проверьте доступность сервиса." ) sys.exit(1) except Exception as e: logger.critical( "Непредвиденная ошибка при запуске бота: %s", e, exc_info=True ) logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__) sys.exit(1) finally: # Context manager (async with self.bot) закрывает бота автоматически self._on_shutdown() try: asyncio.run(main()) except KeyboardInterrupt: # Ctrl+C прерывает bot.start(), context manager закрывает бота logger.info("Bot shutdown complete") sys.exit(0) def _validate_config() -> None: """Проверить конфигурацию при запуске.""" logger.info("Проверка конфигурации...") token = os.getenv("DISCORD_TOKEN") if not token: logger.error("Токен Discord не найден в .env") sys.exit(1) morning_time = os.getenv("MORNING_TIME", "07:00") try: hour, minute = map(int, morning_time.split(":")) if not (0 <= hour <= 23 and 0 <= minute <= 59): raise ValueError except (ValueError, AttributeError): logger.error( "Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time ) sys.exit(1) channel_id = os.getenv("MORNING_CHANNEL_ID") if channel_id: try: int(channel_id) except ValueError: logger.error( "Неверное значение MORNING_CHANNEL_ID: %s (ожидается число)", channel_id, ) sys.exit(1) logger.info("Конфигурация проверена успешно") if __name__ == "__main__": from utils.logger import setup_logging logger.info("=== Запуск Discord бота ===") setup_logging() _validate_config() runner = BotRunner() token = os.getenv("DISCORD_TOKEN") runner.run(token)