import asyncio import logging import os import signal 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) 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( f"Ошибка команды {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 # Бот не может писать в канал — игнорируем @self.bot.command(name="msg") async def msg(ctx: commands.Context, *, text: str) -> None: """Повторяет текст после !msg""" await ctx.send(text) def _signal_handler(self, signum: int, frame) -> None: """Обработать сигнал завершения для graceful shutdown. Останавливает планировщик и корректно закрывает бота, чтобы подключения к gateway завершались штатно. """ sig_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM" logger.info("Получен сигнал %s, graceful shutdown...", sig_name) self.stop_event.set() # Остановить планировщик if self.scheduler: self.scheduler.stop() # Закрыть бота асинхронно loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(self.bot.close()) except Exception: pass finally: loop.close() sys.exit(0) def run(self, token: str) -> None: """Запустить бота с graceful shutdown.""" logger.info("Запуск бота...") async def main() -> None: try: async with self.bot: 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) signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler) try: asyncio.run(main()) except KeyboardInterrupt: # asyncio.run() выбрасывает KeyboardInterrupt при Ctrl+C, # но сигнал уже обработан _signal_handler и бот закрыт 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)