Исправление планировщика 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:
parent
4f48471564
commit
8ee5ed669f
14
README.md
14
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
|
||||
|
||||
|
||||
18
bot.py
18
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:
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
with patch("asyncio.create_task"):
|
||||
scheduler = Scheduler(bot)
|
||||
loop_mock = MagicMock()
|
||||
scheduler.morning_loop = loop_mock
|
||||
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()
|
||||
with patch("asyncio.create_task"):
|
||||
scheduler = Scheduler(bot)
|
||||
loop_mock = MagicMock()
|
||||
scheduler.morning_loop = loop_mock
|
||||
|
||||
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:
|
||||
|
||||
@ -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()
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
self._running = False
|
||||
logger.info("Планировщик остановлен")
|
||||
except RuntimeError:
|
||||
logger.warning("Планировщик уже остановлен")
|
||||
|
||||
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):
|
||||
async def _scheduler_loop(self):
|
||||
"""Бесконечный цикл: ждём целевое время -> запускаем morning -> повторяем."""
|
||||
while self._running:
|
||||
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:
|
||||
self._last_run_date = now.day
|
||||
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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user