Исправление планировщика morning: tasks.loop → asyncio.sleep

- Scheduler больше не опрашивает каждую секунду (tasks.loop мог пропустить
  момент срабатывания из-за неточности тайминга). Теперь спит ровно до
  целевого времени через asyncio.sleep, затем запускает morning и повторяет.
- Добавлено логирование ожидания и срабатывания расписания.
- Перенес инициализацию планировщика из on_ready в on_guild_available —
  гарантирует, что кэш гильдии загружен до запуска Scheduler.
- Обновлены тесты под новый Scheduler (asyncio.create_task вместо tasks.loop).
- README.md: исправлены пути admin.py → console_commands/admin.py для docker exec.
- docker-compose.yml: добавлены переменные LOG_LEVEL и rate-limit конфиги.
- console_commands/__init__.py: переименованы help → console_help,
  reload → reload_cogs (избежал конфликта с built-in и уточнил имена).
This commit is contained in:
deadzilla 2026-06-16 22:10:40 +05:00
parent 4f48471564
commit 8ee5ed669f
6 changed files with 130 additions and 112 deletions

View File

@ -187,12 +187,12 @@ DISCORD_TOKEN=ваш_токен docker-compose up
Для управления ботом из терминала (без Discord-чата) используйте `admin.py`: Для управления ботом из терминала (без Discord-чата) используйте `admin.py`:
```bash ```bash
docker exec discord-bot python admin.py pogoda docker exec discord-bot python console_commands/admin.py pogoda
docker exec discord-bot python admin.py news docker exec discord-bot python console_commands/admin.py news
docker exec discord-bot python admin.py cat docker exec discord-bot python console_commands/admin.py cat
docker exec discord-bot python admin.py morning docker exec discord-bot python console_commands/admin.py morning
docker exec discord-bot python admin.py logs # последние строки лога docker exec discord-bot python console_commands/admin.py logs # последние строки лога
docker exec discord-bot python admin.py help docker exec discord-bot python console_commands/admin.py help
docker stop discord-bot # остановка бота docker stop discord-bot # остановка бота
``` ```
@ -336,7 +336,7 @@ pytest-asyncio>=0.21.0
| `MorningData` | dataclass с полями weather, articles, posts, cat_url | | `MorningData` | dataclass с полями weather, articles, posts, cat_url |
| `gather_morning()` | Параллельный сбор всех данных для дайджеста | | `gather_morning()` | Параллельный сбор всех данных для дайджеста |
| `run_morning()` | Формирование и отправка embed в канал Discord | | `run_morning()` | Формирование и отправка embed в канал Discord |
| `Scheduler` | Планировщик ежедневных задач (discord.ext.tasks.loop) | | `Scheduler` | Планировщик ежедневных задач (asyncio.sleep до целевого времени) |
### utils/rate_limiter.py ### utils/rate_limiter.py

18
bot.py
View File

@ -51,14 +51,24 @@ class BotRunner:
await self.bot.add_cog(cog) await self.bot.add_cog(cog)
for cog in self.bot.cogs: for cog in self.bot.cogs:
logger.info(" Загружен: %s", cog) 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") morning_time = os.getenv("MORNING_TIME", "07:00")
self.scheduler = Scheduler(self.bot, morning_time) self.scheduler = Scheduler(self.bot, morning_time)
self.bot._scheduler = self.scheduler self.bot._scheduler = self.scheduler
logger.info(" Планировщик запущен (время: %s)", morning_time) logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name)
self.bot_ready.set()
@self.bot.event @self.bot.event
async def on_command_error(ctx: commands.Context, error: Exception) -> None: async def on_command_error(ctx: commands.Context, error: Exception) -> None:

View File

