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:
parent
47e4b60111
commit
071e313015
10
Dockerfile
10
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"]
|
||||
|
||||
@ -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
280
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)
|
||||
|
||||
@ -6,4 +6,5 @@ services:
|
||||
environment:
|
||||
- DISCORD_TOKEN=${DISCORD_TOKEN}
|
||||
- MORNING_TIME=${MORNING_TIME:-07:00}
|
||||
- MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID}
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user