import asyncio import inspect 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 console_commands import ALL_CONSOLE_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: self.bot = commands.Bot(command_prefix="!", intents=intents) 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(f"Бот вошёл как {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(f" Загружен: {cog}") # Запуск планировщика morning_time = os.getenv("MORNING_TIME", "07:00") self.scheduler = Scheduler(self.bot, morning_time) logger.info(f" Планировщик запущен (время: {morning_time})") self.bot_ready.set() @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 _print_commands(self) -> None: """Вывести список доступных консольных команд.""" available = {k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop"} print("\nДоступные команды:") for idx, (name, func) in enumerate(available.items(), 1): print(f" {idx}. {name}") print(" 0. stop") def console_input(self) -> None: """Обработка ввода команд из консоли.""" self.bot_ready.wait() self._print_commands() while not self.stop_event.is_set(): try: choice = input("\nВыберите команду (номер): ").strip() if choice == "0": print("\nОстановка бота...") self.stop_event.set() asyncio.run_coroutine_threadsafe( self.bot.close(), self.bot.loop ).result(timeout=5) break try: available = { k: v for k, v in ALL_CONSOLE_COMMANDS.items() if k != "stop" } idx = int(choice) if 0 < idx <= len(available): cmd_name = list(available.keys())[idx - 1] cmd_func = ALL_CONSOLE_COMMANDS[cmd_name] if inspect.iscoroutinefunction(cmd_func): asyncio.run_coroutine_threadsafe( cmd_func(self.stop_event, self.bot), self.bot.loop ).result() else: cmd_func(self.stop_event, self.bot) else: print(f"Неизвестная команда: {choice}") except (ValueError, IndexError): print(f"Неверный формат: {choice}") self._print_commands() except (EOFError, KeyboardInterrupt): self.stop_event.set() try: asyncio.run_coroutine_threadsafe( self.bot.close(), self.bot.loop ).result(timeout=5) except Exception as e: logger.error(f"Ошибка при остановке бота: {e}") break def run(self, token: str) -> None: """Запустить бота.""" try: self.bot.run(token) except discord.LoginFailure as e: logger.critical(f"Ошибка авторизации бота: {e}", exc_info=True) logger.error("Токен неверный или бот отключён. Код ошибки: %s", e) sys.exit(1) except discord.HTTPException as e: logger.critical( f"HTTP ошибка при подключении к Discord: {e}", exc_info=True ) logger.error( "Сбой соединения с Discord API. Проверьте доступность сервиса." ) sys.exit(1) except Exception as e: logger.critical( f"Непредвиденная ошибка при запуске бота: {e}", exc_info=True ) logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__) sys.exit(1) except KeyboardInterrupt: logger.info("Остановка бота...") self.stop_event.set() if self.scheduler: self.scheduler.stop() asyncio.run_coroutine_threadsafe( self.bot.close(), self.bot.loop ).result() sys.exit(0) def _validate_config() -> None: """Проверить конфигурацию при запуске.""" 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) if __name__ == "__main__": _validate_config() runner = BotRunner() # Консольный ввод работает только в интерактивном терминале # В Docker stdin недоступен — пропускаем консольный режим if sys.stdin.isatty(): logger.info("Введите 'stop' для остановки бота") thread = threading.Thread(target=runner.console_input, daemon=True) thread.start() token = os.getenv("DISCORD_TOKEN") runner.run(token)