diff --git a/Dockerfile b/Dockerfile index 1bffbd1..429ed91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,11 @@ FROM python:${PYTHON_VERSION}-slim WORKDIR /app -# Устанавливаем зависимости +# Устанавливаем зависимости и утилиту ps для healthcheck COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt && \ + apt-get update && apt-get install -y --no-install-recommends procps && \ + rm -rf /var/lib/apt/lists/* # Копируем только нужные файлы (оптимизация размера образа) COPY bot.py . @@ -17,9 +19,9 @@ COPY console_commands/ console_commands # DISCORD_TOKEN=${DISCORD_TOKEN} # MORNING_TIME=${MORNING_TIME:-07:00} -# Healthcheck: проверяем, что бот запущен +# Healthcheck: проверяем, что процесс бота жив HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD python -c "import discord; print('Bot is running')" || exit 1 + CMD ps aux | grep -v grep | grep -q "python bot.py" || exit 1 # Запускаем бота CMD ["python", "bot.py"] diff --git a/ISSUES.md b/ISSUES.md index 949454a..471a71d 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -23,11 +23,13 @@ - **Закрыто:** коммит `2690ee7` - [x] **Убрать дублирование `commands/` и `console_commands/`** — вынести общую логику в `utils/`, оставить тонкие обёртки - **Закрыто:** коммит `560dc55` — `format_weather_data_for_console()`, `gather_morning()`, `utils/__init__.py` -- [ ] **Валидация конфигурации** — проверить формат `MORNING_TIME` (`ЧЧ:ММ`) и наличие `MORNING_CHANNEL_ID` при запуске +- [x] **Валидация конфигурации** — проверить формат `MORNING_TIME` (`ЧЧ:ММ`) и наличие `MORNING_CHANNEL_ID` при запуске + - **Закрыто:** добавлена `_validate_config()` в `bot.py` — проверка формата `MORNING_TIME` и типа `MORNING_CHANNEL_ID` ### Средний приоритет -- [ ] **Инкапсулировать глобальные переменные** — `stop_event`, `bot_ready`, `scheduler` завернуть в класс `BotRunner` +- [x] **Инкапсулировать глобальные переменные** — `stop_event`, `bot_ready`, `scheduler` завернуть в класс `BotRunner` + - **Закрыто:** создан класс `BotRunner` в `bot.py` — все глобальные состояния инкапсулированы - [ ] **Добавить rate-limiting** для API-вызовов (TheCatAPI, wttr.in, Habr RSS) - [ ] **Настроить логирование** — уровни, формат, вывод в файл/консоль - [ ] **Добавить тесты для `console_commands/`** — есть только `test_help_console.py`, нужны `test_pogoda_console.py`, `test_news_console.py`, `test_morning_console.py` @@ -35,7 +37,8 @@ ### Низкий приоритет -- [ ] **Улучшить Dockerfile** — multi-stage сборка +- [x] **Улучшить Dockerfile** — multi-stage сборка + - **Закрыто:** для чистого Python без компиляции выгода минимальная, не актуально - [x] **Добавить healthcheck** — в Dockerfile - **Закрыто:** healthcheck присутствует в Dockerfile (`CMD python -c "import discord; print('Bot is running')"`), добавлен коммитом `7bfb96c` - [ ] **Добавить pre-commit хуки** — lint (flake8/ruff), форматирование (black) diff --git a/bot.py b/bot.py index f81ef10..58ecde7 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,7 @@ import logging import os import sys import threading +from typing import TYPE_CHECKING import discord from discord.ext import commands @@ -14,6 +15,9 @@ 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() @@ -21,136 +25,198 @@ load_dotenv() intents = discord.Intents.default() intents.message_content = True -bot = commands.Bot(command_prefix="!", intents=intents) -stop_event = threading.Event() -bot_ready = threading.Event() -scheduler: Scheduler | None = None +class BotRunner: + """Управляет жизненным циклом бота.""" -@bot.event -async def on_ready(): - global scheduler - logger.info(f"Бот вошёл как {bot.user}") - for cog_class in ALL_COMMANDS: - cog = cog_class() - await bot.add_cog(cog) - for cog in bot.cogs: - logger.info(f" Загружен: {cog}") + 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 - # Запуск планировщика - morning_time = os.getenv("MORNING_TIME", "07:00") - scheduler = Scheduler(bot, morning_time) - logger.info(f" Планировщик запущен (время: {morning_time})") + self._setup_events() - bot_ready.set() + 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}") -@bot.event -async def on_command_error(ctx, error): - if isinstance(error, CommandNotFound): - return + # Запуск планировщика + morning_time = os.getenv("MORNING_TIME", "07:00") + self.scheduler = Scheduler(self.bot, morning_time) + logger.info(f" Планировщик запущен (время: {morning_time})") - # Терминал — детали для разработчика - cmd_name = ctx.command.name if ctx and ctx.command else '?' - logger.error( - f"Ошибка команды {cmd_name}: {error}", - exc_info=True, - ) + self.bot_ready.set() - # Discord — только если команда не ответила сама - # ctx.interaction есть только у slash-команд (AutoshardedInteractionContext) - # Для текстовых команд (!prefix) атрибута нет — используем hasattr - if ctx and hasattr(ctx, 'interaction') and ctx.interaction and ctx.interaction.response.is_done(): - return + @self.bot.event + async def on_command_error(ctx: commands.Context, error: Exception) -> None: + if isinstance(error, CommandNotFound): + return - try: - await ctx.send("Не удалось выполнить команду. Попробуйте позже.") - except (discord.NotFound, discord.Forbidden): - pass # Бот не может писать в канал — игнорируем + # Терминал — детали для разработчика + 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 -@bot.command(name="msg") -async def msg(ctx, *, text: str): - """Повторяет текст после !msg""" - await ctx.send(text) - - -def _print_commands(): - """Вывести список доступных консольных команд.""" - 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(): - bot_ready.wait() - _print_commands() - - while not stop_event.is_set(): - try: - choice = input("\nВыберите команду (номер): ").strip() - if choice == "0": - print("\nОстановка бота...") - stop_event.set() - asyncio.run_coroutine_threadsafe(bot.close(), 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(stop_event, bot), bot.loop).result() + 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: - cmd_func(stop_event, bot) - else: - print(f"Неизвестная команда: {choice}") - except (ValueError, IndexError): - print(f"Неверный формат: {choice}") - _print_commands() - except (EOFError, KeyboardInterrupt): - stop_event.set() - try: - asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result(timeout=5) - except Exception as e: - logger.error(f"Ошибка при остановке бота: {e}") - break + 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) -if __name__ == "__main__": +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=console_input, daemon=True) + thread = threading.Thread(target=runner.console_input, daemon=True) thread.start() - try: - 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("Остановка бота...") - stop_event.set() - if scheduler: - scheduler.stop() - asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result() - sys.exit(0) + token = os.getenv("DISCORD_TOKEN") + runner.run(token) diff --git a/docker-compose.yml b/docker-compose.yml index 57ac542..81adb6e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,5 @@ services: environment: - DISCORD_TOKEN=${DISCORD_TOKEN} - MORNING_TIME=${MORNING_TIME:-07:00} + - MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID} - PYTHONUNBUFFERED=1 diff --git a/tests/test_bot.py b/tests/test_bot.py index 4a268a3..afefa4e 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -22,9 +22,13 @@ class TestBotInit: """Проверка, что бот создан с правильным префиксом команд.""" import bot - assert bot.bot.command_prefix == "!", ( - f"Команда должна быть с префиксом '!', а не '{bot.bot.command_prefix}'" - ) + runner = bot.BotRunner() + try: + assert runner.bot.command_prefix == "!", ( + f"Команда должна быть с префиксом '!', а не '{runner.bot.command_prefix}'" + ) + finally: + runner.stop_event.set() class TestBotErrorHandlingCodeExists: