Compare commits

..

No commits in common. "b98c6964984414a923f681db3e0408b5c0b0f145" and "dda5753f8c04a193affbb94450107f907f5e4503" have entirely different histories.

24 changed files with 188 additions and 431 deletions

28
bot.py
View File

@ -11,13 +11,13 @@ from typing import TYPE_CHECKING
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта # discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment] asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
import discord # noqa: E402 import discord
from discord.ext import commands # noqa: E402 from discord.ext import commands
from discord.ext.commands import CommandNotFound # noqa: E402 from discord.ext.commands import CommandNotFound
from dotenv import load_dotenv # noqa: E402 from dotenv import load_dotenv
from commands import ALL_COMMANDS # noqa: E402 from commands import ALL_COMMANDS
from utils.morning_runner import Scheduler # noqa: E402 from utils.morning_runner import Scheduler
if TYPE_CHECKING: if TYPE_CHECKING:
from utils.morning_runner import Scheduler as SchedulerType from utils.morning_runner import Scheduler as SchedulerType
@ -74,9 +74,7 @@ class BotRunner:
morning_time = os.getenv("MORNING_TIME", "07:00") morning_time = os.getenv("MORNING_TIME", "07:00")
self.scheduler = Scheduler(self.bot, morning_time) self.scheduler = Scheduler(self.bot, morning_time)
self.bot._scheduler = self.scheduler self.bot._scheduler = self.scheduler
logger.info( logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name)
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
)
@self.bot.event @self.bot.event
async def on_command_error(ctx: commands.Context, error: Exception) -> None: async def on_command_error(ctx: commands.Context, error: Exception) -> None:
@ -86,9 +84,7 @@ class BotRunner:
# Терминал — детали для разработчика # Терминал — детали для разработчика
cmd_name = ctx.command.name if ctx and ctx.command else "?" cmd_name = ctx.command.name if ctx and ctx.command else "?"
logger.error( logger.error(
"Ошибка команды %s: %s", "Ошибка команды %s: %s", cmd_name, error,
cmd_name,
error,
exc_info=True, exc_info=True,
) )
@ -150,9 +146,7 @@ class BotRunner:
logger.critical( logger.critical(
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True "Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
) )
logger.error( logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
"Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__
)
sys.exit(1) sys.exit(1)
finally: finally:
# Context manager (async with self.bot) закрывает бота автоматически # Context manager (async with self.bot) закрывает бота автоматически
@ -180,7 +174,9 @@ def _validate_config() -> None:
if not (0 <= hour <= 23 and 0 <= minute <= 59): if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError raise ValueError
except (ValueError, AttributeError): except (ValueError, AttributeError):
logger.error("Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time) logger.error(
"Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time
)
sys.exit(1) sys.exit(1)
channel_id = os.getenv("MORNING_CHANNEL_ID") channel_id = os.getenv("MORNING_CHANNEL_ID")

View File

@ -15,13 +15,14 @@ class Cat(commands.Cog):
"""Получить случайного котика""" """Получить случайного котика"""
url = await fetch_cat() url = await fetch_cat()
if url is None: if url is None:
logger.warning( logger.warning("%s: !cat — не удалось получить котика (API вернул None)", ctx.author)
"%s: !cat — не удалось получить котика (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить котика. Попробуйте позже.") await ctx.send("Не удалось получить котика. Попробуйте позже.")
return return
embed = discord.Embed(title="Котик для тебя!", color=discord.Color.orange()) embed = discord.Embed(
title="Котик для тебя!",
color=discord.Color.orange()
)
embed.set_image(url=url) embed.set_image(url=url)
await ctx.send(embed=embed) await ctx.send(embed=embed)
logger.info("%s: !cat выполнена", ctx.author) logger.info("%s: !cat выполнена", ctx.author)

View File

@ -1,3 +1,4 @@
import discord
from discord.ext import commands from discord.ext import commands
@ -27,3 +28,5 @@ class Help(commands.Cog):
message += "\n\n" + "=" * 40 message += "\n\n" + "=" * 40
await ctx.send(message) await ctx.send(message)

View File

@ -1,4 +1,5 @@
import logging import logging
import discord
from discord.ext import commands from discord.ext import commands
from utils.morning_runner import run_morning from utils.morning_runner import run_morning

View File

