feat: !morning — заменить embed на plain text, кот отдельным сообщением

- Переименован format_weather_for_embed -> format_weather_for_message
- Убран markdown жирный (**text**) из заголовков погоды и статей
- Утренний дайджест отправляется как обычное текстовое сообщение
- Котик отправляется отдельным сообщением перед дайджестом
- Truncation через truncate_message (лимит 2000 символов)
- Обновлены тесты: проверка 2 send() вместо embed
This commit is contained in:
deadzilla 2026-07-09 13:28:39 +05:00
parent 2197a0052d
commit cef53197b1
6 changed files with 57 additions and 60 deletions

View File

@ -99,7 +99,7 @@ class TestFormatArticles:
] ]
result = format_articles(articles, "Заголовок", "https://habr.com/feed") result = format_articles(articles, "Заголовок", "https://habr.com/feed")
assert len(result) == 3 # заголовок + 2 статьи assert len(result) == 3 # заголовок + 2 статьи
assert result[0] == "**Заголовок**\n<https://habr.com/feed>" assert result[0] == "Заголовок\n<https://habr.com/feed>"
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>" assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>" assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
@ -122,7 +122,7 @@ class TestFormatArticles:
def test_format_articles_empty_list(self) -> None: def test_format_articles_empty_list(self) -> None:
"""Пустой список должен вернуть только заголовок.""" """Пустой список должен вернуть только заголовок."""
result = format_articles([], "Заголовок", "https://habr.com/feed") result = format_articles([], "Заголовок", "https://habr.com/feed")
assert result == ["**Заголовок**\n<https://habr.com/feed>"] assert result == ["Заголовок\n<https://habr.com/feed>"]
assert len(result) == 1 assert len(result) == 1
def test_format_articles_none(self) -> None: def test_format_articles_none(self) -> None:
@ -143,7 +143,7 @@ class TestFormatArticles:
] ]
result = format_articles(articles, "Новости AI", "https://habr.com/ai") result = format_articles(articles, "Новости AI", "https://habr.com/ai")
assert len(result) == 2 assert len(result) == 2
assert result[0] == "**Новости AI**\n<https://habr.com/ai>" assert result[0] == "Новости AI\n<https://habr.com/ai>"
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>" assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
def test_format_articles_long_title_truncated(self) -> None: def test_format_articles_long_title_truncated(self) -> None:

View File

@ -101,8 +101,8 @@ class TestRunMorning:
"""Тесты run_morning.""" """Тесты run_morning."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_morning_sends_embed(self) -> None: async def test_run_morning_sends_plain_message(self) -> None:
"""run_morning должен отправлять embed в канал.""" """run_morning должен отправлять plain text в канал."""
bot = AsyncMock() bot = AsyncMock()
channel = AsyncMock() channel = AsyncMock()
channel.name = "test-channel" channel.name = "test-channel"
@ -153,22 +153,29 @@ class TestRunMorning:
"utils.morning_runner.fetch_cat", "utils.morning_runner.fetch_cat",
new=AsyncMock(return_value="http://cat.jpg"), 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() # Два сообщения: кот + дайджест
call_args = channel.send.call_args[1] assert channel.send.call_count == 2
assert "embed" in call_args
assert call_args["embed"] is not None # Первое сообщение — URL кота
first_call = channel.send.call_args_list[0]
assert first_call[0][0] == "http://cat.jpg"
# Второе сообщение — текстовый дайджест
second_call = channel.send.call_args_list[1]
message_text = second_call[0][0]
assert "Утренний дайджест" in message_text
assert "Погода в Магнитогорске" in message_text
class TestRunMorningWithFallback: class TestRunMorningWithFallback:
"""Тесты fallback в пустом embed.""" """Тесты fallback в plain text."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_morning_empty_embed_fallback(self) -> None: async def test_run_morning_empty_fallback(self) -> None:
"""run_morning должен добавлять fallback сообщение при пустых данных.""" """run_morning должен отправлять fallback сообщение при пустых данных."""
bot = AsyncMock() bot = AsyncMock()
channel = AsyncMock() channel = AsyncMock()
channel.name = "test-channel" channel.name = "test-channel"
@ -182,21 +189,14 @@ class TestRunMorningWithFallback:
), ),
patch("utils.morning_runner.fetch_rss", 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.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
await run_morning(bot, channel) await run_morning(bot, channel)
# Убедимся, что send был вызван # Только одно сообщение (без кота) с fallback текстом
channel.send.assert_called_once() channel.send.assert_called_once()
call_args = channel.send.call_args[1] call_args = channel.send.call_args
assert "embed" in call_args message_text = call_args[0][0]
assert "Не удалось получить данные из внешних источников" in message_text
# Проверяем, что description содержит fallback сообщение
embed_description = call_args["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:
@ -233,13 +233,13 @@ class TestRunMorningWithFallback:
): ):
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
assert "embed" in call_args message_text = call_args[0][0]
# Проверяем, что в embed есть только погода и нет fallback сообщения # Проверяем, что в тексте есть погода и нет fallback сообщения
embed_description = call_args["embed"].description assert "Погода в Магнитогорске" in message_text
assert "Погода в Магнитогорске" in embed_description
assert ( assert (
"Не удалось получить данные из внешних источников" not in embed_description "Не удалось получить данные из внешних источников" not in message_text
) )

View File

