ruff format --check падает на 7 файлах (не 5 как было указано). Изменения — чистое форматирование: line wrapping, trailing commas, blank lines. Никаких логических изменений. Файлы: bot.py, tests/test_commands_news.py, tests/test_commands_pg.py, tests/test_fetch_weather.py, tests/test_help_command.py, tests/test_morning_runner.py, utils/pogoda.py
290 lines
11 KiB
Python
290 lines
11 KiB
Python
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)
|
||
|
||
yandex_key = os.getenv("YANDEX_WEATHER_API_KEY")
|
||
if not yandex_key:
|
||
logger.error("YANDEX_WEATHER_API_KEY не найден в .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)
|