@ -3,11 +3,11 @@ from .news import news
from .cat import cat from .cat import cat
from .pogoda import pogoda from .pogoda import pogoda
from .morning import morning from .morning import morning
from .help import help from .help import help as console_help
from .status import status from .status import status
from .stats import stats from .stats import stats
from .logs import logs from .logs import logs
from .reload import reload from .reload import reload as reload_cogs
from .trigger_morning import trigger_morning from .trigger_morning import trigger_morning
ALL_CONSOLE_COMMANDS = { ALL_CONSOLE_COMMANDS = {
@ -16,10 +16,10 @@ ALL_CONSOLE_COMMANDS = {
"cat": cat, "cat": cat,
"pogoda": pogoda, "pogoda": pogoda,
"morning": morning, "morning": morning,
"help": help, "help": console_help,
"status": status, "status": status,
"stats": stats, "stats": stats,
"logs": logs, "logs": logs,
"reload": reload, "reload": reload_cogs,
"trigger morning": trigger_morning, "trigger morning": trigger_morning,
} }

View File

@ -7,5 +7,14 @@ services:
- DISCORD_TOKEN=${DISCORD_TOKEN} - DISCORD_TOKEN=${DISCORD_TOKEN}
- MORNING_TIME=${MORNING_TIME:-07:00} - MORNING_TIME=${MORNING_TIME:-07:00}
- MORNING_CHANNEL_ID=${MORNING_CHANNEL_ID} - 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 - PYTHONUNBUFFERED=1
- TZ=UTC5 - TZ=UTC5

View File

@ -1,7 +1,7 @@
"""Тесты для utils/morning_runner.py — Scheduler и run_morning.""" """Тесты для utils/morning_runner.py — Scheduler и run_morning."""
import asyncio import asyncio
from datetime import datetime from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import discord import discord
@ -16,114 +16,86 @@ class TestSchedulerInit:
def test_init_sets_morning_time(self): def test_init_sets_morning_time(self):
"""Инициализация должна устанавливать время.""" """Инициализация должна устанавливать время."""
bot = AsyncMock() bot = AsyncMock()
mock_loop = MagicMock() with patch("asyncio.create_task") as mock_task:
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
scheduler = Scheduler(bot, "08:30") scheduler = Scheduler(bot, "08:30")
assert scheduler.morning_time == "08:30" assert scheduler.morning_time == "08:30"
def test_init_default_morning_time(self): def test_init_default_morning_time(self):
"""Инициализация с дефолтным временем.""" """Инициализация с дефолтным временем."""
bot = AsyncMock() bot = AsyncMock()
mock_loop = MagicMock() with patch("asyncio.create_task") as mock_task:
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
assert scheduler.morning_time == "07:00" assert scheduler.morning_time == "07:00"
def test_init_creates_loop(self): def test_init_creates_task(self):
"""Инициализация должна создавать loop.""" """Инициализация должна создавать asyncio.Task."""
bot = AsyncMock() bot = AsyncMock()
mock_loop = MagicMock() with patch("asyncio.create_task") as mock_task:
with patch("utils.morning_runner.tasks.loop", return_value=mock_loop):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
assert scheduler.morning_loop is not None mock_task.assert_called_once()
assert scheduler._task is not None
class TestSchedulerCalculateNextRun: 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): def test_next_run_today_before_time(self):
"""Если сейчас раньше времени — вернуть сегодня.""" """Если сейчас раньше времени — вернуть сегодня."""
bot = AsyncMock() bot = AsyncMock()
with patch("asyncio.create_task"):
scheduler = Scheduler(bot, "14:00") scheduler = Scheduler(bot, "14:00")
with patch("utils.morning_runner.datetime") as mock_dt: now = datetime(2026, 5, 29, 10, 0, 0)
mock_dt.now.return_value = datetime(2026, 5, 29, 10, 0, 0) next_run = scheduler._calculate_next_run(now)
next_run = scheduler._calculate_next_run()
assert next_run == datetime(2026, 5, 29, 14, 0, 0) 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() bot = AsyncMock()
with patch("asyncio.create_task"):
scheduler = Scheduler(bot, "14:00") scheduler = Scheduler(bot, "14:00")
with patch("utils.morning_runner.datetime") as mock_dt: now = datetime(2026, 5, 29, 15, 0, 0)
mock_dt.now.return_value = datetime(2026, 5, 29, 15, 0, 0) next_run = scheduler._calculate_next_run(now)
next_run = scheduler._calculate_next_run()
assert next_run == datetime(2026, 5, 30, 14, 0, 0) assert next_run == datetime(2026, 5, 30, 14, 0, 0)
def test_next_run_exact_time(self): def test_next_run_exact_time(self):
"""Если сейчас ровно время — вернуть завтра.""" """Если сейчас ровно время — вернуть завтра."""
bot = AsyncMock() bot = AsyncMock()
with patch("asyncio.create_task"):
scheduler = Scheduler(bot, "14:00") scheduler = Scheduler(bot, "14:00")
with patch("utils.morning_runner.datetime") as mock_dt: now = datetime(2026, 5, 29, 14, 0, 0)
mock_dt.now.return_value = datetime(2026, 5, 29, 14, 0, 0) next_run = scheduler._calculate_next_run(now)
next_run = scheduler._calculate_next_run()
assert next_run == datetime(2026, 5, 30, 14, 0, 0) assert next_run == datetime(2026, 5, 30, 14, 0, 0)
class TestSchedulerStartStop: class TestSchedulerStartStop:
"""Тесты запуска/остановки планировщика.""" """Тесты запуска/остановки планировщика."""
@pytest.fixture(autouse=True) def test_start_starts_task(self):
def _mock_loop(self): """start() должен запустить task."""
"""Замокать tasks.loop, чтобы не создавать реальный coroutine."""
with patch("utils.morning_runner.tasks.loop", return_value=MagicMock()):
yield
def test_start_starts_loop(self):
"""start() должен вызывать start() на loop."""
bot = AsyncMock() bot = AsyncMock()
with patch("asyncio.create_task"):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
loop_mock = MagicMock() assert scheduler._running is True
scheduler.morning_loop = loop_mock
scheduler.start() def test_stop_stops_task(self):
loop_mock.start.assert_called_once() """stop() должен остановить task."""
def test_stop_stops_loop(self):
"""stop() должен вызывать stop() на loop."""
bot = AsyncMock() bot = AsyncMock()
with patch("asyncio.create_task"):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
loop_mock = MagicMock()
scheduler.morning_loop = loop_mock
scheduler.stop() scheduler.stop()
loop_mock.stop.assert_called_once() assert scheduler._running is False
def test_double_start_no_duplicate(self):
class TestSchedulerCheckAndRun: """Повторный start не должен создавать второй task."""
"""Тесты проверки и запуска morning."""
@pytest.mark.asyncio
async def test_check_and_run_same_day_no_duplicate(self):
"""Не должен запускать дважды в один день."""
bot = AsyncMock() bot = AsyncMock()
scheduler = Scheduler(bot, "07:00") with patch("asyncio.create_task") as mock_task:
scheduler._last_run_date = datetime.now().day scheduler = Scheduler(bot)
scheduler.start() # второй вызов
with patch("utils.morning_runner.datetime") as mock_dt: # create_task вызван только при инициализации
mock_dt.now.return_value = datetime(2026, 5, 29, 7, 0, 0) assert mock_task.call_count == 1
await scheduler._check_and_run_morning()
# run_morning не должен вызываться
assert scheduler._last_run_date == datetime.now().day
class TestRunMorning: class TestRunMorning:

