From 624af2dad85c002ed4e59540f2d8789382d951cc Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 13:04:53 +0300 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=D0=B1=D0=BE=D1=82=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=BC=20DISCORD=5FWEBHOOK=5FURL,?= =?UTF-8?q?=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/discord_sender.py | 10 ++++++---- bot/tests/test_discord_sender.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) 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_discord_sender.py b/bot/tests/test_discord_sender.py index c0b8b80..9dbe754 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,19 @@ 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 From aeb90c470acbd0eb817846b74efcf86dc71c99ae Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 13:11:53 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=D0=B1=D0=BE=D1=82=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D1=83=D0=B5=D1=82,=20=D0=B5?= =?UTF-8?q?=D1=81=D0=BB=D0=B8=20=D0=BD=D0=B5=D1=82=20=D0=BD=D0=B8=20Telegr?= =?UTF-8?q?am,=20=D0=BD=D0=B8=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/config.py | 10 ++++++++++ bot/tests/test_config.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) 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/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) From b2d68bd1c47c9975efa591fce074e94b16c05326 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 13:12:17 +0300 Subject: [PATCH 3/4] =?UTF-8?q?style:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=83=D1=81=D1=82=D0=B0=D1=8F?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=20TestWithoutWebhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_discord_sender.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/tests/test_discord_sender.py b/bot/tests/test_discord_sender.py index 9dbe754..b519aef 100644 --- a/bot/tests/test_discord_sender.py +++ b/bot/tests/test_discord_sender.py @@ -85,6 +85,7 @@ def test_rate_limit_with_unreadable_body_uses_default_pause(self, sleeps): assert asyncio.run(sender._post("x")) is True assert sleeps == [5] + class TestWithoutWebhook: def test_empty_url_does_not_raise(self): sender = DiscordSender("") From 68b9da995fb80930c4fdc3bd2e9fe2f59d627644 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 13:25:15 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=D0=B1=D0=B8=D1=82=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D0=B8=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D1=8F=D1=8E=D1=82=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 20 ++++++++++++++++++-- bot/tests/test_cards.py | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) 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/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))