discordBot/utils/morning_runner.py
deadzilla 48e64c2bc8 Replace wttr.in/Open-Meteo with Yandex Weather API
- Switch from wttr.in + Open-Meteo fallback to Yandex Weather API
- Add YANDEX_WEATHER_API_KEY to .env and config validation
- Enrich weather output: wind gusts, wind direction (Russian)
- Pressure now in mmHg directly from Yandex (no conversion needed)
- Update rate limiter: weather_limiter + open_meteo_limiter → yandex_weather_limiter
- Remove dead code: API_URL_WEATHER, fetch_open_meteo, translate_weather
- Update all tests (271 passed)
2026-07-20 23:31:11 +05:00

282 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Утилита для запуска утреннего дайджеста."""
import asyncio
import logging
import os
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Optional
import discord
from discord.ext import commands
from utils.pogoda import (
fetch_weather,
format_weather_for_message,
)
from utils.news import (
fetch_rss,
format_articles,
RSS_URL_ARTICLES,
RSS_URL_POSTS,
truncate_message,
)
from utils.cat import fetch_cat
logger = logging.getLogger(__name__)
@dataclass
class MorningData:
"""Собранные данные для утреннего дайджеста."""
weather: Optional[dict]
articles: Optional[list]
posts: Optional[list]
cat_url: Optional[str]
async def gather_morning() -> MorningData:
"""Собрать все данные для утреннего дайджеста параллельно."""
weather_data, articles, posts, cat_url = await asyncio.gather(
fetch_weather(),
fetch_rss(RSS_URL_ARTICLES),
fetch_rss(RSS_URL_POSTS),
fetch_cat(),
)
return MorningData(
weather=weather_data,
articles=articles,
posts=posts,
cat_url=cat_url,
)
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None:
"""Выполнить утренний дайджест и отправить в канал как plain text."""
try:
data = await gather_morning()
# --- Котик отдельным сообщением ---
if data.cat_url:
await channel.send(data.cat_url)
# --- Формируем plain text ---
message_lines = ["Утренний дайджест", ""]
has_real_data = False
# --- Погода ---
weather_text = format_weather_for_message(data.weather)
if weather_text:
has_real_data = True
message_lines.append(weather_text)
else:
message_lines.append("Не удалось получить данные о погоде.")
message_lines.append("")
# --- Новости: статьи ---
if data.articles is not None:
if data.articles:
has_real_data = True
lines = format_articles(
data.articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
message_lines.extend(lines)
else:
message_lines.append("Новостей пока нет.")
else:
message_lines.append("Не удалось получить новости.")
message_lines.append("")
# --- Новости: посты ---
if data.posts is not None:
if data.posts:
has_real_data = True
lines = format_articles(
data.posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
)
message_lines.extend(lines)
else:
message_lines.append("Новостей пока нет.")
else:
message_lines.append("Не удалось получить новости.")
# Fallback для пустых данных
if not has_real_data:
message_lines = [
"Не удалось получить данные из внешних источников.",
"Проверьте доступность API и повторите попытку позже.",
]
message = truncate_message("\n".join(message_lines))
await channel.send(message)
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:
"""Планировщик ежедневных задач.
Использует 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: date | None = None # полная дата, не день месяца
self._target_channel_id: int | None = None
channel_id_str = os.getenv("MORNING_CHANNEL_ID")
if channel_id_str:
try:
self._target_channel_id = int(channel_id_str)
except ValueError:
logger.warning(
"Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str
)
self._task: asyncio.Task | None = None
self._running = False
def _start_scheduler(self):
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):
if self._task and not self._task.done():
self._task.cancel()
self._running = False
logger.info("Планировщик остановлен")
@staticmethod
def _format_duration(seconds: float) -> str:
"""Форматирует секунды в читаемый вид: '1 дн, 8 ч, 57 мин, 50 сек'."""
days, remainder = divmod(seconds, 86400)
hours, remainder = divmod(remainder, 3600)
minutes, secs = divmod(remainder, 60)
parts = []
if days:
parts.append(f"{int(days)} дн")
if hours:
parts.append(f"{int(hours)} ч")
if minutes:
parts.append(f"{int(minutes)} мин")
parts.append(f"{int(secs)} сек")
return ", ".join(parts)
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)
if now >= today_run:
return today_run + timedelta(days=1)
return today_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()
logger.info(
"Ожидание: target=%s, sleep=%s",
target.strftime("%Y-%m-%d %H:%M"),
self._format_duration(sleep_seconds),
)
# Спим до целевого времени
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("Выполняю morning в %s", self.morning_time)
# Определяем целевой сервер для fallback
target_guild: discord.Guild | None = None
if self._target_channel_id:
guild = self.bot.get_guild(
(await self.bot.fetch_channel(self._target_channel_id)).guild.id
)
if guild:
target_guild = guild
# Если задан конкретный канал — отправляем туда
if 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 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
)
# Fallback: первый текстовый канал целевого сервера с правами send_messages
guilds_to_check: list[discord.Guild] = []
if target_guild:
guilds_to_check.append(target_guild)
guilds_to_check.extend(self.bot.guilds)
sent = False
for guild in guilds_to_check:
for channel in guild.text_channels:
if channel.permissions_for(guild.me).send_messages:
try:
await run_morning(self.bot, channel)
sent = True
return
except Exception as e:
logger.error(
"Ошибка отправки в #%s (%s): %s",
channel.name,
guild.name,
e,
)
continue
if not sent:
logger.error("Не удалось найти канал для отправки morning-дайджеста")
async def start(self) -> None:
self._start_scheduler()
def stop(self) -> None:
self._stop_scheduler()