@ -20,9 +20,7 @@ class News(commands.Cog):
"""Топ-5 свежих статей и новостей по AI с Habr""" """Топ-5 свежих статей и новостей по AI с Habr"""
articles = await fetch_rss(RSS_URL_ARTICLES) articles = await fetch_rss(RSS_URL_ARTICLES)
if articles is None: if articles is None:
logger.warning( logger.warning("%s: !nw — не удалось получить статьи (API вернул None)", ctx.author)
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить новости. Попробуйте позже.") await ctx.send("Не удалось получить новости. Попробуйте позже.")
return return
@ -31,11 +29,9 @@ class News(commands.Cog):
await ctx.send("Новостей пока нет.") await ctx.send("Новостей пока нет.")
return return
articles_text = format_articles( articles_text = format_articles(articles,
articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"Лучшие статьи за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
posts = await fetch_rss(RSS_URL_POSTS) posts = await fetch_rss(RSS_URL_POSTS)
@ -51,20 +47,16 @@ class News(commands.Cog):
) )
if posts is None: if posts is None:
logger.warning( logger.warning("%s: !nw — не удалось получить посты (API вернул None)", ctx.author)
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
)
embed.add_field( embed.add_field(
name="Новости", name="Новости",
value="Не удалось получить новости.", value="Не удалось получить новости.",
inline=False, inline=False,
) )
elif posts: elif posts:
posts_text = format_articles( posts_text = format_articles(posts,
posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr",
"Лучшие новости за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
)
embed.add_field( embed.add_field(
name="Новости", name="Новости",
value=truncate_embed_field("\n".join(posts_text)), value=truncate_embed_field("\n".join(posts_text)),
@ -79,9 +71,4 @@ class News(commands.Cog):
) )
await ctx.send(embed=embed) await ctx.send(embed=embed)
logger.info( logger.info("%s: !nw выполнена (статей: %d, постов: %d)", ctx.author, len(articles), len(posts) if posts else 0)
"%s: !nw выполнена (статей: %d, постов: %d)",
ctx.author,
len(articles),
len(posts) if posts else 0,
)

View File

@ -16,9 +16,7 @@ class Pg(commands.Cog):
"""Прогноз погоды в Магнитогорске""" """Прогноз погоды в Магнитогорске"""
data = await fetch_weather(self.api_url) data = await fetch_weather(self.api_url)
if data is None: if data is None:
logger.warning( logger.warning("%s: !pg — не удалось получить погоду (API вернул None)", ctx.author)
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
)
await ctx.send("Не удалось получить данные о погоде.") await ctx.send("Не удалось получить данные о погоде.")
return return

View File

@ -14,13 +14,7 @@ class Stats(commands.Cog):
guilds = ctx.bot.guilds guilds = ctx.bot.guilds
total_guilds = len(guilds) total_guilds = len(guilds)
total_channels = sum( total_channels = sum(
len( len([ch for ch in guild.channels if not isinstance(ch, discord.CategoryChannel)])
[
ch
for ch in guild.channels
if not isinstance(ch, discord.CategoryChannel)
]
)
for guild in guilds for guild in guilds
) )
total_members = sum(guild.member_count or 0 for guild in guilds) total_members = sum(guild.member_count or 0 for guild in guilds)

View File

@ -1,4 +1,4 @@
discord.py>=2.3.2 discord.py>=2.3.2
python-dotenv>=1.0.0 python-dotenv>=1.0.0
requests>=2.31.0 requests>=2.31.0
defusedxml>=0.7.0 defusedxml>=7.0.0

View File

@ -42,9 +42,7 @@ class TestBotErrorHandling:
import bot import bot
runner = bot.BotRunner() runner = bot.BotRunner()
with patch.object( with patch.object(runner.bot, "start", side_effect=discord.LoginFailure("bad token")):
runner.bot, "start", side_effect=discord.LoginFailure("bad token")
):
with patch.object(runner.bot, "__aenter__", return_value=runner.bot): with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
with patch.object(runner.bot, "__aexit__", return_value=None): with patch.object(runner.bot, "__aexit__", return_value=None):
with patch("sys.exit") as mock_exit: with patch("sys.exit") as mock_exit:
@ -57,11 +55,7 @@ class TestBotErrorHandling:
runner = bot.BotRunner() runner = bot.BotRunner()
mock_response = MagicMock(status=502) mock_response = MagicMock(status=502)
with patch.object( with patch.object(runner.bot, "start", side_effect=discord.HTTPException(mock_response, "Bad Gateway")):
runner.bot,
"start",
side_effect=discord.HTTPException(mock_response, "Bad Gateway"),
):
with patch.object(runner.bot, "__aenter__", return_value=runner.bot): with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
with patch.object(runner.bot, "__aexit__", return_value=None): with patch.object(runner.bot, "__aexit__", return_value=None):
with patch("sys.exit") as mock_exit: with patch("sys.exit") as mock_exit:
@ -81,25 +75,17 @@ class TestBotErrorHandling:
runner = bot.BotRunner() runner = bot.BotRunner()
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют # Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать" assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
assert hasattr(runner, "_on_shutdown_async"), ( assert hasattr(runner, "_on_shutdown_async"), "Метод _on_shutdown_async должен существовать"
"Метод _on_shutdown_async должен существовать"
)
# Проверяем, что signal модуль НЕ импортирован в bot.py # Проверяем, что signal модуль НЕ импортирован в bot.py
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f: with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
content = f.read() content = f.read()
assert "signal.signal" not in content, ( assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
"Не должно быть signal.signal — используется on_shutdown"
)
def test_code_uses_async_bot_pattern(self) -> None: def test_code_uses_async_bot_pattern(self) -> None:
"""Проверка, что bot.py использует async with / asyncio.run.""" """Проверка, что bot.py использует async with / asyncio.run."""
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f: with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
content = f.read() content = f.read()
assert "async with self.bot" in content, ( assert "async with self.bot" in content, "Должен быть паттерн 'async with self.bot'"
"Должен быть паттерн 'async with self.bot'"
)
assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()" assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()"
assert "bot.run(token)" not in content, ( assert "bot.run(token)" not in content, "Не должно быть bot.run(token) — это антипаттерн"
"Не должно быть bot.run(token) — это антипаттерн"
)

View File

@ -176,9 +176,7 @@ class TestPgCommand:
"""Описание погоды на русском должно корректно переводиться.""" """Описание погоды на русском должно корректно переводиться."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data( weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
weatherDesc=[{"value": "Переменная облачность"}]
)
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)

View File

@ -1,10 +1,9 @@
"""Тесты для команды !stats.""" """Тесты для команды !stats."""
import pytest
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from commands.stats import Stats from commands.stats import Stats
class TestStatsCommand: class TestStatsCommand:
"""Тесты Discord-команды stats.""" """Тесты Discord-команды stats."""

View File

@ -1,8 +1,8 @@
"""Тесты для команды !status.""" """Тесты для команды !status."""
import time import time
from unittest.mock import AsyncMock, MagicMock import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from commands.status import Status from commands.status import Status

View File

@ -1,3 +1,5 @@
import json
import pytest
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -61,9 +63,7 @@ class TestFetchCat:
async def test_fetch_cat_json_parse_error(self, mock_get) -> None: async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
"""Ошибка парсинга JSON должна вернуть None.""" """Ошибка парсинга JSON должна вернуть None."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.side_effect = requests.JSONDecodeError( mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
"Expecting value", "", 0
)
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_cat() result = await fetch_cat()
@ -90,9 +90,7 @@ class TestFetchCat:
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None: async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
"""URL со спецсимволами должен вернуться как есть.""" """URL со спецсимволами должен вернуться как есть."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [ mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
{"url": "https://example.com/cat?w=100&h=200"}
]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_cat() result = await fetch_cat()

View File

@ -1,3 +1,5 @@
import pytest
import requests import requests
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from utils.news import fetch_rss from utils.news import fetch_rss
@ -156,14 +158,12 @@ class TestFetchRss:
</item>""" </item>"""
for i in range(15) for i in range(15)
) )
rss_content = ( rss_content = (f"""<?xml version="1.0" encoding="UTF-8"?>
f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
{items} {items}
</channel> </channel>
</rss>""" </rss>""").encode()
).encode()
mock_response = MagicMock() mock_response = MagicMock()
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
@ -409,10 +409,4 @@ class TestFetchRss:
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_rss("https://example.com/rss") result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["tags"] == [ assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]
"AI",
"ML",
"Deep Learning",
"NLP",
"Computer Vision",
]

View File

@ -1,3 +1,4 @@
import pytest import pytest
import requests import requests
from requests.exceptions import ConnectionError, Timeout, SSLError from requests.exceptions import ConnectionError, Timeout, SSLError
@ -107,16 +108,7 @@ class TestFetchOpenMeteo:
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None: async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
"""Кастомные координаты должны быть в URL.""" """Кастомные координаты должны быть в URL."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
"current": {
"temperature": 25,
"apparent_temperature": 22,
"weather_code": 0,
"wind_speed_10m": 3,
"relative_humidity_2m": 50,
"pressure_msl": 1020,
}
}
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo(lat=55.7558, lon=37.6173) result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
@ -135,15 +127,13 @@ class TestFetchOpenMeteo:
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = await fetch_open_meteo() result = await fetch_open_meteo()
assert result is not None assert result is not None
assert result["current_condition"][0]["weatherDesc"] == [ assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
{"value": "Неизвестно"}
]
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None: async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
"""SSLError → вернуть None.""" """SSLError → вернуть None."""
mock_get.side_effect = SSLError("SSL Error") mock_get.side_effect = SSLError("SSL Error")
with patch("utils.pogoda.fetch_open_meteo"): with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
# Внутренний fallback тоже падает, проверяем что возвращается None # Внутренний fallback тоже падает, проверяем что возвращается None
pass pass
result = await fetch_open_meteo() result = await fetch_open_meteo()
@ -184,16 +174,7 @@ class TestFetchOpenMeteo:
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None: async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
"""Retry: первая попытка падает, вторая успешна.""" """Retry: первая попытка падает, вторая успешна."""
success_response = MagicMock() success_response = MagicMock()
success_response.json.return_value = { success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
"current": {
"temperature": 20,
"apparent_temperature": 18,
"weather_code": 1,
"wind_speed_10m": 4,
"relative_humidity_2m": 60,
"pressure_msl": 1015,
}
}
success_response.raise_for_status = MagicMock() success_response.raise_for_status = MagicMock()
mock_get.side_effect = [ConnectionError("fail"), success_response] mock_get.side_effect = [ConnectionError("fail"), success_response]
result = await fetch_open_meteo(max_retries=2) result = await fetch_open_meteo(max_retries=2)
@ -203,11 +184,7 @@ class TestFetchOpenMeteo:
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None: async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
"""Все попытки неудачны → None.""" """Все попытки неудачны → None."""
mock_get.side_effect = [ mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
ConnectionError("fail"),
ConnectionError("fail"),
ConnectionError("fail"),
]
result = await fetch_open_meteo(max_retries=3) result = await fetch_open_meteo(max_retries=3)
assert result is None assert result is None
assert mock_get.call_count == 3 assert mock_get.call_count == 3

View File

@ -9,11 +9,7 @@ class TestTruncateTitle:
"title, max_len, expected", "title, max_len, expected",
[ [
("Короткий заголовок", 60, "Короткий заголовок"), ("Короткий заголовок", 60, "Короткий заголовок"),
( ("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"),
"Заголовок ровно в 60 символов1234567890",
60,
"Заголовок ровно в 60 символов1234567890",
),
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения ("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
("", 60, ""), ("", 60, ""),
("A" * 100, 100, "A" * 100), ("A" * 100, 100, "A" * 100),
@ -64,9 +60,7 @@ class TestParseDate:
def test_parse_date_invalid(self) -> None: def test_parse_date_invalid(self) -> None:
"""Невалидная дата должна вернуть первые 10 символов.""" """Невалидная дата должна вернуть первые 10 символов."""
result = _parse_date("invalid-date-string") result = _parse_date("invalid-date-string")
assert ( assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
result == "invalid.da"
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
class TestFormatArticles: class TestFormatArticles:
@ -99,13 +93,7 @@ class TestFormatArticles:
def test_format_articles_limit_to_5(self) -> None: def test_format_articles_limit_to_5(self) -> None:
"""Больше 5 статей должно быть обрезано до 5.""" """Больше 5 статей должно быть обрезано до 5."""
articles = [ articles = [
{ {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": f"Статья {i}",
"link": f"https://habr.com/{i}",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
for i in range(10) for i in range(10)
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
@ -143,13 +131,7 @@ class TestFormatArticles:
"""Длинный заголовок должен быть обрезан до 60 символов с '...'.""" """Длинный заголовок должен быть обрезан до 60 символов с '...'."""
long_title = "A" * 100 long_title = "A" * 100
articles = [ articles = [
{ {"title": long_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": long_title,
"link": "https://habr.com/1",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert len(result[1].split("\n")[0]) == 63 # 60 + "..." assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
@ -159,13 +141,7 @@ class TestFormatArticles:
"""Короткий заголовок должен остаться без изменений.""" """Короткий заголовок должен остаться без изменений."""
short_title = "Кот" short_title = "Кот"
articles = [ articles = [
{ {"title": short_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": short_title,
"link": "https://habr.com/1",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1].split("\n")[0] == "Кот" assert result[1].split("\n")[0] == "Кот"
@ -174,13 +150,7 @@ class TestFormatArticles:
"""Заголовок ровно 60 символов не должен обрезаться.""" """Заголовок ровно 60 символов не должен обрезаться."""
exact_title = "A" * 60 exact_title = "A" * 60
articles = [ articles = [
{ {"title": exact_title, "link": "https://habr.com/1", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": exact_title,
"link": "https://habr.com/1",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1].split("\n")[0] == exact_title assert result[1].split("\n")[0] == exact_title
@ -203,13 +173,7 @@ class TestFormatArticles:
def test_format_articles_empty_date(self) -> None: def test_format_articles_empty_date(self) -> None:
"""Пустая дата должна быть пустой строкой.""" """Пустая дата должна быть пустой строкой."""
articles = [ articles = [
{ {"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []}
"title": "Статья",
"link": "https://habr.com/1",
"pub_date": "",
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1] == "Статья\n <https://habr.com/1>" assert result[1] == "Статья\n <https://habr.com/1>"
@ -217,13 +181,7 @@ class TestFormatArticles:
def test_format_articles_none_date(self) -> None: def test_format_articles_none_date(self) -> None:
"""None дата должна быть пустой строкой.""" """None дата должна быть пустой строкой."""
articles = [ articles = [
{ {"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []}
"title": "Статья",
"link": "https://habr.com/1",
"pub_date": None,
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1] == "Статья\n <https://habr.com/1>" assert result[1] == "Статья\n <https://habr.com/1>"
@ -231,13 +189,7 @@ class TestFormatArticles:
def test_format_articles_empty_link(self) -> None: def test_format_articles_empty_link(self) -> None:
"""Пустая ссылка должна быть пустой строкой в угловых скобках.""" """Пустая ссылка должна быть пустой строкой в угловых скобках."""
articles = [ articles = [
{ {"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": "Статья",
"link": "",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert result[1].endswith(" <>") assert result[1].endswith(" <>")
@ -259,13 +211,7 @@ class TestFormatArticles:
def test_format_articles_exact_5_articles(self) -> None: def test_format_articles_exact_5_articles(self) -> None:
"""Ровно 5 статей должно быть включено.""" """Ровно 5 статей должно быть включено."""
articles = [ articles = [
{ {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": f"Статья {i}",
"link": f"https://habr.com/{i}",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
for i in range(5) for i in range(5)
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
@ -275,13 +221,7 @@ class TestFormatArticles:
def test_format_articles_6th_article_excluded(self) -> None: def test_format_articles_6th_article_excluded(self) -> None:
"""6-я статья должна быть исключена.""" """6-я статья должна быть исключена."""
articles = [ articles = [
{ {"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
"title": f"Статья {i}",
"link": f"https://habr.com/{i}",
"pub_date": "Mon, 28 May 2026 10:00:00 +0000",
"creator": "",
"tags": [],
}
for i in range(6) for i in range(6)
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")

View File

@ -40,9 +40,7 @@ class TestHelpCommandDiscord:
mock_ctx.bot.commands = [ mock_ctx.bot.commands = [
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"), self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"), self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
self._make_mock_command( self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
"morning", "Утренний дайджест: погода + новости + котик"
),
self._make_mock_command("cat", "Случайный котик"), self._make_mock_command("cat", "Случайный котик"),
] ]
mock_ctx.send = AsyncMock(side_effect=send_side_effect) mock_ctx.send = AsyncMock(side_effect=send_side_effect)
@ -63,7 +61,7 @@ class TestHelpCommandDiscord:
assert cmd in message, f"Команда {cmd} не найдена" assert cmd in message, f"Команда {cmd} не найдена"
# Проверяем разделение тире между командой и описанием # Проверяем разделение тире между командой и описанием
lines = [line.strip() for line in message.split("\n") if "" in line] lines = [l.strip() for l in message.split("\n") if "" in l]
assert len(lines) >= 4 assert len(lines) >= 4

View File

@ -5,6 +5,7 @@
""" """
import pytest import pytest
import requests
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -19,7 +20,6 @@ async def loaded_bot():
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
from commands import ALL_COMMANDS from commands import ALL_COMMANDS
for cog_class in ALL_COMMANDS: for cog_class in ALL_COMMANDS:
await bot.add_cog(cog_class()) await bot.add_cog(cog_class())
return bot return bot
@ -32,7 +32,6 @@ class TestCogLoading:
async def test_all_cogs_load(self, loaded_bot) -> None: async def test_all_cogs_load(self, loaded_bot) -> None:
"""Все ког-модули должны загружаться без ошибок.""" """Все ког-модули должны загружаться без ошибок."""
from commands import ALL_COMMANDS from commands import ALL_COMMANDS
assert len(loaded_bot.cogs) == len(ALL_COMMANDS) assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
@pytest.mark.asyncio @pytest.mark.asyncio
@ -93,7 +92,6 @@ class TestCommandFlow:
bot = commands.Bot(command_prefix="!", intents=intents) bot = commands.Bot(command_prefix="!", intents=intents)
from commands.stats import Stats from commands.stats import Stats
await bot.add_cog(Stats()) await bot.add_cog(Stats())
mock_ctx = MagicMock() mock_ctx = MagicMock()

View File

@ -1,6 +1,5 @@
"""Тесты для utils/logger.py — проверка настройки логирования.""" """Тесты для utils/logger.py — проверка настройки логирования."""
import contextlib
import io import io
import logging import logging
import logging.handlers import logging.handlers
@ -124,6 +123,9 @@ def test_log_message_format() -> None:
assert "test message" in output assert "test message" in output
import contextlib
@contextlib.contextmanager @contextlib.contextmanager
def _isolated_logger(): def _isolated_logger():
"""Создать изолированный root-логгер без handlers из других тестов.""" """Создать изолированный root-логгер без handlers из других тестов."""

View File

@ -1,8 +1,10 @@
"""Тесты для utils/morning_runner.py — Scheduler и run_morning.""" """Тесты для utils/morning_runner.py — Scheduler и run_morning."""
from datetime import datetime import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import discord
import pytest import pytest
from utils.morning_runner import Scheduler, run_morning from utils.morning_runner import Scheduler, run_morning
@ -29,7 +31,7 @@ class TestSchedulerInit:
"""Инициализация должна вызывать _start_scheduler.""" """Инициализация должна вызывать _start_scheduler."""
bot = AsyncMock() bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler") as mock_start: with patch.object(Scheduler, "_start_scheduler") as mock_start:
Scheduler(bot) scheduler = Scheduler(bot)
mock_start.assert_called_once() mock_start.assert_called_once()
@ -79,12 +81,8 @@ class TestSchedulerStartStop:
def test_stop_stops_task(self) -> None: def test_stop_stops_task(self) -> None:
"""stop() должен остановить task.""" """stop() должен остановить task."""
bot = AsyncMock() bot = AsyncMock()
with patch.object(Scheduler, "_start_scheduler"): with patch("asyncio.create_task"):
scheduler = Scheduler(bot) scheduler = Scheduler(bot)
scheduler._running = True
mock_task = MagicMock()
mock_task.done.return_value = False
scheduler._task = mock_task
scheduler.stop() scheduler.stop()
assert scheduler._running is False assert scheduler._running is False
@ -111,50 +109,16 @@ class TestRunMorning:
weather_data = { weather_data = {
"current_condition": [ "current_condition": [
{ {"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
"temp_C": "20",
"FeelsLikeC": "22",
"weatherDesc": [{"value": "Clear"}],
"humidity": "50",
"windspeedKmph": "10",
"pressure": "1013",
}
] ]
} }
articles = [ articles = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
{ posts = [{"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": [],
}
]
posts = [
{
"title": "Test",
"link": "http://test.com",
"pub_date": "Mon, 01 Jan 2026 00:00:00 GMT",
"creator": "",
"tags": [],
}
]
with ( with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
patch( patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[articles, posts])), \
"utils.morning_runner.fetch_weather", patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value="http://cat.jpg")), \
new=AsyncMock(return_value=weather_data), patch("utils.morning_runner.discord.Embed") as mock_embed:
),
patch(
"utils.morning_runner.fetch_rss",
new=AsyncMock(side_effect=[articles, posts]),
),
patch(
"utils.morning_runner.fetch_cat",
new=AsyncMock(return_value="http://cat.jpg"),
),
patch("utils.morning_runner.discord.Embed"),
):
await run_morning(bot, channel) await run_morning(bot, channel)
channel.send.assert_called_once() channel.send.assert_called_once()
@ -176,28 +140,25 @@ class TestRunMorningWithFallback:
channel.permissions_for.return_value.send_messages = True channel.permissions_for.return_value.send_messages = True
# Все API возвращают None/пусто # Все API возвращают None/пусто
with ( with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \
patch( patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)), \
"utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None) patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), \
), patch("utils.morning_runner.discord.Embed") as mock_embed_class:
patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)),
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
patch("utils.morning_runner.discord.Embed") as mock_embed_class,
):
embed_mock = AsyncMock() embed_mock = AsyncMock()
mock_embed_class.return_value = embed_mock mock_embed_class.return_value = embed_mock
await run_morning(bot, channel) await run_morning(bot, channel)
# Убедимся, что send был вызван # Убедимся, что send был вызван
channel.send.assert_called_once() channel.send.assert_called_once()
call_args = channel.send.call_args[1] call_args = channel.send.call_args[1]
assert "embed" in call_args assert "embed" in call_args
# Проверяем, что description содержит fallback сообщение # Проверяем, что description содержит fallback сообщение
embed_description = call_args["embed"].description embed_description = call_args["embed"].description
assert "Не удалось получить данные из внешних источников" in embed_description assert "Не удалось получить данные из внешних источников" in embed_description
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_morning_only_weather_data(self) -> None: async def test_run_morning_only_weather_data(self) -> None:
"""run_morning должен корректно обрабатывать только погоду без новостей.""" """run_morning должен корректно обрабатывать только погоду без новостей."""
@ -209,37 +170,20 @@ class TestRunMorningWithFallback:
weather_data = { weather_data = {
"current_condition": [ "current_condition": [
{ {"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"}
"temp_C": "20",
"FeelsLikeC": "22",
"weatherDesc": [{"value": "Clear"}],
"humidity": "50",
"windspeedKmph": "10",
"pressure": "1013",
}
] ]
} }
with ( with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
patch( patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None])), \
"utils.morning_runner.fetch_weather", patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)):
new=AsyncMock(return_value=weather_data),
),
patch(
"utils.morning_runner.fetch_rss",
new=AsyncMock(side_effect=[None, None]),
),
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)),
):
await run_morning(bot, channel) await run_morning(bot, channel)
channel.send.assert_called_once() channel.send.assert_called_once()
call_args = channel.send.call_args[1] call_args = channel.send.call_args[1]
assert "embed" in call_args assert "embed" in call_args
# Проверяем, что в embed есть только погода и нет fallback сообщения # Проверяем, что в embed есть только погода и нет fallback сообщения
embed_description = call_args["embed"].description embed_description = call_args["embed"].description
assert "Погода в Магнитогорске" in embed_description assert "Погода в Магнитогорске" in embed_description
assert ( assert "Не удалось получить данные из внешних источников" not in embed_description
"Не удалось получить данные из внешних источников" not in embed_description
)

View File

@ -1,10 +1,5 @@
import pytest import pytest
from utils.pogoda import ( from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
translate_weather,
pressure_to_mmhg,
wmo_to_russian,
format_weather_data_for_console,
)
class TestFormatWeatherDataForConsole: class TestFormatWeatherDataForConsole:
@ -13,16 +8,14 @@ class TestFormatWeatherDataForConsole:
def test_format_valid_data(self) -> None: def test_format_valid_data(self) -> None:
"""Полные данные должны быть отформатированы корректно.""" """Полные данные должны быть отформатированы корректно."""
data = { data = {
"current_condition": [ "current_condition": [{
{ "temp_C": "25",
"temp_C": "25", "FeelsLikeC": "26",
"FeelsLikeC": "26", "weatherDesc": [{"value": "Clear"}],
"weatherDesc": [{"value": "Clear"}], "humidity": "45",
"humidity": "45", "windspeedKmph": "10",
"windspeedKmph": "10", "pressure": "1013",
"pressure": "1013", }]
}
]
} }
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
@ -37,7 +30,9 @@ class TestFormatWeatherDataForConsole:
def test_format_empty_data(self) -> None: def test_format_empty_data(self) -> None:
"""Пустые данные должны возвращать None.""" """Пустые данные должны возвращать None."""
data = {"current_condition": [{}]} data = {
"current_condition": [{}]
}
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
@ -54,16 +49,14 @@ class TestFormatWeatherDataForConsole:
def test_format_with_dashes(self) -> None: def test_format_with_dashes(self) -> None:
"""Неизвестные значения должны отображаться как ''.""" """Неизвестные значения должны отображаться как ''."""
data = { data = {
"current_condition": [ "current_condition": [{
{ "temp_C": "",
"temp_C": "", "FeelsLikeC": "",
"FeelsLikeC": "", "weatherDesc": [{"value": ""}],
"weatherDesc": [{"value": ""}], "humidity": "",
"humidity": "", "windspeedKmph": "",
"windspeedKmph": "", "pressure": "",
"pressure": "", }]
}
]
} }
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
@ -78,16 +71,14 @@ class TestFormatWeatherDataForConsole:
def test_format_wind_conversion(self) -> None: def test_format_wind_conversion(self) -> None:
"""Проверка конвертации ветра из км/ч в м/с.""" """Проверка конвертации ветра из км/ч в м/с."""
data = { data = {
"current_condition": [ "current_condition": [{
{ "temp_C": "20",
"temp_C": "20", "FeelsLikeC": "19",
"FeelsLikeC": "19", "weatherDesc": [{"value": "Cloudy"}],
"weatherDesc": [{"value": "Cloudy"}], "humidity": "60",
"humidity": "60", "windspeedKmph": "36",
"windspeedKmph": "36", "pressure": "1000",
"pressure": "1000", }]
}
]
} }
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
@ -97,16 +88,14 @@ class TestFormatWeatherDataForConsole:
def test_format_negative_temperature(self) -> None: def test_format_negative_temperature(self) -> None:
"""Отрицательная температура должна отображаться корректно.""" """Отрицательная температура должна отображаться корректно."""
data = { data = {
"current_condition": [ "current_condition": [{
{ "temp_C": "-5",
"temp_C": "-5", "FeelsLikeC": "-10",
"FeelsLikeC": "-10", "weatherDesc": [{"value": "Snow"}],
"weatherDesc": [{"value": "Snow"}], "humidity": "80",
"humidity": "80", "windspeedKmph": "20",
"windspeedKmph": "20", "pressure": "980",
"pressure": "980", }]
}
]
} }
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
@ -116,6 +105,7 @@ class TestFormatWeatherDataForConsole:
class TestTranslateWeather: class TestTranslateWeather:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"english, expected", "english, expected",
[ [
@ -131,10 +121,7 @@ class TestTranslateWeather:
("Light rain", "Небольшой дождь"), ("Light rain", "Небольшой дождь"),
("Moderate rain", "Умеренный дождь"), ("Moderate rain", "Умеренный дождь"),
("Heavy rain", "Сильный дождь"), ("Heavy rain", "Сильный дождь"),
( ("Moderate or heavy rain at times", "Дождь"), # длинный ключ проверяется первым
"Moderate or heavy rain at times",
"Дождь",
), # длинный ключ проверяется первым
("Heavy rain at times", "Сильный дождь"), ("Heavy rain at times", "Сильный дождь"),
("Light snow", "Небольшой снег"), ("Light snow", "Небольшой снег"),
("Moderate snow", "Умеренный снег"), ("Moderate snow", "Умеренный снег"),

View File

@ -30,7 +30,6 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class MorningData: class MorningData:
"""Собранные данные для утреннего дайджеста.""" """Собранные данные для утреннего дайджеста."""
weather: Optional[dict] weather: Optional[dict]
articles: Optional[list] articles: Optional[list]
posts: Optional[list] posts: Optional[list]
@ -82,11 +81,9 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
if data.articles is not None: if data.articles is not None:
if data.articles: if data.articles:
has_real_data = True has_real_data = True
lines = format_articles( lines = format_articles(data.articles,
data.articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"Лучшие статьи за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
)
description_lines.append("\n".join(lines)) description_lines.append("\n".join(lines))
else: else:
description_lines.append("Новостей пока нет.") description_lines.append("Новостей пока нет.")
@ -99,11 +96,9 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
if data.posts is not None: if data.posts is not None:
if data.posts: if data.posts:
has_real_data = True has_real_data = True
lines = format_articles( lines = format_articles(data.posts,
data.posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr",
"Лучшие новости за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
)
description_lines.append("\n".join(lines)) description_lines.append("\n".join(lines))
else: else:
description_lines.append("Новостей пока нет.") description_lines.append("Новостей пока нет.")
@ -114,7 +109,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
if not has_real_data: if not has_real_data:
description_lines = [ description_lines = [
"Не удалось получить данные из внешних источников.", "Не удалось получить данные из внешних источников.",
"Проверьте доступность API и повторите попытку позже.", "Проверьте доступность API и повторите попытку позже."
] ]
description = "\n".join(description_lines) description = "\n".join(description_lines)
@ -149,9 +144,7 @@ class Scheduler:
try: try:
self._target_channel_id = int(channel_id_str) self._target_channel_id = int(channel_id_str)
except ValueError: except ValueError:
logger.warning( logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str)
"Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str
)
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._running = False self._running = False
self._start_scheduler() self._start_scheduler()
@ -236,14 +229,10 @@ class Scheduler:
await run_morning(self.bot, channel) await run_morning(self.bot, channel)
return return
except Exception as e: except Exception as e:
logger.error( logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
"Ошибка отправки в канал %s: %s", self._target_channel_id, e
)
return return
else: else:
logger.warning( logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id)
"Канал с ID %s не текстовый — fallback", self._target_channel_id
)
# Fallback: первый канал с правами send_messages # Fallback: первый канал с правами send_messages
sent = False sent = False

View File

@ -9,12 +9,8 @@ from utils.rate_limiter import habr_rss_limiter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RSS_URL_ARTICLES = ( RSS_URL_ARTICLES = "https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru"
"https://habr.com/ru/rss/hubs/artificial_intelligence/articles/top/daily/?fl=ru" RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
)
RSS_URL_POSTS = (
"https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/daily/?fl=ru"
)
_session = requests.Session() _session = requests.Session()
@ -66,15 +62,13 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
creator = creator_el.text if creator_el is not None else "" creator = creator_el.text if creator_el is not None else ""
tags = [cat.text for cat in categories if cat.text] if categories else [] tags = [cat.text for cat in categories if cat.text] if categories else []
articles.append( articles.append({
{ "title": title,
"title": title, "link": link,
"link": link, "pub_date": pub_date,
"pub_date": pub_date, "creator": creator,
"creator": creator, "tags": tags,
"tags": tags, })
}
)
return articles[:10] return articles[:10]
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error("Ошибка при получении RSS (%s): %s", url, e) logger.error("Ошибка при получении RSS (%s): %s", url, e)
@ -104,14 +98,14 @@ def truncate_embed_text(text: str, max_len: int = 4096) -> str:
"""Обрезать текст для embed.description (лимит Discord: 4096 символов).""" """Обрезать текст для embed.description (лимит Discord: 4096 символов)."""
if len(text) <= max_len: if len(text) <= max_len:
return text return text
return text[: max_len - 3] + "..." return text[:max_len - 3] + "..."
def truncate_embed_field(text: str, max_len: int = 1024) -> str: def truncate_embed_field(text: str, max_len: int = 1024) -> str:
"""Обрезать текст для embed field value (лимит Discord: 1024 символа).""" """Обрезать текст для embed field value (лимит Discord: 1024 символа)."""
if len(text) <= max_len: if len(text) <= max_len:
return text return text
return text[: max_len - 3] + "..." return text[:max_len - 3] + "..."
def format_articles(articles: list[dict], title: str, link: str) -> list[str]: def format_articles(articles: list[dict], title: str, link: str) -> list[str]:

View File

@ -14,9 +14,7 @@ API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
_session = requests.Session() _session = requests.Session()
async def fetch_weather( async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
api_url: str, timeout: int = 10, max_retries: int = 3
) -> Optional[dict]:
"""Получить данные о погоде с retry.""" """Получить данные о погоде с retry."""
await weather_limiter.acquire() await weather_limiter.acquire()
for attempt in range(max_retries): for attempt in range(max_retries):
@ -26,10 +24,8 @@ async def fetch_weather(
return response.json() return response.json()
except (SSLError, ConnectionError, Timeout): except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = 2**attempt delay = 2 ** attempt
logger.warning( logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
)
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
break break
@ -41,9 +37,7 @@ async def fetch_weather(
return await fetch_open_meteo() return await fetch_open_meteo()
async def fetch_open_meteo( async def fetch_open_meteo(lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3
) -> Optional[dict]:
"""Fallback на Open-Meteo API.""" """Fallback на Open-Meteo API."""
await open_meteo_limiter.acquire() await open_meteo_limiter.acquire()
url = ( url = (
@ -62,23 +56,19 @@ async def fetch_open_meteo(
weather_code = current.get("weather_code", None) weather_code = current.get("weather_code", None)
desc = wmo_to_russian(weather_code) desc = wmo_to_russian(weather_code)
return { return {
"current_condition": [ "current_condition": [{
{ "temp_C": current.get("temperature", ""),
"temp_C": current.get("temperature", ""), "FeelsLikeC": current.get("apparent_temperature", ""),
"FeelsLikeC": current.get("apparent_temperature", ""), "weatherDesc": [{"value": desc}],
"weatherDesc": [{"value": desc}], "humidity": current.get("relative_humidity_2m", ""),
"humidity": current.get("relative_humidity_2m", ""), "windspeedKmph": current.get("wind_speed_10m", ""),
"windspeedKmph": current.get("wind_speed_10m", ""), "pressure": current.get("pressure_msl", ""),
"pressure": current.get("pressure_msl", ""), }]
}
]
} }
except (SSLError, ConnectionError, Timeout): except (SSLError, ConnectionError, Timeout):
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = 2**attempt delay = 2 ** attempt
logger.warning( logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
)
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
break break
@ -94,33 +84,18 @@ def wmo_to_russian(code: Optional[int]) -> str:
"""Перевод WMO weather code в русский.""" """Перевод WMO weather code в русский."""
mapping = { mapping = {
0: "Ясно", 0: "Ясно",
1: "Ясно", 1: "Ясно", 2: "Переменная облачность",
2: "Переменная облачность",
3: "Пасмурно", 3: "Пасмурно",
45: "Туман", 45: "Туман", 48: "Туман",
48: "Туман", 51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
51: "Лёгкая морось", 56: "Ледяная морось", 57: "Сильная ледяная морось",
53: "Морось", 61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
55: "Сильная морось", 66: "Ледяной дождь", 67: "Сильный ледяной дождь",
56: "Ледяная морось", 71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
57: "Сильная ледяная морось",
61: "Небольшой дождь",
63: "Дождь",
65: "Сильный дождь",
66: "Ледяной дождь",
67: "Сильный ледяной дождь",
71: "Небольшой снег",
73: "Снег",
75: "Сильный снег",
77: "Снежная крупа", 77: "Снежная крупа",
80: "Небольшой ливень", 80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
81: "Ливень", 85: "Снежный ливень", 86: "Сильный снежный ливень",
82: "Сильный ливень", 95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
85: "Снежный ливень",
86: "Сильный снежный ливень",
95: "Гроза",
96: "Гроза с градом",
99: "Сильная гроза с градом",
} }
return mapping.get(code, "Неизвестно") return mapping.get(code, "Неизвестно")
@ -179,7 +154,7 @@ def translate_weather(en: Optional[str]) -> str:
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]: def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
""" """
Форматировать погодные данные для консольного вывода. Форматировать погодные данные для консольного вывода.
:param data: Ответ от API (dict) :param data: Ответ от API (dict)
:return: Строки с отформатированной погодой :return: Строки с отформатированной погодой
""" """
@ -191,12 +166,10 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
current = current_condition_list[0] current = current_condition_list[0]
if not current: if not current:
return None return None
temp = current.get("temp_C", "") temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "") feels_like = current.get("FeelsLikeC", "")
description = translate_weather( description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", ""))
current.get("weatherDesc", [{}])[0].get("value", "")
)
humidity = current.get("humidity", "") humidity = current.get("humidity", "")
wind_kmh = current.get("windspeedKmph", "") wind_kmh = current.get("windspeedKmph", "")
try: try: