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