diff --git a/bot/cards.py b/bot/cards.py index 4b0c772..2840ffc 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -1,4 +1,5 @@ import json +import logging import random from html import escape @@ -7,6 +8,8 @@ from config import settings from models import Card +log = logging.getLogger(__name__) + _CODE_FENCE_OPEN = "```python\n" _CODE_FENCE_CLOSE = "\n```" _PRE_OPEN = '
'
@@ -17,6 +20,19 @@ def _difficulty_emoji(difficulty: str) -> str:
return {"easy": "🟢", "normal": "🟡", "hard": "🔴"}.get(difficulty, "⚪")
+def _parse_tags(card: Card) -> list[str]:
+ """Разбирает теги карточки. Битые или неожиданные данные дают пустой список."""
+ try:
+ tags = json.loads(card.tags or "[]")
+ except (ValueError, TypeError):
+ log.warning(f"Карточка id={card.id}: теги не разобрать как JSON, пропуск")
+ return []
+ if not isinstance(tags, list) or not all(isinstance(t, str) for t in tags):
+ log.warning(f"Карточка id={card.id}: теги не список строк, пропуск")
+ return []
+ return tags
+
+
def get_next_cards(db: Session, count: int, last_id: int = 0) -> list[Card]:
"""Возвращает следующие карточки по порядку начиная с last_id."""
cards = (
@@ -46,7 +62,7 @@ def format_card(card: Card) -> list[str]:
f"{escape(card.answer)}"
)
- tags = json.loads(card.tags or "[]")
+ tags = _parse_tags(card)
if tags:
tags_line = " ".join(f"{escape(t)}" for t in tags)
text += f"\n\n🏷 {tags_line}"
@@ -111,7 +127,7 @@ def format_card_discord(card: Card) -> list[str]:
f"{card.answer}\n"
)
- tags = json.loads(card.tags or "[]")
+ tags = _parse_tags(card)
if tags:
text += "\n\n🏷 " + " ".join(f"`{t}`" for t in tags)
diff --git a/bot/config.py b/bot/config.py
index 8a31f9e..47e9cab 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -31,6 +31,15 @@ def _parse_admin_ids(raw: str) -> list[int]:
return ids
+def _check_delivery_channels(telegram_enabled: bool, discord_webhook_url: str) -> None:
+ """Бот должен куда-то отправлять карточки: нужен Telegram или Discord."""
+ if not telegram_enabled and not discord_webhook_url.strip():
+ raise RuntimeError(
+ "Некуда отправлять карточки: TELEGRAM_ENABLED=false и "
+ "DISCORD_WEBHOOK_URL не задан"
+ )
+
+
class Settings:
bot_token: str = _require("BOT_TOKEN")
admin_ids: list[int] = _parse_admin_ids(_require("ADMIN_IDS"))
@@ -48,3 +57,4 @@ class Settings:
settings = Settings()
+_check_delivery_channels(settings.telegram_enabled, settings.discord_webhook_url)
diff --git a/bot/discord_sender.py b/bot/discord_sender.py
index d49fbd8..24676af 100644
--- a/bot/discord_sender.py
+++ b/bot/discord_sender.py
@@ -15,12 +15,14 @@
class DiscordSender:
def __init__(self, webhook_url: str) -> None:
- if not webhook_url:
- raise ValueError("Discord webhook URL не задан")
- self.webhook_url = webhook_url
+ self.webhook_url = webhook_url.strip()
+ self.enabled = bool(self.webhook_url)
self.session: aiohttp.ClientSession | None = None
async def start(self) -> None:
+ if not self.enabled:
+ log.warning("Discord отключён: DISCORD_WEBHOOK_URL не задан")
+ return
self.session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=settings.discord_timeout)
)
@@ -30,7 +32,7 @@ async def stop(self) -> None:
await self.session.close()
async def send(self, messages: list[str]) -> bool:
- if not messages:
+ if not self.enabled or not messages:
return True
failed = 0
diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py
index 06d42a7..341269b 100644
--- a/bot/tests/test_cards.py
+++ b/bot/tests/test_cards.py
@@ -4,6 +4,8 @@
from html import unescape
from unittest.mock import MagicMock
+import pytest
+
from cards import (
_PRE_CLOSE,
_PRE_OPEN,
@@ -19,6 +21,8 @@
from config import settings
from models import Card
+BAD_TAGS = ["{bad", "5", '"abc"', '{"a": 1}', "[1, 2]"]
+
def make_card(**kwargs) -> Card:
"""Создаёт карточку с дефолтными значениями для тестов."""
@@ -201,3 +205,25 @@ def test_long_code_parts_within_discord_limit(self):
card = make_card(code_example="x" * 5000)
result = format_card_discord(card)
assert all(len(part) <= settings.discord_max_length for part in result)
+
+
+class TestBrokenTags:
+ @pytest.mark.parametrize("tags", BAD_TAGS)
+ def test_telegram_ignores_invalid_tags(self, tags):
+ card = make_card(tags=tags)
+ assert "🏷" not in "".join(format_card(card))
+
+ @pytest.mark.parametrize("tags", BAD_TAGS)
+ def test_discord_ignores_invalid_tags(self, tags):
+ card = make_card(tags=tags)
+ assert "🏷" not in "".join(format_card_discord(card))
+
+ def test_invalid_tags_logged_with_card_id(self, caplog):
+ card = make_card(id=7, tags="{bad")
+ with caplog.at_level("WARNING"):
+ format_card(card)
+ assert "id=7" in caplog.text
+
+ def test_discord_valid_tags_included(self):
+ card = make_card(tags=json.dumps(["list", "basics"]))
+ assert "`list`" in "".join(format_card_discord(card))
diff --git a/bot/tests/test_config.py b/bot/tests/test_config.py
index 3ed305c..b96a024 100644
--- a/bot/tests/test_config.py
+++ b/bot/tests/test_config.py
@@ -35,3 +35,19 @@ def test_skips_empty_items(self):
def test_invalid_rejected_with_name(self, raw):
with pytest.raises(RuntimeError, match="ADMIN_IDS"):
config._parse_admin_ids(raw)
+
+
+class TestCheckDeliveryChannels:
+ def test_telegram_only_ok(self):
+ config._check_delivery_channels(True, "")
+
+ def test_discord_only_ok(self):
+ config._check_delivery_channels(False, "https://example.test/webhook")
+
+ def test_both_ok(self):
+ config._check_delivery_channels(True, "https://example.test/webhook")
+
+ @pytest.mark.parametrize("url", ["", " "])
+ def test_no_channels_rejected(self, url):
+ with pytest.raises(RuntimeError, match="DISCORD_WEBHOOK_URL"):
+ config._check_delivery_channels(False, url)
diff --git a/bot/tests/test_discord_sender.py b/bot/tests/test_discord_sender.py
index c0b8b80..b519aef 100644
--- a/bot/tests/test_discord_sender.py
+++ b/bot/tests/test_discord_sender.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
import os
import sys
@@ -83,3 +84,20 @@ def test_rate_limit_with_unreadable_body_uses_default_pause(self, sleeps):
sender = make_sender([FakeResponse(429, json_error=True), FakeResponse(204)])
assert asyncio.run(sender._post("x")) is True
assert sleeps == [5]
+
+
+class TestWithoutWebhook:
+ def test_empty_url_does_not_raise(self):
+ sender = DiscordSender("")
+ assert sender.enabled is False
+
+ def test_send_without_webhook_is_noop(self):
+ sender = DiscordSender("")
+ assert asyncio.run(sender.send(["текст"])) is True
+
+ def test_start_without_webhook_warns_and_opens_no_session(self, caplog):
+ sender = DiscordSender("")
+ with caplog.at_level(logging.WARNING):
+ asyncio.run(sender.start())
+ assert sender.session is None
+ assert "DISCORD_WEBHOOK_URL" in caplog.text