Добавить планировщик утреннего дайджеста
- 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:
parent
b912312271
commit
66d188c1cd
@ -1 +1,2 @@
|
||||
DISCORD_TOKEN=your_bot_token_here
|
||||
MORNING_TIME=07:00
|
||||
|
||||
@ -31,6 +31,14 @@ python bot.py
|
||||
| `!cat` | Случайный котик | Embed с изображением |
|
||||
| `!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 |
|
||||
| `MORNING_TIME` | Время запуска утреннего дайджеста | `.env` (формат `ЧЧ:ММ`, по умолчанию `07:00`) |
|
||||
|
||||
## Зависимости
|
||||
```txt
|
||||
|
||||
11
bot.py
11
bot.py
@ -12,6 +12,7 @@ from dotenv import load_dotenv
|
||||
|
||||
from commands import ALL_COMMANDS
|
||||
from console_commands import ALL_CONSOLE_COMMANDS
|
||||
from utils.morning_runner import Scheduler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -23,16 +24,24 @@ intents.message_content = True
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
stop_event = threading.Event()
|
||||
bot_ready = threading.Event()
|
||||
scheduler: Scheduler | None = None
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
global scheduler
|
||||
print(f"Бот вошёл как {bot.user}")
|
||||
for cog_class in ALL_COMMANDS:
|
||||
cog = cog_class()
|
||||
await bot.add_cog(cog)
|
||||
for cog in bot.cogs:
|
||||
print(f" Загружен: {cog}")
|
||||
|
||||
# Запуск планировщика
|
||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||
scheduler = Scheduler(bot, morning_time)
|
||||
print(f" Планировщик запущен (время: {morning_time})")
|
||||
|
||||
bot_ready.set()
|
||||
|
||||
|
||||
@ -123,5 +132,7 @@ if __name__ == "__main__":
|
||||
except KeyboardInterrupt:
|
||||
print("\nОстановка бота...")
|
||||
stop_event.set()
|
||||
if scheduler:
|
||||
scheduler.stop()
|
||||
asyncio.run_coroutine_threadsafe(bot.close(), bot.loop).result()
|
||||
sys.exit(0)
|
||||
|
||||
@ -1,95 +1,16 @@
|
||||
import asyncio
|
||||
|
||||
import discord
|
||||
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.cat import fetch_cat
|
||||
|
||||
from utils.morning_runner import run_morning
|
||||
|
||||
|
||||
class Morning(commands.Cog):
|
||||
"""Команда !morning — погода и новости утром"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
pass
|
||||
|
||||
@commands.command(name="morning")
|
||||
async def morning(self, ctx):
|
||||
"""Погода, лучшие статьи за сутки и котик"""
|
||||
# Параллельный запрос погоды, новостей и котика
|
||||
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)
|
||||
await run_morning(ctx.bot, ctx.channel)
|
||||
|
||||
134
tests/test_morning_runner.py
Normal file
134
tests/test_morning_runner.py
Normal 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
167
utils/morning_runner.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user