View File

@ -4,12 +4,11 @@ import asyncio
import logging import logging
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Optional from typing import Optional
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord.ext import tasks
from utils.pogoda import ( from utils.pogoda import (
API_URL_WEATHER, API_URL_WEATHER,
@ -120,13 +119,18 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
class Scheduler: class Scheduler:
"""Планировщик ежедневных задач.""" """Планировщик ежедневных задач.
Использует asyncio.Task вместо tasks.loop
спит до целевого времени, затем выполняет задачу и повторяет.
Это надёжнее, чем опрос каждую секунду (tasks.loop может пропустить
момент срабатывания из-за неточности asyncio тайминга).
"""
def __init__(self, bot: commands.Bot, morning_time: str = "07:00"): def __init__(self, bot: commands.Bot, morning_time: str = "07:00"):
self.bot = bot self.bot = bot
self.morning_time = morning_time self.morning_time = morning_time
self._last_run_date = None self._last_run_date: date | None = None # полная дата, не день месяца
# Канал для утреннего дайджеста (по умолчанию None — первый попавшийся)
self._target_channel_id: int | None = None self._target_channel_id: int | None = None
channel_id_str = os.getenv("MORNING_CHANNEL_ID") channel_id_str = os.getenv("MORNING_CHANNEL_ID")
if channel_id_str: if channel_id_str:
@ -134,25 +138,26 @@ class Scheduler:
self._target_channel_id = int(channel_id_str) self._target_channel_id = int(channel_id_str)
except ValueError: except ValueError:
logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str) 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() self._start_scheduler()
def _start_scheduler(self): def _start_scheduler(self):
try: if self._running:
self.morning_loop.start()
logger.info("Планировщик запущен (время: %s)", self.morning_time)
except RuntimeError:
logger.warning("Планировщик уже запущен") logger.warning("Планировщик уже запущен")
return
self._running = True
self._task = asyncio.create_task(self._scheduler_loop())
logger.info("Планировщик запущен (время: %s)", self.morning_time)
def _stop_scheduler(self): def _stop_scheduler(self):
try: if self._task and not self._task.done():
self.morning_loop.stop() self._task.cancel()
self._running = False
logger.info("Планировщик остановлен") logger.info("Планировщик остановлен")
except RuntimeError:
logger.warning("Планировщик уже остановлен")
def _calculate_next_run(self) -> datetime: def _calculate_next_run(self, now: datetime) -> datetime:
now = datetime.now() """Рассчитать время следующего запуска."""
hour, minute = map(int, self.morning_time.split(":")) hour, minute = map(int, self.morning_time.split(":"))
today_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0) 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 + timedelta(days=1)
return today_run return today_run
async def _check_and_run_morning(self): async def _scheduler_loop(self):
"""Бесконечный цикл: ждём целевое время -> запускаем morning -> повторяем."""
while self._running:
now = datetime.now() now = datetime.now()
target = self._calculate_next_run() target = self._calculate_next_run(now)
sleep_seconds = (target - now).total_seconds()
if now >= target and now.day != self._last_run_date: logger.info(
self._last_run_date = now.day "Ожидание: 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() await self._run_morning()
else:
logger.info("Morning уже запускался сегодня, пропуск")
async def _run_morning(self): async def _run_morning(self):
logger.info(f"Выполняю morning в {self.morning_time}") logger.info("Выполняю morning в %s", self.morning_time)
# Если задан конкретный канал — отправляем туда # Если задан конкретный канал — отправляем туда
if self._target_channel_id: 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 isinstance(channel, discord.TextChannel):
if channel.permissions_for(channel.guild.me).send_messages: if channel.permissions_for(channel.guild.me).send_messages:
try: try:
await channel.send("🌅 Утренний дайджест!")
await run_morning(self.bot, channel) await run_morning(self.bot, channel)
return return
except Exception as e: except Exception as e:
logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e) logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
return return
else: else:
logger.warning("Канал с ID %s не найден или не текстовый — fallback", self._target_channel_id) logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id)
# Fallback: первый канал с правами send_messages # Fallback: первый канал с правами send_messages
sent = False
for channel in self.bot.get_all_channels(): for channel in self.bot.get_all_channels():
if isinstance(channel, discord.TextChannel): if isinstance(channel, discord.TextChannel):
if channel.permissions_for(channel.guild.me).send_messages: if channel.permissions_for(channel.guild.me).send_messages:
try: try:
await channel.send("🌅 Утренний дайджест!")
await run_morning(self.bot, channel) await run_morning(self.bot, channel)
sent = True
return return
except Exception as e: except Exception as e:
logger.error("Ошибка отправки в #%s: %s", channel.name, e) logger.error("Ошибка отправки в #%s: %s", channel.name, e)
continue continue
if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста")
def start(self): def start(self):
self._start_scheduler() self._start_scheduler()