Исправление планировщика 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`:
```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

18
bot.py
View File

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

View File

@ -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,
}

View File

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

View File

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

View File

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