import asyncio import logging import os import sys import threading import time from typing import TYPE_CHECKING # Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16 # discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта from utils import compat # noqa: F401 import discord # noqa: E402 from discord.ext import commands # noqa: E402 from discord.ext.commands import CommandNotFound # noqa: E402 from dotenv import load_dotenv # noqa: E402 from commands import ALL_COMMANDS # noqa: E402 from utils.morning_runner import Scheduler # noqa: E402 if TYPE_CHECKING: from utils.morning_runner import Scheduler as SchedulerType logger = logging.getLogger(__name__) # Время запуска бота (на уровне модуля, чтобы не хранить на объекте discord.py Bot) START_TIME: float = time.time() class TextHelpCommand(commands.HelpCommand): """Выводит справку по командам простым текстом вместо embed.""" def get_command_signature(self, command: commands.Command) -> str: return f"!{command.qualified_name} {command.signature}" async def send_bot_help( self, mapping: dict[discord.ext.commands.Command, list[discord.ext.commands.Command]] ) -> None: lines: list[str] = ["Доступные команды:"] for cog_or_none, cog_commands in mapping.items(): if cog_or_none is None: # Standalone-команды (без cog) — показываем если не hidden for command in cog_commands: if not command.hidden: desc = command.short_doc or "" lines.append(f" !{command.name} - {desc}") continue for command in cog_commands: if not command.hidden: desc = command.short_doc or "" lines.append(f" !{command.name} - {desc}") lines.append("\nВведите !<название команды> для использования.") await self.get_destination().send("\n".join(lines)) async def send_cog_help( self, cog: discord.ext.commands.Cog ) -> None: commands_with_desc = cog.get_commands() lines: list[str] = [f"Команды [{cog.qualified_name}]:"] for command in commands_with_desc: if not command.hidden: desc = command.short_doc or "" lines.append(f" !{command.name} - {desc}") await self.get_destination().send("\n".join(lines)) async def send_command_help(self, command: commands.Command) -> None: lines: list[str] = [f"!{command.qualified_name} {command.signature}"] if command.doc: lines.append(command.short_doc or command.help) if command.aliases: lines.append(f"\nАлиасы: {', '.join('!' + a for a in command.aliases)}") await self.get_destination().send("\n".join(lines)) async def send_group_help( self, group: commands.Group ) -> None: lines: list[str] = [f"!{group.qualified_name} {group.signature}"] if group.doc: lines.append(group.short_doc or group.help) for command in group.commands: if not command.hidden: desc = command.short_doc or "" lines.append(f" !{command.name} - {desc}") await self.get_destination().send("\n".join(lines)) async def send_error_message(self, error: str) -> None: await self.get_destination().send(error) load_dotenv() intents = discord.Intents.default() intents.message_content = True class BotRunner: """Управляет жизненным циклом бота.""" def __init__(self) -> None: self.bot = commands.Bot( command_prefix="!", intents=intents, help_command=TextHelpCommand(), ) 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) await self.scheduler.start() 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.command else "?" logger.error( "Ошибка команды %s: %s", cmd_name, error, exc_info=True, ) # Discord — только если команда не ответила сама # ctx.interaction есть только у slash-команд (AutoshardedInteractionContext) # Для текстовых команд (!prefix) атрибута нет — используем hasattr if ( 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("Планировщик остановлен") from utils import close_all_sessions close_all_sessions() logger.info("Сетевые сессии закрыты") async def _on_shutdown_async(self) -> None: """Асинхронный хук завершения (discord.py on_shutdown).""" self._on_shutdown() def run(self, token: str) -> None: """Запустить бота с graceful shutdown. Graceful shutdown обеспечивается: - discord.py on_shutdown событие для остановки планировщика - async with self.bot (context manager) для graceful disconnect - KeyboardInterrupt (Ctrl+C) на POSIX - SIGTERM обрабатывается через entrypoint-скрипт (Docker/K8s) """ logger.info("Запуск бота...") async def main() -> None: try: async with self.bot: 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 setup_logging() logger.info("=== Запуск Discord бота ===") _validate_config() runner = BotRunner() token = os.getenv("DISCORD_TOKEN") runner.run(token)