@ -3,7 +3,7 @@ from .pogoda import (
fetch_weather, fetch_weather,
fetch_open_meteo, fetch_open_meteo,
format_weather_data_for_console, format_weather_data_for_console,
format_weather_for_embed, format_weather_for_message,
pressure_to_mmhg, pressure_to_mmhg,
translate_weather, translate_weather,
wmo_to_russian, wmo_to_russian,
@ -23,7 +23,7 @@ __all__ = [
"fetch_weather", "fetch_weather",
"fetch_open_meteo", "fetch_open_meteo",
"format_weather_data_for_console", "format_weather_data_for_console",
"format_weather_for_embed", "format_weather_for_message",
"pressure_to_mmhg", "pressure_to_mmhg",
"translate_weather", "translate_weather",
"wmo_to_russian", "wmo_to_russian",

View File

@ -13,14 +13,14 @@ from discord.ext import commands
from utils.pogoda import ( from utils.pogoda import (
API_URL_WEATHER, API_URL_WEATHER,
fetch_weather, fetch_weather,
format_weather_for_embed, format_weather_for_message,
) )
from utils.news import ( from utils.news import (
fetch_rss, fetch_rss,
format_articles, format_articles,
RSS_URL_ARTICLES, RSS_URL_ARTICLES,
RSS_URL_POSTS, RSS_URL_POSTS,
truncate_embed_text, truncate_message,
) )
from utils.cat import fetch_cat from utils.cat import fetch_cat
@ -54,29 +54,27 @@ async def gather_morning() -> MorningData:
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None: async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None:
"""Выполнить утренний дайджест и отправить в канал.""" """Выполнить утренний дайджест и отправить в канал как plain text."""
try: try:
data = await gather_morning() data = await gather_morning()
# --- Формируем embed --- # --- Котик отдельным сообщением ---
embed = discord.Embed(title="Утренний дайджест", color=0xF4A460)
# Котик как thumbnail
if data.cat_url: if data.cat_url:
embed.set_thumbnail(url=data.cat_url) await channel.send(data.cat_url)
description_lines = [] # --- Формируем plain text ---
message_lines = ["Утренний дайджест", ""]
has_real_data = False has_real_data = False
# --- Погода --- # --- Погода ---
weather_text = format_weather_for_embed(data.weather) weather_text = format_weather_for_message(data.weather)
if weather_text: if weather_text:
has_real_data = True has_real_data = True
description_lines.append(weather_text) message_lines.append(weather_text)
else: else:
description_lines.append("Не удалось получить данные о погоде.") message_lines.append("Не удалось получить данные о погоде.")
description_lines.append("") message_lines.append("")
# --- Новости: статьи --- # --- Новости: статьи ---
if data.articles is not None: if data.articles is not None:
@ -87,13 +85,13 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
"Лучшие статьи за сутки / Искусственный интеллект / Хаб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)) message_lines.extend(lines)
else: else:
description_lines.append("Новостей пока нет.") message_lines.append("Новостей пока нет.")
else: else:
description_lines.append("Не удалось получить новости.") message_lines.append("Не удалось получить новости.")
description_lines.append("") message_lines.append("")
# --- Новости: посты --- # --- Новости: посты ---
if data.posts is not None: if data.posts is not None:
@ -104,22 +102,21 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None
"Лучшие новости за сутки / Искусственный интеллект / Хаб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)) message_lines.extend(lines)
else: else:
description_lines.append("Новостей пока нет.") message_lines.append("Новостей пока нет.")
else: else:
description_lines.append("Не удалось получить новости.") message_lines.append("Не удалось получить новости.")
# Fallback для пустых данных # Fallback для пустых данных
if not has_real_data: if not has_real_data:
description_lines = [ message_lines = [
"Не удалось получить данные из внешних источников.", "Не удалось получить данные из внешних источников.",
"Проверьте доступность API и повторите попытку позже.", "Проверьте доступность API и повторите попытку позже.",
] ]
description = "\n".join(description_lines) message = truncate_message("\n".join(message_lines))
embed.description = truncate_embed_text(description) await channel.send(message)
await channel.send(embed=embed)
logger.info("Утренний дайджест отправлен в #%s", channel.name) logger.info("Утренний дайджест отправлен в #%s", channel.name)
except Exception as e: except Exception as e:

View File

@ -123,7 +123,7 @@ def truncate_message(text: str, max_len: int = 2000) -> str:
def format_articles(articles: list[dict], title: str, link: str) -> list[str]: def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
"""Сформировать список строк для вывода статей/постов.""" """Сформировать список строк для вывода статей/постов."""
lines = [f"**{title}**\n<{link}>"] lines = [f"{title}\n<{link}>"]
for i, article in enumerate(articles[:5], 1): for i, article in enumerate(articles[:5], 1):
date_str = _parse_date(article["pub_date"]) date_str = _parse_date(article["pub_date"])
short_title = truncate_title(article["title"]) short_title = truncate_title(article["title"])

View File

@ -215,14 +215,14 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
] ]
def format_weather_for_embed(data: Optional[dict]) -> Optional[str]: def format_weather_for_message(data: Optional[dict]) -> Optional[str]:
"""Форматировать погоду для Discord embed (с заголовком).""" """Форматировать погоду для plain text сообщения (с заголовком)."""
if data is None: if data is None:
return None return None
lines = format_weather_data_for_console(data) lines = format_weather_data_for_console(data)
if not lines: if not lines:
return None return None
return "**Погода в Магнитогорске:**\n" + "\n".join(lines) return "Погода в Магнитогорске:\n" + "\n".join(lines)
def pressure_to_mmhg(mb: Any) -> float | str: def pressure_to_mmhg(mb: Any) -> float | str: