Compare commits
14 Commits
dda5753f8c
...
b98c696498
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b98c696498 | ||
|
|
7ade3147cf | ||
|
|
d20957ac5f | ||
|
|
4b61909ebc | ||
|
|
4e816d5998 | ||
|
|
b21237b042 | ||
|
|
0fa467d40e | ||
|
|
a1b72ecbcf | ||
|
|
00ec2eac0e | ||
|
|
ab0039661a | ||
|
|
5a2ff483a1 | ||
|
|
cd25d63f5b | ||
|
|
be0aa5211a | ||
|
|
02b6b10ea3 |
28
bot.py
28
bot.py
@ -11,13 +11,13 @@ from typing import TYPE_CHECKING
|
||||
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import CommandNotFound
|
||||
from dotenv import load_dotenv
|
||||
import discord # noqa: E402
|
||||
from discord.ext import commands # noqa: E402
|
||||
from discord.ext.commands import CommandNotFound # noqa: E402
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
from commands import ALL_COMMANDS
|
||||
from utils.morning_runner import Scheduler
|
||||
from commands import ALL_COMMANDS # noqa: E402
|
||||
from utils.morning_runner import Scheduler # noqa: E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from utils.morning_runner import Scheduler as SchedulerType
|
||||
@ -74,7 +74,9 @@ class BotRunner:
|
||||
morning_time = os.getenv("MORNING_TIME", "07:00")
|
||||
self.scheduler = Scheduler(self.bot, morning_time)
|
||||
self.bot._scheduler = self.scheduler
|
||||
logger.info("Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name)
|
||||
logger.info(
|
||||
"Планировщик запущен (время: %s, сервер: %s)", morning_time, guild.name
|
||||
)
|
||||
|
||||
@self.bot.event
|
||||
async def on_command_error(ctx: commands.Context, error: Exception) -> None:
|
||||
@ -84,7 +86,9 @@ class BotRunner:
|
||||
# Терминал — детали для разработчика
|
||||
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
||||
logger.error(
|
||||
"Ошибка команды %s: %s", cmd_name, error,
|
||||
"Ошибка команды %s: %s",
|
||||
cmd_name,
|
||||
error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@ -146,7 +150,9 @@ class BotRunner:
|
||||
logger.critical(
|
||||
"Непредвиденная ошибка при запуске бота: %s", e, exc_info=True
|
||||
)
|
||||
logger.error("Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__)
|
||||
logger.error(
|
||||
"Критическая ошибка при запуске. Код ошибки: %s", type(e).__name__
|
||||
)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
# Context manager (async with self.bot) закрывает бота автоматически
|
||||
@ -174,9 +180,7 @@ def _validate_config() -> None:
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
raise ValueError
|
||||
except (ValueError, AttributeError):
|
||||
logger.error(
|
||||
"Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time
|
||||
)
|
||||
logger.error("Неверный формат MORNING_TIME: %s (ожидается ЧЧ:ММ)", morning_time)
|
||||
sys.exit(1)
|
||||
|
||||
channel_id = os.getenv("MORNING_CHANNEL_ID")
|
||||
|
||||
@ -15,14 +15,13 @@ class Cat(commands.Cog):
|
||||
"""Получить случайного котика"""
|
||||
url = await fetch_cat()
|
||||
if url is None:
|
||||
logger.warning("%s: !cat — не удалось получить котика (API вернул None)", ctx.author)
|
||||
logger.warning(
|
||||
"%s: !cat — не удалось получить котика (API вернул None)", ctx.author
|
||||
)
|
||||
await ctx.send("Не удалось получить котика. Попробуйте позже.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Котик для тебя!",
|
||||
color=discord.Color.orange()
|
||||
)
|
||||
embed = discord.Embed(title="Котик для тебя!", color=discord.Color.orange())
|
||||
embed.set_image(url=url)
|
||||
await ctx.send(embed=embed)
|
||||
logger.info("%s: !cat выполнена", ctx.author)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
@ -28,5 +27,3 @@ class Help(commands.Cog):
|
||||
message += "\n\n" + "=" * 40
|
||||
|
||||
await ctx.send(message)
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from utils.morning_runner import run_morning
|
||||
|
||||
@ -20,7 +20,9 @@ class News(commands.Cog):
|
||||
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
||||
if articles is None:
|
||||
logger.warning("%s: !nw — не удалось получить статьи (API вернул None)", ctx.author)
|
||||
logger.warning(
|
||||
"%s: !nw — не удалось получить статьи (API вернул None)", ctx.author
|
||||
)
|
||||
await ctx.send("Не удалось получить новости. Попробуйте позже.")
|
||||
return
|
||||
|
||||
@ -29,9 +31,11 @@ class News(commands.Cog):
|
||||
await ctx.send("Новостей пока нет.")
|
||||
return
|
||||
|
||||
articles_text = format_articles(articles,
|
||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/")
|
||||
articles_text = format_articles(
|
||||
articles,
|
||||
"Лучшие статьи за сутки / Искусственный интеллект / Хабr",
|
||||
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/",
|
||||
)
|
||||
|
||||
posts = await fetch_rss(RSS_URL_POSTS)
|
||||
|
||||
@ -47,16 +51,20 @@ class News(commands.Cog):
|
||||
)
|
||||
|
||||
if posts is None:
|
||||
logger.warning("%s: !nw — не удалось получить посты (API вернул None)", ctx.author)
|
||||
logger.warning(
|
||||
"%s: !nw — не удалось получить посты (API вернул None)", ctx.author
|
||||
)
|
||||
embed.add_field(
|
||||
name="Новости",
|
||||
value="Не удалось получить новости.",
|
||||
inline=False,
|
||||
)
|
||||
elif posts:
|
||||
posts_text = format_articles(posts,
|
||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/")
|
||||
posts_text = format_articles(
|
||||
posts,
|
||||
"Лучшие новости за сутки / Искусственный интеллект / Хабr",
|
||||
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/",
|
||||
)
|
||||
embed.add_field(
|
||||
name="Новости",
|
||||
value=truncate_embed_field("\n".join(posts_text)),
|
||||
@ -71,4 +79,9 @@ class News(commands.Cog):
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
logger.info("%s: !nw выполнена (статей: %d, постов: %d)", ctx.author, len(articles), len(posts) if posts else 0)
|
||||
logger.info(
|
||||
"%s: !nw выполнена (статей: %d, постов: %d)",
|
||||
ctx.author,
|
||||
len(articles),
|
||||
len(posts) if posts else 0,
|
||||
)
|
||||
|
||||
@ -16,7 +16,9 @@ class Pg(commands.Cog):
|
||||
"""Прогноз погоды в Магнитогорске"""
|
||||
data = await fetch_weather(self.api_url)
|
||||
if data is None:
|
||||
logger.warning("%s: !pg — не удалось получить погоду (API вернул None)", ctx.author)
|
||||
logger.warning(
|
||||
"%s: !pg — не удалось получить погоду (API вернул None)", ctx.author
|
||||
)
|
||||
await ctx.send("Не удалось получить данные о погоде.")
|
||||
return
|
||||
|
||||
|
||||
@ -14,7 +14,13 @@ class Stats(commands.Cog):
|
||||
guilds = ctx.bot.guilds
|
||||
total_guilds = len(guilds)
|
||||
total_channels = sum(
|
||||
len([ch for ch in guild.channels if not isinstance(ch, discord.CategoryChannel)])
|
||||
len(
|
||||
[
|
||||
ch
|
||||
for ch in guild.channels
|
||||
if not isinstance(ch, discord.CategoryChannel)
|
||||
]
|
||||
)
|
||||
for guild in guilds
|
||||
)
|
||||
total_members = sum(guild.member_count or 0 for guild in guilds)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
discord.py>=2.3.2
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
defusedxml>=7.0.0
|
||||
defusedxml>=0.7.0
|
||||
|
||||
@ -42,7 +42,9 @@ class TestBotErrorHandling:
|
||||
import bot
|
||||
|
||||
runner = bot.BotRunner()
|
||||
with patch.object(runner.bot, "start", side_effect=discord.LoginFailure("bad token")):
|
||||
with patch.object(
|
||||
runner.bot, "start", side_effect=discord.LoginFailure("bad token")
|
||||
):
|
||||
with patch.object(runner.bot, "__aenter__", return_value=runner.bot):
|
||||
with patch.object(runner.bot, "__aexit__", return_value=None):
|
||||
with patch("sys.exit") as mock_exit:
|
||||
@ -55,7 +57,11 @@ class TestBotErrorHandling:
|
||||
|
||||
runner = bot.BotRunner()
|
||||
mock_response = MagicMock(status=502)
|
||||
with patch.object(runner.bot, "start", side_effect=discord.HTTPException(mock_response, "Bad Gateway")):
|
||||
with patch.object(
|
||||
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, "__aexit__", return_value=None):
|
||||
with patch("sys.exit") as mock_exit:
|
||||
@ -75,17 +81,25 @@ class TestBotErrorHandling:
|
||||
runner = bot.BotRunner()
|
||||
# Проверяем, что _on_shutdown и _on_shutdown_async методы существуют
|
||||
assert hasattr(runner, "_on_shutdown"), "Метод _on_shutdown должен существовать"
|
||||
assert hasattr(runner, "_on_shutdown_async"), "Метод _on_shutdown_async должен существовать"
|
||||
assert hasattr(runner, "_on_shutdown_async"), (
|
||||
"Метод _on_shutdown_async должен существовать"
|
||||
)
|
||||
# Проверяем, что signal модуль НЕ импортирован в bot.py
|
||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
|
||||
assert "signal.signal" not in content, (
|
||||
"Не должно быть signal.signal — используется on_shutdown"
|
||||
)
|
||||
|
||||
def test_code_uses_async_bot_pattern(self) -> None:
|
||||
"""Проверка, что bot.py использует async with / asyncio.run."""
|
||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
assert "async with self.bot" in content, "Должен быть паттерн 'async with self.bot'"
|
||||
assert "async with self.bot" in content, (
|
||||
"Должен быть паттерн 'async with self.bot'"
|
||||
)
|
||||
assert "asyncio.run(main())" in content, "Должен быть вызов asyncio.run()"
|
||||
assert "bot.run(token)" not in content, "Не должно быть bot.run(token) — это антипаттерн"
|
||||
assert "bot.run(token)" not in content, (
|
||||
"Не должно быть bot.run(token) — это антипаттерн"
|
||||
)
|
||||
|
||||
@ -176,7 +176,9 @@ class TestPgCommand:
|
||||
"""Описание погоды на русском должно корректно переводиться."""
|
||||
cog = self._make_cog()
|
||||
ctx = self._make_ctx()
|
||||
weather = self._make_weather_data(weatherDesc=[{"value": "Переменная облачность"}])
|
||||
weather = self._make_weather_data(
|
||||
weatherDesc=[{"value": "Переменная облачность"}]
|
||||
)
|
||||
|
||||
with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
|
||||
await cog.pg.callback(cog, ctx)
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
"""Тесты для команды !stats."""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from commands.stats import Stats
|
||||
|
||||
|
||||
class TestStatsCommand:
|
||||
"""Тесты Discord-команды stats."""
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""Тесты для команды !status."""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from commands.status import Status
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import json
|
||||
import pytest
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
from unittest.mock import patch, MagicMock
|
||||
@ -63,7 +61,9 @@ class TestFetchCat:
|
||||
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
|
||||
"""Ошибка парсинга JSON должна вернуть None."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
|
||||
mock_response.json.side_effect = requests.JSONDecodeError(
|
||||
"Expecting value", "", 0
|
||||
)
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_cat()
|
||||
@ -90,7 +90,9 @@ class TestFetchCat:
|
||||
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
|
||||
"""URL со спецсимволами должен вернуться как есть."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
|
||||
mock_response.json.return_value = [
|
||||
{"url": "https://example.com/cat?w=100&h=200"}
|
||||
]
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_cat()
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import patch, MagicMock
|
||||
from utils.news import fetch_rss
|
||||
@ -158,12 +156,14 @@ class TestFetchRss:
|
||||
</item>"""
|
||||
for i in range(15)
|
||||
)
|
||||
rss_content = (f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
rss_content = (
|
||||
f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
{items}
|
||||
</channel>
|
||||
</rss>""").encode()
|
||||
</rss>"""
|
||||
).encode()
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = rss_content
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
@ -409,4 +409,10 @@ class TestFetchRss:
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_rss("https://example.com/rss")
|
||||
assert result is not None
|
||||
assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]
|
||||
assert result[0]["tags"] == [
|
||||
"AI",
|
||||
"ML",
|
||||
"Deep Learning",
|
||||
"NLP",
|
||||
"Computer Vision",
|
||||
]
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||
@ -108,7 +107,16 @@ class TestFetchOpenMeteo:
|
||||
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
|
||||
"""Кастомные координаты должны быть в URL."""
|
||||
mock_response = MagicMock()
|
||||
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}}
|
||||
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,
|
||||
}
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
|
||||
@ -127,13 +135,15 @@ class TestFetchOpenMeteo:
|
||||
mock_get.return_value = mock_response
|
||||
result = await fetch_open_meteo()
|
||||
assert result is not None
|
||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
||||
assert result["current_condition"][0]["weatherDesc"] == [
|
||||
{"value": "Неизвестно"}
|
||||
]
|
||||
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
|
||||
"""SSLError → вернуть None."""
|
||||
mock_get.side_effect = SSLError("SSL Error")
|
||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||
with patch("utils.pogoda.fetch_open_meteo"):
|
||||
# Внутренний fallback тоже падает, проверяем что возвращается None
|
||||
pass
|
||||
result = await fetch_open_meteo()
|
||||
@ -174,7 +184,16 @@ class TestFetchOpenMeteo:
|
||||
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
|
||||
"""Retry: первая попытка падает, вторая успешна."""
|
||||
success_response = MagicMock()
|
||||
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}}
|
||||
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,
|
||||
}
|
||||
}
|
||||
success_response.raise_for_status = MagicMock()
|
||||
mock_get.side_effect = [ConnectionError("fail"), success_response]
|
||||
result = await fetch_open_meteo(max_retries=2)
|
||||
@ -184,7 +203,11 @@ class TestFetchOpenMeteo:
|
||||
@patch("utils.pogoda._session.get")
|
||||
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
|
||||
"""Все попытки неудачны → None."""
|
||||
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
|
||||
mock_get.side_effect = [
|
||||
ConnectionError("fail"),
|
||||
ConnectionError("fail"),
|
||||
ConnectionError("fail"),
|
||||
]
|
||||
result = await fetch_open_meteo(max_retries=3)
|
||||
assert result is None
|
||||
assert mock_get.call_count == 3
|
||||
|
||||
@ -9,7 +9,11 @@ class TestTruncateTitle:
|
||||
"title, max_len, expected",
|
||||
[
|
||||
("Короткий заголовок", 60, "Короткий заголовок"),
|
||||
("Заголовок ровно в 60 символов1234567890", 60, "Заголовок ровно в 60 символов1234567890"),
|
||||
(
|
||||
"Заголовок ровно в 60 символов1234567890",
|
||||
60,
|
||||
"Заголовок ровно в 60 символов1234567890",
|
||||
),
|
||||
("A" * 80, 60, "A" * 60 + "..."), # ASCII для надёжного сравнения
|
||||
("", 60, ""),
|
||||
("A" * 100, 100, "A" * 100),
|
||||
@ -60,7 +64,9 @@ class TestParseDate:
|
||||
def test_parse_date_invalid(self) -> None:
|
||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
||||
result = _parse_date("invalid-date-string")
|
||||
assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
||||
assert (
|
||||
result == "invalid.da"
|
||||
) # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
||||
|
||||
|
||||
class TestFormatArticles:
|
||||
@ -93,7 +99,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_limit_to_5(self) -> None:
|
||||
"""Больше 5 статей должно быть обрезано до 5."""
|
||||
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)
|
||||
]
|
||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||
@ -131,7 +143,13 @@ class TestFormatArticles:
|
||||
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
||||
long_title = "A" * 100
|
||||
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")
|
||||
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
||||
@ -141,7 +159,13 @@ class TestFormatArticles:
|
||||
"""Короткий заголовок должен остаться без изменений."""
|
||||
short_title = "Кот"
|
||||
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")
|
||||
assert result[1].split("\n")[0] == "Кот"
|
||||
@ -150,7 +174,13 @@ class TestFormatArticles:
|
||||
"""Заголовок ровно 60 символов не должен обрезаться."""
|
||||
exact_title = "A" * 60
|
||||
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")
|
||||
assert result[1].split("\n")[0] == exact_title
|
||||
@ -173,7 +203,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_empty_date(self) -> None:
|
||||
"""Пустая дата должна быть пустой строкой."""
|
||||
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")
|
||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||
@ -181,7 +217,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_none_date(self) -> None:
|
||||
"""None дата должна быть пустой строкой."""
|
||||
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")
|
||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||
@ -189,7 +231,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_empty_link(self) -> None:
|
||||
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
||||
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")
|
||||
assert result[1].endswith(" <>")
|
||||
@ -211,7 +259,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_exact_5_articles(self) -> None:
|
||||
"""Ровно 5 статей должно быть включено."""
|
||||
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)
|
||||
]
|
||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||
@ -221,7 +275,13 @@ class TestFormatArticles:
|
||||
def test_format_articles_6th_article_excluded(self) -> None:
|
||||
"""6-я статья должна быть исключена."""
|
||||
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)
|
||||
]
|
||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||
|
||||
@ -40,7 +40,9 @@ class TestHelpCommandDiscord:
|
||||
mock_ctx.bot.commands = [
|
||||
self._make_mock_command("pg", "Прогноз погоды в Магнитогорске"),
|
||||
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
||||
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
|
||||
self._make_mock_command(
|
||||
"morning", "Утренний дайджест: погода + новости + котик"
|
||||
),
|
||||
self._make_mock_command("cat", "Случайный котик"),
|
||||
]
|
||||
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
||||
@ -61,7 +63,7 @@ class TestHelpCommandDiscord:
|
||||
assert cmd in message, f"Команда {cmd} не найдена"
|
||||
|
||||
# Проверяем разделение тире между командой и описанием
|
||||
lines = [l.strip() for l in message.split("\n") if "—" in l]
|
||||
lines = [line.strip() for line in message.split("\n") if "—" in line]
|
||||
assert len(lines) >= 4
|
||||
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
@ -20,6 +19,7 @@ async def loaded_bot():
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
|
||||
from commands import ALL_COMMANDS
|
||||
|
||||
for cog_class in ALL_COMMANDS:
|
||||
await bot.add_cog(cog_class())
|
||||
return bot
|
||||
@ -32,6 +32,7 @@ class TestCogLoading:
|
||||
async def test_all_cogs_load(self, loaded_bot) -> None:
|
||||
"""Все ког-модули должны загружаться без ошибок."""
|
||||
from commands import ALL_COMMANDS
|
||||
|
||||
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -92,6 +93,7 @@ class TestCommandFlow:
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
|
||||
from commands.stats import Stats
|
||||
|
||||
await bot.add_cog(Stats())
|
||||
|
||||
mock_ctx = MagicMock()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import logging
|
||||
import logging.handlers
|
||||
@ -123,9 +124,6 @@ def test_log_message_format() -> None:
|
||||
assert "test message" in output
|
||||
|
||||
|
||||
import contextlib
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _isolated_logger():
|
||||
"""Создать изолированный root-логгер без handlers из других тестов."""
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
||||
|
||||
import asyncio
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import discord
|
||||
import pytest
|
||||
|
||||
from utils.morning_runner import Scheduler, run_morning
|
||||
@ -31,7 +29,7 @@ class TestSchedulerInit:
|
||||
"""Инициализация должна вызывать _start_scheduler."""
|
||||
bot = AsyncMock()
|
||||
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||
scheduler = Scheduler(bot)
|
||||
Scheduler(bot)
|
||||
mock_start.assert_called_once()
|
||||
|
||||
|
||||
@ -81,8 +79,12 @@ class TestSchedulerStartStop:
|
||||
def test_stop_stops_task(self) -> None:
|
||||
"""stop() должен остановить task."""
|
||||
bot = AsyncMock()
|
||||
with patch("asyncio.create_task"):
|
||||
with patch.object(Scheduler, "_start_scheduler"):
|
||||
scheduler = Scheduler(bot)
|
||||
scheduler._running = True
|
||||
mock_task = MagicMock()
|
||||
mock_task.done.return_value = False
|
||||
scheduler._task = mock_task
|
||||
scheduler.stop()
|
||||
assert scheduler._running is False
|
||||
|
||||
@ -109,16 +111,50 @@ class TestRunMorning:
|
||||
|
||||
weather_data = {
|
||||
"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 = [{"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": []}]
|
||||
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": [],
|
||||
}
|
||||
]
|
||||
|
||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
||||
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") as mock_embed:
|
||||
with (
|
||||
patch(
|
||||
"utils.morning_runner.fetch_weather",
|
||||
new=AsyncMock(return_value=weather_data),
|
||||
),
|
||||
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)
|
||||
|
||||
channel.send.assert_called_once()
|
||||
@ -140,11 +176,14 @@ class TestRunMorningWithFallback:
|
||||
channel.permissions_for.return_value.send_messages = True
|
||||
|
||||
# Все API возвращают None/пусто
|
||||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \
|
||||
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:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)
|
||||
),
|
||||
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()
|
||||
mock_embed_class.return_value = embed_mock
|
||||
|
||||
@ -170,13 +209,28 @@ class TestRunMorningWithFallback:
|
||||
|
||||
weather_data = {
|
||||
"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 patch("utils.morning_runner.fetch_weather", 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)):
|
||||
with (
|
||||
patch(
|
||||
"utils.morning_runner.fetch_weather",
|
||||
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)
|
||||
|
||||
channel.send.assert_called_once()
|
||||
@ -186,4 +240,6 @@ class TestRunMorningWithFallback:
|
||||
# Проверяем, что в embed есть только погода и нет fallback сообщения
|
||||
embed_description = call_args["embed"].description
|
||||
assert "Погода в Магнитогорске" in embed_description
|
||||
assert "Не удалось получить данные из внешних источников" not in embed_description
|
||||
assert (
|
||||
"Не удалось получить данные из внешних источников" not in embed_description
|
||||
)
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import pytest
|
||||
from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console
|
||||
from utils.pogoda import (
|
||||
translate_weather,
|
||||
pressure_to_mmhg,
|
||||
wmo_to_russian,
|
||||
format_weather_data_for_console,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatWeatherDataForConsole:
|
||||
@ -8,14 +13,16 @@ class TestFormatWeatherDataForConsole:
|
||||
def test_format_valid_data(self) -> None:
|
||||
"""Полные данные должны быть отформатированы корректно."""
|
||||
data = {
|
||||
"current_condition": [{
|
||||
"temp_C": "25",
|
||||
"FeelsLikeC": "26",
|
||||
"weatherDesc": [{"value": "Clear"}],
|
||||
"humidity": "45",
|
||||
"windspeedKmph": "10",
|
||||
"pressure": "1013",
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "25",
|
||||
"FeelsLikeC": "26",
|
||||
"weatherDesc": [{"value": "Clear"}],
|
||||
"humidity": "45",
|
||||
"windspeedKmph": "10",
|
||||
"pressure": "1013",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
@ -30,9 +37,7 @@ class TestFormatWeatherDataForConsole:
|
||||
|
||||
def test_format_empty_data(self) -> None:
|
||||
"""Пустые данные должны возвращать None."""
|
||||
data = {
|
||||
"current_condition": [{}]
|
||||
}
|
||||
data = {"current_condition": [{}]}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
|
||||
@ -49,14 +54,16 @@ class TestFormatWeatherDataForConsole:
|
||||
def test_format_with_dashes(self) -> None:
|
||||
"""Неизвестные значения должны отображаться как '—'."""
|
||||
data = {
|
||||
"current_condition": [{
|
||||
"temp_C": "—",
|
||||
"FeelsLikeC": "—",
|
||||
"weatherDesc": [{"value": "—"}],
|
||||
"humidity": "—",
|
||||
"windspeedKmph": "—",
|
||||
"pressure": "—",
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "—",
|
||||
"FeelsLikeC": "—",
|
||||
"weatherDesc": [{"value": "—"}],
|
||||
"humidity": "—",
|
||||
"windspeedKmph": "—",
|
||||
"pressure": "—",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
@ -71,14 +78,16 @@ class TestFormatWeatherDataForConsole:
|
||||
def test_format_wind_conversion(self) -> None:
|
||||
"""Проверка конвертации ветра из км/ч в м/с."""
|
||||
data = {
|
||||
"current_condition": [{
|
||||
"temp_C": "20",
|
||||
"FeelsLikeC": "19",
|
||||
"weatherDesc": [{"value": "Cloudy"}],
|
||||
"humidity": "60",
|
||||
"windspeedKmph": "36",
|
||||
"pressure": "1000",
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "20",
|
||||
"FeelsLikeC": "19",
|
||||
"weatherDesc": [{"value": "Cloudy"}],
|
||||
"humidity": "60",
|
||||
"windspeedKmph": "36",
|
||||
"pressure": "1000",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
@ -88,14 +97,16 @@ class TestFormatWeatherDataForConsole:
|
||||
def test_format_negative_temperature(self) -> None:
|
||||
"""Отрицательная температура должна отображаться корректно."""
|
||||
data = {
|
||||
"current_condition": [{
|
||||
"temp_C": "-5",
|
||||
"FeelsLikeC": "-10",
|
||||
"weatherDesc": [{"value": "Snow"}],
|
||||
"humidity": "80",
|
||||
"windspeedKmph": "20",
|
||||
"pressure": "980",
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": "-5",
|
||||
"FeelsLikeC": "-10",
|
||||
"weatherDesc": [{"value": "Snow"}],
|
||||
"humidity": "80",
|
||||
"windspeedKmph": "20",
|
||||
"pressure": "980",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = format_weather_data_for_console(data)
|
||||
@ -105,7 +116,6 @@ class TestFormatWeatherDataForConsole:
|
||||
|
||||
|
||||
class TestTranslateWeather:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"english, expected",
|
||||
[
|
||||
@ -121,7 +131,10 @@ class TestTranslateWeather:
|
||||
("Light rain", "Небольшой дождь"),
|
||||
("Moderate rain", "Умеренный дождь"),
|
||||
("Heavy rain", "Сильный дождь"),
|
||||
("Moderate or heavy rain at times", "Дождь"), # длинный ключ проверяется первым
|
||||
(
|
||||
"Moderate or heavy rain at times",
|
||||
"Дождь",
|
||||
), # длинный ключ проверяется первым
|
||||
("Heavy rain at times", "Сильный дождь"),
|
||||
("Light snow", "Небольшой снег"),
|
||||
("Moderate snow", "Умеренный снег"),
|
||||
|
||||
@ -30,6 +30,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class MorningData:
|
||||
"""Собранные данные для утреннего дайджеста."""
|
||||
|
||||
weather: Optional[dict]
|
||||
articles: Optional[list]
|
||||
posts: Optional[list]
|
||||
@ -81,9 +82,11 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
||||
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/")
|
||||
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("Новостей пока нет.")
|
||||
@ -96,9 +99,11 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
||||
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/")
|
||||
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("Новостей пока нет.")
|
||||
@ -109,7 +114,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
|
||||
if not has_real_data:
|
||||
description_lines = [
|
||||
"Не удалось получить данные из внешних источников.",
|
||||
"Проверьте доступность API и повторите попытку позже."
|
||||
"Проверьте доступность API и повторите попытку позже.",
|
||||
]
|
||||
|
||||
description = "\n".join(description_lines)
|
||||
@ -144,7 +149,9 @@ class Scheduler:
|
||||
try:
|
||||
self._target_channel_id = int(channel_id_str)
|
||||
except ValueError:
|
||||
logger.warning("Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str)
|
||||
logger.warning(
|
||||
"Неверное значение MORNING_CHANNEL_ID: %s", channel_id_str
|
||||
)
|
||||
self._task: asyncio.Task | None = None
|
||||
self._running = False
|
||||
self._start_scheduler()
|
||||
@ -229,10 +236,14 @@ class Scheduler:
|
||||
await run_morning(self.bot, channel)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Ошибка отправки в канал %s: %s", self._target_channel_id, e)
|
||||
logger.error(
|
||||
"Ошибка отправки в канал %s: %s", self._target_channel_id, e
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.warning("Канал с ID %s не текстовый — fallback", self._target_channel_id)
|
||||
logger.warning(
|
||||
"Канал с ID %s не текстовый — fallback", self._target_channel_id
|
||||
)
|
||||
|
||||
# Fallback: первый канал с правами send_messages
|
||||
sent = False
|
||||
|
||||
@ -9,8 +9,12 @@ from utils.rate_limiter import habr_rss_limiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RSS_URL_ARTICLES = "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_ARTICLES = (
|
||||
"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"
|
||||
)
|
||||
|
||||
_session = requests.Session()
|
||||
|
||||
@ -62,13 +66,15 @@ async def fetch_rss(url: str) -> Optional[list[dict]]:
|
||||
creator = creator_el.text if creator_el is not None else ""
|
||||
tags = [cat.text for cat in categories if cat.text] if categories else []
|
||||
|
||||
articles.append({
|
||||
"title": title,
|
||||
"link": link,
|
||||
"pub_date": pub_date,
|
||||
"creator": creator,
|
||||
"tags": tags,
|
||||
})
|
||||
articles.append(
|
||||
{
|
||||
"title": title,
|
||||
"link": link,
|
||||
"pub_date": pub_date,
|
||||
"creator": creator,
|
||||
"tags": tags,
|
||||
}
|
||||
)
|
||||
return articles[:10]
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Ошибка при получении RSS (%s): %s", url, e)
|
||||
@ -98,14 +104,14 @@ def truncate_embed_text(text: str, max_len: int = 4096) -> str:
|
||||
"""Обрезать текст для embed.description (лимит Discord: 4096 символов)."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len - 3] + "..."
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def truncate_embed_field(text: str, max_len: int = 1024) -> str:
|
||||
"""Обрезать текст для embed field value (лимит Discord: 1024 символа)."""
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[:max_len - 3] + "..."
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
|
||||
|
||||
@ -14,7 +14,9 @@ API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||
_session = requests.Session()
|
||||
|
||||
|
||||
async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
|
||||
async def fetch_weather(
|
||||
api_url: str, timeout: int = 10, max_retries: int = 3
|
||||
) -> Optional[dict]:
|
||||
"""Получить данные о погоде с retry."""
|
||||
await weather_limiter.acquire()
|
||||
for attempt in range(max_retries):
|
||||
@ -24,8 +26,10 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
|
||||
return response.json()
|
||||
except (SSLError, ConnectionError, Timeout):
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2 ** attempt
|
||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
||||
delay = 2**attempt
|
||||
logger.warning(
|
||||
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
break
|
||||
@ -37,7 +41,9 @@ async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -
|
||||
return await 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]:
|
||||
async def fetch_open_meteo(
|
||||
lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3
|
||||
) -> Optional[dict]:
|
||||
"""Fallback на Open-Meteo API."""
|
||||
await open_meteo_limiter.acquire()
|
||||
url = (
|
||||
@ -56,19 +62,23 @@ async def fetch_open_meteo(lat: float = 53.4069, lon: float = 58.9797, timeout:
|
||||
weather_code = current.get("weather_code", None)
|
||||
desc = wmo_to_russian(weather_code)
|
||||
return {
|
||||
"current_condition": [{
|
||||
"temp_C": current.get("temperature", "—"),
|
||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
||||
"weatherDesc": [{"value": desc}],
|
||||
"humidity": current.get("relative_humidity_2m", "—"),
|
||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
||||
"pressure": current.get("pressure_msl", "—"),
|
||||
}]
|
||||
"current_condition": [
|
||||
{
|
||||
"temp_C": current.get("temperature", "—"),
|
||||
"FeelsLikeC": current.get("apparent_temperature", "—"),
|
||||
"weatherDesc": [{"value": desc}],
|
||||
"humidity": current.get("relative_humidity_2m", "—"),
|
||||
"windspeedKmph": current.get("wind_speed_10m", "—"),
|
||||
"pressure": current.get("pressure_msl", "—"),
|
||||
}
|
||||
]
|
||||
}
|
||||
except (SSLError, ConnectionError, Timeout):
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2 ** attempt
|
||||
logger.warning("Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay)
|
||||
delay = 2**attempt
|
||||
logger.warning(
|
||||
"Попытка %d не удалась. Повтор через %d сек...", attempt + 1, delay
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
break
|
||||
@ -84,18 +94,33 @@ def wmo_to_russian(code: Optional[int]) -> str:
|
||||
"""Перевод WMO weather code в русский."""
|
||||
mapping = {
|
||||
0: "Ясно",
|
||||
1: "Ясно", 2: "Переменная облачность",
|
||||
1: "Ясно",
|
||||
2: "Переменная облачность",
|
||||
3: "Пасмурно",
|
||||
45: "Туман", 48: "Туман",
|
||||
51: "Лёгкая морось", 53: "Морось", 55: "Сильная морось",
|
||||
56: "Ледяная морось", 57: "Сильная ледяная морось",
|
||||
61: "Небольшой дождь", 63: "Дождь", 65: "Сильный дождь",
|
||||
66: "Ледяной дождь", 67: "Сильный ледяной дождь",
|
||||
71: "Небольшой снег", 73: "Снег", 75: "Сильный снег",
|
||||
45: "Туман",
|
||||
48: "Туман",
|
||||
51: "Лёгкая морось",
|
||||
53: "Морось",
|
||||
55: "Сильная морось",
|
||||
56: "Ледяная морось",
|
||||
57: "Сильная ледяная морось",
|
||||
61: "Небольшой дождь",
|
||||
63: "Дождь",
|
||||
65: "Сильный дождь",
|
||||
66: "Ледяной дождь",
|
||||
67: "Сильный ледяной дождь",
|
||||
71: "Небольшой снег",
|
||||
73: "Снег",
|
||||
75: "Сильный снег",
|
||||
77: "Снежная крупа",
|
||||
80: "Небольшой ливень", 81: "Ливень", 82: "Сильный ливень",
|
||||
85: "Снежный ливень", 86: "Сильный снежный ливень",
|
||||
95: "Гроза", 96: "Гроза с градом", 99: "Сильная гроза с градом",
|
||||
80: "Небольшой ливень",
|
||||
81: "Ливень",
|
||||
82: "Сильный ливень",
|
||||
85: "Снежный ливень",
|
||||
86: "Сильный снежный ливень",
|
||||
95: "Гроза",
|
||||
96: "Гроза с градом",
|
||||
99: "Сильная гроза с градом",
|
||||
}
|
||||
return mapping.get(code, "Неизвестно")
|
||||
|
||||
@ -169,7 +194,9 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
|
||||
|
||||
temp = current.get("temp_C", "—")
|
||||
feels_like = current.get("FeelsLikeC", "—")
|
||||
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—"))
|
||||
description = translate_weather(
|
||||
current.get("weatherDesc", [{}])[0].get("value", "—")
|
||||
)
|
||||
humidity = current.get("humidity", "—")
|
||||
wind_kmh = current.get("windspeedKmph", "—")
|
||||
try:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user