"""Утилита для запуска утреннего дайджеста.""" 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 ( API_URL_WEATHER, fetch_weather, format_weather_for_embed, ) from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS 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(API_URL_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): """Выполнить утренний дайджест и отправить в канал.""" try: data = await gather_morning() # --- Формируем embed --- embed = discord.Embed(title="🌅 Утренний дайджест!", color=0xF4A460) # Котик как thumbnail if data.cat_url: embed.set_thumbnail(url=data.cat_url) description_lines = [] has_real_data = False # --- Погода --- weather_text = format_weather_for_embed(data.weather) if weather_text: has_real_data = True description_lines.append(weather_text) else: description_lines.append("Не удалось получить данные о погоде.") description_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/") description_lines.append("\n".join(lines)) else: description_lines.append("Новостей пока нет.") else: description_lines.append("Не удалось получить новости.") description_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/") description_lines.append("\n".join(lines)) else: description_lines.append("Новостей пока нет.") else: description_lines.append("Не удалось получить новости.") # Fallback для пустых данных if not has_real_data: description_lines = [ "Не удалось получить данные из внешних источников.", "Проверьте доступность API и повторите попытку позже." ] 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: """Планировщик ежедневных задач. Использует 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 self._start_scheduler() 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("Планировщик остановлен") 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=%.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("Выполняю morning в %s", self.morning_time) # Если задан конкретный канал — отправляем туда 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 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 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() def stop(self): self._stop_scheduler()