refactor: инкапсулировать глобальные переменные в BotRunner + валидация конфига

- bot.py: создан класс BotRunner (bot, stop_event, bot_ready, scheduler)
- bot.py: добавлена _validate_config() — проверка MORNING_TIME (ЧЧ:ММ) и MORNING_CHANNEL_ID (int)
- bot.py: убраны все глобальные переменные
- Dockerfile: healthcheck через ps aux (проверка процесса python bot.py)
- docker-compose.yml: добавлен MORNING_CHANNEL_ID
- ISSUES.md: закрыто 3 задачи (валидация конфига, инкапсуляция, multi-stage)
- tests/test_bot.py: адаптирован тест под BotRunner
This commit is contained in:
deadzilla 2026-06-09 23:09:02 +05:00
parent 47e4b60111
commit 071e313015
5 changed files with 193 additions and 117 deletions

View File

@ -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"]

View File

@ -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)

280
bot.py
View File

@ -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)

View File

@ -6,4 +6,5 @@ services:
environment:
- DISCORD_TOKEN=${DISCORD_TOKEN}
- MORNING_TIME=${MORNING_TIME:-07:00}
- MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID}
- PYTHONUNBUFFERED=1

View File

@ -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: