Добавить планировщик утреннего дайджеста

- utils/morning_runner.py: Scheduler + run_morning()
- bot.py: автоматический запуск планировщика при старте
- commands/morning.py: использовать run_morning() вместо дублирования
- .env.example: добавить MORNING_TIME=07:00
- AGENTS.md: обновить документацию
- tests/test_morning_runner.py: 10 тестов для Scheduler
This commit is contained in:
deadzilla 2026-05-29 17:48:26 +05:00
parent a3e11b1e9c
commit e387f330a0
6 changed files with 326 additions and 83 deletions

View File

@ -1 +1,2 @@
DISCORD_TOKEN=your_bot_token_here DISCORD_TOKEN=your_bot_token_here
MORNING_TIME=07:00

View File

@ -31,6 +31,14 @@ python bot.py
| `!cat` | Случайный котик | Embed с изображением | | `!cat` | Случайный котик | Embed с изображением |
| `!msg <текст>` | Повторяет текст | Текст | | `!msg <текст>` | Повторяет текст | Текст |
## Планировщик
- Ежедневный утренний дайджест (`!morning`) запускается автоматически в 07:00
- Время задаётся в `.env` переменной `MORNING_TIME` (формат `ЧЧ:ММ`)
- Реализован через `discord.ext.tasks.loop`
- Отправляет дайджест в первый канал, где бот имеет права
- Код в `utils/morning_runner.py`
## Структура проекта ## Структура проекта
| Каталог | Назначение | | Каталог | Назначение |
@ -71,6 +79,7 @@ python bot.py
| Переменная | Описание | Где взять | | Переменная | Описание | Где взять |
|------------|----------|-----------| |------------|----------|-----------|
| `DISCORD_TOKEN` | Токен бота | Discord Developer Portal | | `DISCORD_TOKEN` | Токен бота | Discord Developer Portal |
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
## Зависимости ## Зависимости
```txt ```txt

11
bot.py
View File

@ -12,6 +12,7 @@ from dotenv import load_dotenv
from commands import ALL_COMMANDS from commands import ALL_COMMANDS
from console_commands import ALL_CONSOLE_COMMANDS from console_commands import ALL_CONSOLE_COMMANDS
from utils.morning_runner import Scheduler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -23,16 +24,24 @@ intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
stop_event = threading.Event() stop_event = threading.Event()
bot_ready = threading.Event() bot_ready = threading.Event()
scheduler: Scheduler | None = None
@bot.event @bot.event
async def on_ready(): async def on_ready():
global scheduler
print(f"Бот вошёл как {bot.user}") print(f"Бот вошёл как {bot.user}")
for cog_class in ALL_COMMANDS: for cog_class in ALL_COMMANDS:
cog = cog_class() cog = cog_class()
await bot.add_cog(cog) await bot.add_cog(cog)
for cog in bot.cogs: for cog in bot.cogs:
print(f" Загружен: {cog}") print(f" Загружен: {cog}")
# Запуск планировщика
morning_time = os.getenv("MORNING_TIME", "07:00")
scheduler = Scheduler(bot, morning_time)
print(f" Планировщик запущен (время: {morning_time})")
bot_ready.set() bot_ready.set()
@ -123,5 +132,7 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nОстановка бота...") print("\nОстановка бота...")
stop_event.set() stop_event.set()
if scheduler:
scheduler.stop()
asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result() asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result()
sys.exit(0) sys.exit(0)

View File

@ -1,95 +1,16 @@
import asyncio
import discord import discord
from discord.ext import commands from discord.ext import commands
from utils.pogoda import fetch_weather, pressure_to_mmhg, translate_weather
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS from utils.morning_runner import run_morning
from utils.cat import fetch_cat
class Morning(commands.Cog): class Morning(commands.Cog):
"""Команда !morning — погода и новости утром""" """Команда !morning — погода и новости утром"""
def __init__(self): def __init__(self):
self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru" pass
@commands.command(name="morning") @commands.command(name="morning")
async def morning(self, ctx): async def morning(self, ctx):
"""Погода, лучшие статьи за сутки и котик""" """Погода, лучшие статьи за сутки и котик"""
# Параллельный запрос погоды, новостей и котика await run_morning(ctx.bot, ctx.channel)
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(self.api_url),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),
)
# --- Формируем embed ---
embed = discord.Embed(title="Доброе утро!", color=0xF4A460)
# Котик как thumbnail (маленький в углу)
if cat_url:
embed.set_thumbnail(url=cat_url)
description_lines = []
# --- Погода ---
if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0]
if current:
temp = current.get("temp_C", "\u2014")
feels_like = current.get("FeelsLikeC", "\u2014")
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "\u2014"))
humidity = current.get("humidity", "\u2014")
wind_kmh = current.get("windspeedKmph", "\u2014")
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "\u2014" else "\u2014"
except (ValueError, TypeError):
wind = "\u2014"
pressure_mb = current.get("pressure", "\u2014")
pressure_mm = pressure_to_mmhg(pressure_mb)
description_lines.append(
f"**Погода в Магнитогорске:**\n"
f"Температура: {temp}\u00b0C (ощущается как {feels_like}\u00b0C)\n"
f"Описание: {description}\n"
f"Влажность: {humidity}%\n"
f"Ветер: {wind} м/с\n"
f"Давление: {pressure_mm} мм рт. ст."
)
else:
description_lines.append("Не удалось получить данные о погоде.")
else:
description_lines.append("Не удалось получить данные о погоде.")
description_lines.append("") # пустая строка-разделитель
# --- Новости: статьи ---
if articles is not None:
if articles:
lines = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
description_lines.append("") # пустая строка-разделитель
# --- Новости: посты ---
if posts is not None:
if posts:
lines = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
embed.description = "\n".join(description_lines)
await ctx.send(embed=embed)

View File

@ -0,0 +1,134 @@
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import discord
import pytest
from utils.morning_runner import Scheduler, run_morning
class TestSchedulerInit:
"""Тесты инициализации Scheduler."""
def test_init_sets_morning_time(self):
"""Инициализация должна устанавливать время."""
bot = AsyncMock()
scheduler = Scheduler(bot, "08:30")
assert scheduler.morning_time == "08:30"
def test_init_default_morning_time(self):
"""Инициализация с дефолтным временем."""
bot = AsyncMock()
scheduler = Scheduler(bot)
assert scheduler.morning_time == "07:00"
def test_init_creates_loop(self):
"""Инициализация должна создавать loop."""
bot = AsyncMock()
scheduler = Scheduler(bot)
assert scheduler.morning_loop is not None
class TestSchedulerCalculateNextRun:
"""Тесты расчёта следующего запуска."""
def test_next_run_today_before_time(self):
"""Если сейчас раньше времени — вернуть сегодня."""
bot = AsyncMock()
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)
def test_next_run_tomorrow_after_time(self):
"""Если сейчас позже времени — вернуть завтра."""
bot = AsyncMock()
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)
def test_next_run_exact_time(self):
"""Если сейчас ровно время — вернуть завтра."""
bot = AsyncMock()
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)
class TestSchedulerStartStop:
"""Тесты запуска/остановки планировщика."""
def test_start_starts_loop(self):
"""start() должен вызывать start() на loop."""
bot = AsyncMock()
scheduler = Scheduler(bot)
loop_mock = MagicMock()
scheduler.morning_loop = loop_mock
scheduler.start()
loop_mock.start.assert_called_once()
def test_stop_stops_loop(self):
"""stop() должен вызывать stop() на loop."""
bot = AsyncMock()
scheduler = Scheduler(bot)
loop_mock = MagicMock()
scheduler.morning_loop = loop_mock
scheduler.stop()
loop_mock.stop.assert_called_once()
class TestSchedulerCheckAndRun:
"""Тесты проверки и запуска morning."""
@pytest.mark.asyncio
async def test_check_and_run_same_day_no_duplicate(self):
"""Не должен запускать дважды в один день."""
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
class TestRunMorning:
"""Тесты run_morning."""
@pytest.mark.asyncio
async def test_run_morning_sends_embed(self):
"""run_morning должен отправлять embed в канал."""
bot = AsyncMock()
channel = AsyncMock()
channel.name = "test-channel"
channel.guild.me = MagicMock()
channel.permissions_for.return_value.send_messages = True
with patch("utils.morning_runner.asyncio.gather", new=AsyncMock(return_value=(
{"current_condition": [{"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}]},
[{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}],
[{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}],
"http://cat.jpg",
))), patch("utils.morning_runner.discord.Embed") as mock_embed:
await run_morning(bot, channel)
channel.send.assert_called_once()
call_args = channel.send.call_args[1]
assert "embed" in call_args
assert call_args["embed"] is not None

167
utils/morning_runner.py Normal file
View File

@ -0,0 +1,167 @@
"""Утилита для запуска утреннего дайджеста."""
import asyncio
import logging
from datetime import datetime, timedelta
import discord
from discord.ext import tasks
from utils.pogoda import fetch_weather, pressure_to_mmhg, translate_weather
from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
from utils.cat import fetch_cat
logger = logging.getLogger(__name__)
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
"""Выполнить утренний дайджест и отправить в канал."""
try:
api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
# Параллельный запрос погоды, новостей и котика
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(api_url),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),
)
# --- Формируем embed ---
embed = discord.Embed(title="🌅 Утренний дайджест!", color=0xF4A460)
# Котик как thumbnail
if cat_url:
embed.set_thumbnail(url=cat_url)
description_lines = []
# --- Погода ---
if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0]
if current:
temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "")
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", ""))
humidity = current.get("humidity", "")
wind_kmh = current.get("windspeedKmph", "")
try:
wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "" else ""
except (ValueError, TypeError):
wind = ""
pressure_mb = current.get("pressure", "")
pressure_mm = pressure_to_mmhg(pressure_mb)
description_lines.append(
f"**Погода в Магнитогорске:**\n"
f"Температура: {temp}°C (ощущается как {feels_like}°C)\n"
f"Описание: {description}\n"
f"Влажность: {humidity}%\n"
f"Ветер: {wind} м/с\n"
f"Давление: {pressure_mm} мм рт. ст."
)
else:
description_lines.append("Не удалось получить данные о погоде.")
else:
description_lines.append("Не удалось получить данные о погоде.")
description_lines.append("")
# --- Новости: статьи ---
if articles is not None:
if articles:
lines = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
description_lines.append("")
# --- Новости: посты ---
if posts is not None:
if posts:
lines = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
description_lines.append("\n".join(lines))
else:
description_lines.append("Новостей пока нет.")
else:
description_lines.append("Не удалось получить новости.")
embed.description = "\n".join(description_lines)
await channel.send(embed=embed)
logger.info("✅ Утренний дайджест отправлен в #%s", channel.name)
except Exception as e:
logger.error("Ошибка при выполнении утреннего дайджеста: %s", e, exc_info=True)
try:
await channel.send("Не удалось выполнить утренний дайджест.")
except Exception:
pass
class Scheduler:
"""Планировщик ежедневных задач."""
def __init__(self, bot: commands.Bot, morning_time: str = "07:00"):
self.bot = bot
self.morning_time = morning_time
self._last_run_date = None
self.morning_loop = tasks.loop(seconds=1.0)(self._check_and_run_morning)
self._start_scheduler()
def _start_scheduler(self):
try:
self.morning_loop.start()
logger.info("Планировщик запущен (время: %s)", self.morning_time)
except RuntimeError:
logger.warning("Планировщик уже запущен")
def _stop_scheduler(self):
try:
self.morning_loop.stop()
logger.info("Планировщик остановлен")
except RuntimeError:
logger.warning("Планировщик уже остановлен")
def _calculate_next_run(self) -> datetime:
now = datetime.now()
hour, minute = map(int, self.morning_time.split(":"))
today_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if now >= today_run:
return today_run + timedelta(days=1)
return today_run
async def _check_and_run_morning(self):
now = datetime.now()
target = self._calculate_next_run()
if now >= target and now.day != self._last_run_date:
self._last_run_date = now.day
await self._run_morning()
async def _run_morning(self):
logger.info(f"Выполняю morning в {self.morning_time}")
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)
return
except Exception as e:
logger.error("Ошибка отправки в #%s: %s", channel.name, e)
continue
def start(self):
self._start_scheduler()
def stop(self):
self._stop_scheduler()