discordBot/utils/morning_runner.py
deadzilla 71bcd66794 feat: закрываю Sprint 1 — все 3 задачи выполнены
Задачи Sprint 1 (Critical Fixes):
- [1.1] Fallback в пустом embed (!morning) — добавлена проверка has_real_data и fallback сообщение при отключении внешних API
- [1.2] Обработка ошибок токена/сети в bot.run() — raise_exception=True + логирование LoginFailure, HTTPException, Exception
- [1.3] Рефакторинг парсинга погоды — вынесено в format_weather_data_for_console(), убран дублирующий код из 2 файлов

Изменения:
• utils/morning_runner.py — добавлена проверка has_real_data после формирования description_lines
• bot.py — обработчики исключений для запуска бота с детализированным логированием и пользовательскими сообщениями
• utils/pogoda.py — новая функция format_weather_data_for_console() для центрального форматирования погодных данных
• console_commands/pogoda.py — замена 15 строк дублирующейся логики на вызов единой функции (24→8 строк)
• console_commands/morning.py — аналогичные изменения для команды morning (19→13 строк с погодой)
• tests/test_morning_runner.py — +2 теста для fallback сценариев empty embed и only weather data
• tests/test_bot.py — новый файл с 2 тестами на проверку кода обработки ошибок
• tests/test_pogoda.py — +6 тестов для format_weather_data_for_console()

Статистика тестирования:
• Общее количество тестов: 200 (было 190)
• Новые тесты: 10
• Все тесты проходят успешно

Примечания:
- Сообщения без восклицательных знаков согласно preferencem
2026-06-01 16:32:02 +05:00

205 lines
8.8 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 datetime import datetime, timedelta
import discord
from discord.ext import commands
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 = []
has_real_data = False
# --- Погода ---
if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0]
if current:
has_real_data = True
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:
has_real_data = True
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:
has_real_data = True
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("Не удалось получить новости.")
# 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:
"""Планировщик ежедневных задач."""
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._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.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}")
# Если задан конкретный канал — отправляем туда
if self._target_channel_id:
channel = self.bot.get_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)
# Fallback: первый канал с правами send_messages
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()