diff --git a/.env.example b/.env.example index fc6629d..ddcce96 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ DISCORD_TOKEN=your_bot_token_here +MORNING_TIME=07:00 diff --git a/AGENTS.md b/AGENTS.md index d8df53d..09ac988 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/bot.py b/bot.py index d1da43a..c9e09f3 100644 --- a/bot.py +++ b/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) diff --git a/commands/morning.py b/commands/morning.py index 9f83f74..45d5aa9 100644 --- a/commands/morning.py +++ b/commands/morning.py @@ -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) diff --git a/tests/test_morning_runner.py b/tests/test_morning_runner.py new file mode 100644 index 0000000..6a4511f --- /dev/null +++ b/tests/test_morning_runner.py @@ -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 diff --git a/utils/morning_runner.py b/utils/morning_runner.py new file mode 100644 index 0000000..25b8a96 --- /dev/null +++ b/utils/morning_runner.py @@ -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()