diff --git a/README.md b/README.md index 5bd1c84..e1bb4f6 100644 --- a/README.md +++ b/README.md @@ -187,12 +187,12 @@ DISCORD_TOKEN=ваш_токен docker-compose up Для управления ботом из терминала (без Discord-чата) используйте `admin.py`: ```bash -docker exec discord-bot python admin.py pogoda -docker exec discord-bot python admin.py news -docker exec discord-bot python admin.py cat -docker exec discord-bot python admin.py morning -docker exec discord-bot python admin.py logs # последние строки лога -docker exec discord-bot python admin.py help +docker exec discord-bot python console_commands/admin.py pogoda +docker exec discord-bot python console_commands/admin.py news +docker exec discord-bot python console_commands/admin.py cat +docker exec discord-bot python console_commands/admin.py morning +docker exec discord-bot python console_commands/admin.py logs # последние строки лога +docker exec discord-bot python console_commands/admin.py help docker stop discord-bot # остановка бота ``` @@ -336,7 +336,7 @@ pytest-asyncio>=0.21.0 | `MorningData` | dataclass с полями weather, articles, posts, cat_url | | `gather_morning()` | Параллельный сбор всех данных для дайджеста | | `run_morning()` | Формирование и отправка embed в канал Discord | -| `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) | +| `Scheduler` | Планировщик ежедневных задач (asyncio.sleep до целевого времени) | ### utils/rate_limiter.py diff --git a/bot.py b/bot.py index 63c64ab..b5924b4 100644 --- a/bot.py +++ b/bot.py @@ -51,14 +51,24 @@ class BotRunner: 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) self.bot._scheduler = self.scheduler - logger.info(" Планировщик запущен (время: %s)", morning_time) - - self.bot_ready.set() + logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name) @self.bot.event async def on_command_error(ctx: commands.Context, error: Exception) -> None: diff --git a/console_commands/__init__.py b/console_commands/__init__.py index 10d88b2..6805eb5 100644 --- a/console_commands/__init__.py +++ b/console_commands/__init__.py @@ -3,11 +3,11 @@ from .news import news from .cat import cat from .pogoda import pogoda from .morning import morning -from .help import help +from .help import help as console_help from .status import status from .stats import stats from .logs import logs -from .reload import reload +from .reload import reload as reload_cogs from .trigger_morning import trigger_morning ALL_CONSOLE_COMMANDS = { @@ -16,10 +16,10 @@ ALL_CONSOLE_COMMANDS = { "cat": cat, "pogoda": pogoda, "morning": morning, - "help": help, + "help": console_help, "status": status, "stats": stats, "logs": logs, - "reload": reload, + "reload": reload_cogs, "trigger morning": trigger_morning, } diff --git a/docker-compose.yml b/docker-compose.yml index c42fe76..3c0a8d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,5 +7,14 @@ services: - DISCORD_TOKEN=${DISCORD_TOKEN} - MORNING_TIME=${MORNING_TIME:-07:00} - MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + - CAT_API_RATE=${CAT_API_RATE:-1} + - CAT_API_BURST=${CAT_API_BURST:-3} + - WEATHER_API_RATE=${WEATHER_API_RATE:-1} + - WEATHER_API_BURST=${WEATHER_API_BURST:-3} + - OPEN_METEO_API_RATE=${OPEN_METEO_API_RATE:-2} + - OPEN_METEO_API_BURST=${OPEN_METEO_API_BURST:-5} + - HABR_RSS_RATE=${HABR_RSS_RATE:-1} + - HABR_RSS_BURST=${HABR_RSS_BURST:-2} - PYTHONUNBUFFERED=1 - TZ=UTC5 diff --git a/tests/test_morning_runner.py b/tests/test_morning_runner.py index 2f6d79f..30c9221 100644 --- a/tests/test_morning_runner.py +++ b/tests/test_morning_runner.py @@ -1,7 +1,7 @@ """Тесты для utils/morning_runner.py — Scheduler и run_morning.""" import asyncio -from datetime import datetime +from datetime import date, datetime from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock import discord @@ -16,114 +16,86 @@ class TestSchedulerInit: def test_init_sets_morning_time(self): """Инициализация должна устанавливать время.""" bot = AsyncMock() - mock_loop = MagicMock() - with patch("utils.morning_runner.tasks.loop", return_value=mock_loop): + with patch("asyncio.create_task") as mock_task: scheduler = Scheduler(bot, "08:30") assert scheduler.morning_time == "08:30" def test_init_default_morning_time(self): """Инициализация с дефолтным временем.""" bot = AsyncMock() - mock_loop = MagicMock() - with patch("utils.morning_runner.tasks.loop", return_value=mock_loop): + with patch("asyncio.create_task") as mock_task: scheduler = Scheduler(bot) assert scheduler.morning_time == "07:00" - def test_init_creates_loop(self): - """Инициализация должна создавать loop.""" + def test_init_creates_task(self): + """Инициализация должна создавать asyncio.Task.""" bot = AsyncMock() - mock_loop = MagicMock() - with patch("utils.morning_runner.tasks.loop", return_value=mock_loop): + with patch("asyncio.create_task") as mock_task: scheduler = Scheduler(bot) - assert scheduler.morning_loop is not None + mock_task.assert_called_once() + assert scheduler._task is not None class TestSchedulerCalculateNextRun: """Тесты расчёта следующего запуска.""" - @pytest.fixture(autouse=True) - def _mock_loop(self): - """Замокать tasks.loop, чтобы не создавать реальный coroutine.""" - with patch("utils.morning_runner.tasks.loop", return_value=MagicMock()): - yield - def test_next_run_today_before_time(self): """Если сейчас раньше времени — вернуть сегодня.""" bot = AsyncMock() - scheduler = Scheduler(bot, "14:00") + with patch("asyncio.create_task"): + scheduler = Scheduler(bot, "14:00") - with patch("utils.morning_runner.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2026, 5, 29, 10, 0, 0) - next_run = scheduler._calculate_next_run() - assert next_run == datetime(2026, 5, 29, 14, 0, 0) + now = datetime(2026, 5, 29, 10, 0, 0) + next_run = scheduler._calculate_next_run(now) + assert next_run == datetime(2026, 5, 29, 14, 0, 0) - def test_next_run_tomorning_after_time(self): + def test_next_run_tomorrow_after_time(self): """Если сейчас позже времени — вернуть завтра.""" bot = AsyncMock() - scheduler = Scheduler(bot, "14:00") + with patch("asyncio.create_task"): + scheduler = Scheduler(bot, "14:00") - with patch("utils.morning_runner.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2026, 5, 29, 15, 0, 0) - next_run = scheduler._calculate_next_run() - assert next_run == datetime(2026, 5, 30, 14, 0, 0) + now = datetime(2026, 5, 29, 15, 0, 0) + next_run = scheduler._calculate_next_run(now) + assert next_run == datetime(2026, 5, 30, 14, 0, 0) def test_next_run_exact_time(self): """Если сейчас ровно время — вернуть завтра.""" bot = AsyncMock() - scheduler = Scheduler(bot, "14:00") + with patch("asyncio.create_task"): + scheduler = Scheduler(bot, "14:00") - with patch("utils.morning_runner.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2026, 5, 29, 14, 0, 0) - next_run = scheduler._calculate_next_run() - assert next_run == datetime(2026, 5, 30, 14, 0, 0) + now = datetime(2026, 5, 29, 14, 0, 0) + next_run = scheduler._calculate_next_run(now) + assert next_run == datetime(2026, 5, 30, 14, 0, 0) class TestSchedulerStartStop: """Тесты запуска/остановки планировщика.""" - @pytest.fixture(autouse=True) - def _mock_loop(self): - """Замокать tasks.loop, чтобы не создавать реальный coroutine.""" - with patch("utils.morning_runner.tasks.loop", return_value=MagicMock()): - yield - - def test_start_starts_loop(self): - """start() должен вызывать start() на loop.""" + def test_start_starts_task(self): + """start() должен запустить task.""" bot = AsyncMock() - scheduler = Scheduler(bot) - loop_mock = MagicMock() - scheduler.morning_loop = loop_mock + with patch("asyncio.create_task"): + scheduler = Scheduler(bot) + assert scheduler._running is True - scheduler.start() - loop_mock.start.assert_called_once() - - def test_stop_stops_loop(self): - """stop() должен вызывать stop() на loop.""" + def test_stop_stops_task(self): + """stop() должен остановить task.""" bot = AsyncMock() - scheduler = Scheduler(bot) - loop_mock = MagicMock() - scheduler.morning_loop = loop_mock - + with patch("asyncio.create_task"): + scheduler = Scheduler(bot) scheduler.stop() - loop_mock.stop.assert_called_once() + assert scheduler._running is False - -class TestSchedulerCheckAndRun: - """Тесты проверки и запуска morning.""" - - @pytest.mark.asyncio - async def test_check_and_run_same_day_no_duplicate(self): - """Не должен запускать дважды в один день.""" + def test_double_start_no_duplicate(self): + """Повторный start не должен создавать второй task.""" bot = AsyncMock() - scheduler = Scheduler(bot, "07:00") - scheduler._last_run_date = datetime.now().day - - with patch("utils.morning_runner.datetime") as mock_dt: - mock_dt.now.return_value = datetime(2026, 5, 29, 7, 0, 0) - await scheduler._check_and_run_morning() - - # run_morning не должен вызываться - assert scheduler._last_run_date == datetime.now().day + with patch("asyncio.create_task") as mock_task: + scheduler = Scheduler(bot) + scheduler.start() # второй вызов + # create_task вызван только при инициализации + assert mock_task.call_count == 1 class TestRunMorning: diff --git a/utils/morning_runner.py b/utils/morning_runner.py index e873089..f590b6d 100644 --- a/utils/morning_runner.py +++ b/utils/morning_runner.py @@ -4,12 +4,11 @@ import asyncio import logging import os from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import Optional import discord from discord.ext import commands -from discord.ext import tasks from utils.pogoda import ( API_URL_WEATHER, @@ -120,13 +119,18 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel): class Scheduler: - """Планировщик ежедневных задач.""" + """Планировщик ежедневных задач. + + Использует asyncio.Task вместо tasks.loop — + спит до целевого времени, затем выполняет задачу и повторяет. + Это надёжнее, чем опрос каждую секунду (tasks.loop может пропустить + момент срабатывания из-за неточности asyncio тайминга). + """ def __init__(self, bot: commands.Bot, morning_time: str = "07:00"): self.bot = bot self.morning_time = morning_time - self._last_run_date = None - # Канал для утреннего дайджеста (по умолчанию None — первый попавшийся) + self._last_run_date: date | None = None # полная дата, не день месяца self._target_channel_id: int | None = None channel_id_str = os.getenv("MORNING_CHANNEL_ID") if channel_id_str: @@ -134,25 +138,26 @@ class Scheduler: self._target_channel_id = int(channel_id_str) except ValueError: logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str) - self.morning_loop = tasks.loop(seconds=1.0)(self._check_and_run_morning) + self._task: asyncio.Task | None = None + self._running = False self._start_scheduler() def _start_scheduler(self): - try: - self.morning_loop.start() - logger.info("Планировщик запущен (время: %s)", self.morning_time) - except RuntimeError: + if self._running: logger.warning("Планировщик уже запущен") + return + self._running = True + self._task = asyncio.create_task(self._scheduler_loop()) + logger.info("Планировщик запущен (время: %s)", self.morning_time) def _stop_scheduler(self): - try: - self.morning_loop.stop() - logger.info("Планировщик остановлен") - except RuntimeError: - logger.warning("Планировщик уже остановлен") + if self._task and not self._task.done(): + self._task.cancel() + self._running = False + logger.info("Планировщик остановлен") - def _calculate_next_run(self) -> datetime: - now = datetime.now() + def _calculate_next_run(self, now: datetime) -> datetime: + """Рассчитать время следующего запуска.""" hour, minute = map(int, self.morning_time.split(":")) today_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0) @@ -160,43 +165,65 @@ class Scheduler: return today_run + timedelta(days=1) return today_run - async def _check_and_run_morning(self): - now = datetime.now() - target = self._calculate_next_run() + async def _scheduler_loop(self): + """Бесконечный цикл: ждём целевое время -> запускаем morning -> повторяем.""" + while self._running: + now = datetime.now() + target = self._calculate_next_run(now) + sleep_seconds = (target - now).total_seconds() - if now >= target and now.day != self._last_run_date: - self._last_run_date = now.day - await self._run_morning() + logger.info( + "Ожидание: target=%s, sleep=%.0f сек (%.0f мин)", + target.strftime("%Y-%m-%d %H:%M"), + sleep_seconds, + sleep_seconds / 60, + ) + + # Спим до целевого времени + try: + await asyncio.sleep(sleep_seconds) + except asyncio.CancelledError: + return + + # Проверяем, что нужно запускать (не запускалось сегодня и бот ещё работает) + if self._running and datetime.now().date() != self._last_run_date: + logger.info("Срабатывание расписания: %s", self.morning_time) + await self._run_morning() + else: + logger.info("Morning уже запускался сегодня, пропуск") async def _run_morning(self): - logger.info(f"Выполняю morning в {self.morning_time}") + logger.info("Выполняю morning в %s", self.morning_time) # Если задан конкретный канал — отправляем туда if self._target_channel_id: - channel = self.bot.get_channel(self._target_channel_id) + # fetch_channel — API-запрос, не зависит от кэша + channel = await self.bot.fetch_channel(self._target_channel_id) if isinstance(channel, discord.TextChannel): if channel.permissions_for(channel.guild.me).send_messages: try: - await channel.send("🌅 Утренний дайджест!") await run_morning(self.bot, channel) return except Exception as e: logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e) return else: - logger.warning("Канал с ID %s не найден или не текстовый — fallback", self._target_channel_id) + logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id) # Fallback: первый канал с правами send_messages + sent = False for channel in self.bot.get_all_channels(): if isinstance(channel, discord.TextChannel): if channel.permissions_for(channel.guild.me).send_messages: try: - await channel.send("🌅 Утренний дайджест!") await run_morning(self.bot, channel) + sent = True return except Exception as e: logger.error("Ошибка отправки в #%s: %s", channel.name, e) continue + if not sent: + logger.error("Не удалось найти канал для отправки morning-дайджеста") def start(self): self._start_scheduler()