From 64ecb38f8ad58efe0af5742a6b174459397498fc Mon Sep 17 00:00:00 2001 From: Wlwool Date: Mon, 5 Oct 2026 11:11:51 +0300 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BF=D0=B0=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BE=D0=B1=D1=91=D1=80=D1=82=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D0=B2=20Discord=20=D1=81=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=20(=D0=B1=D1=8B=D0=BB=D0=BE=2012?= =?UTF-8?q?,=20=D0=BD=D1=83=D0=B6=D0=BD=D0=BE=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 9 +++++---- bot/tests/test_cards.py | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/bot/cards.py b/bot/cards.py index 661e159..fc9d9b3 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -5,6 +5,8 @@ from config import settings from models import Card +_CODE_FENCE_OPEN = "```python\n" +_CODE_FENCE_CLOSE = "\n```" def _difficulty_emoji(difficulty: str) -> str: return {"easy": "🟢", "normal": "🟡", "hard": "🔴"}.get(difficulty, "⚪") @@ -87,15 +89,14 @@ def format_card_discord(card: Card) -> list[str]: def _append_discord_code(code: str, parts: list[str]) -> None: """Оборачивает код в ```python блок, разбивая если нужно.""" limit = settings.discord_max_length - # 12 = len("```python\n") + len("\n```") - chunk_limit = limit - 12 + chunk_limit = limit - len(_CODE_FENCE_OPEN) - len(_CODE_FENCE_CLOSE) if len(code) <= chunk_limit: - parts.append(f"```python\n{code}\n```") + parts.append(f"{_CODE_FENCE_OPEN}{code}{_CODE_FENCE_CLOSE}") return for chunk in _split_discord(code, limit=chunk_limit): - parts.append(f"```python\n{chunk}\n```") + parts.append(f"{_CODE_FENCE_OPEN}{chunk}{_CODE_FENCE_CLOSE}") def _split_discord(text: str, limit: int | None = None) -> list[str]: diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index ed7c1c7..f3d1b3d 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -7,7 +7,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from cards import _split_telegram, format_card, get_next_cards, get_random_card +from cards import ( + _split_telegram, + format_card, + format_card_discord, + get_next_cards, + get_random_card, +) +from config import settings from models import Card @@ -164,3 +171,10 @@ def test_returns_none_for_empty_db(self): db.query().all.return_value = [] result = get_random_card(db) assert result is None + +class TestFormatCardDiscord: + def test_long_code_parts_within_discord_limit(self): + """Каждое сообщение вместе с обёрткой ```python не длиннее лимита Discord.""" + card = make_card(code_example="x" * 5000) + result = format_card_discord(card) + assert all(len(part) <= settings.discord_max_length for part in result) From cbe830f1671b40972b51aa4f60e5ea4ab9072c4e Mon Sep 17 00:00:00 2001 From: Wlwool Date: Mon, 5 Oct 2026 11:30:33 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=D0=BA=D0=BE=D0=B4=20=D0=B2=20Telegr?= =?UTF-8?q?am=20=D1=80=D0=B5=D0=B6=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=20?= =?UTF-8?q?=D1=83=D1=87=D1=91=D1=82=D0=BE=D0=BC=20escape=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=91=D1=80=D1=82=D0=BA=D0=B8=20
,=20=D0=BE=D1=82?=
 =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D1=8B=20=D0=BD=D0=B5=20=D1=82=D0=B5?=
 =?UTF-8?q?=D1=80=D1=8F=D1=8E=D1=82=D1=81=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 bot/cards.py            | 34 ++++++++++++++++++++++++++++++----
 bot/tests/test_cards.py | 28 ++++++++++++++++++++++++++++
 2 files changed, 58 insertions(+), 4 deletions(-)

diff --git a/bot/cards.py b/bot/cards.py
index fc9d9b3..f456380 100644
--- a/bot/cards.py
+++ b/bot/cards.py
@@ -7,6 +7,8 @@
 
 _CODE_FENCE_OPEN = "```python\n"
 _CODE_FENCE_CLOSE = "\n```"
+_PRE_OPEN = '
'
+_PRE_CLOSE = "
" def _difficulty_emoji(difficulty: str) -> str: return {"easy": "🟢", "normal": "🟡", "hard": "🔴"}.get(difficulty, "⚪") @@ -44,10 +46,34 @@ def format_card(card: Card) -> list[str]: parts = _split_telegram(text) if card.code_example: - for code_part in _split_telegram(card.code_example, limit=4000): - parts.append( - f"
{escape(code_part)}
" - ) + for code_part in _split_code_telegram(card.code_example): + parts.append(f"{_PRE_OPEN}{escape(code_part)}{_PRE_CLOSE}") + return parts + + +def _split_code_telegram(code: str, limit: int = 4096) -> list[str]: + """Режет код так, чтобы после escape и обёртки
 сообщение влезло в limit.
+    Режет по последнему переводу строки, отступы следующего куска не трогает.
+    """
+    budget = limit - len(_PRE_OPEN) - len(_PRE_CLOSE)
+    parts: list[str] = []
+    while code:
+        used = 0
+        end = 0
+        for ch in code:
+            size = len(escape(ch))
+            if used + size > budget:
+                break
+            used += size
+            end += 1
+        else:
+            parts.append(code)
+            break
+        cut = code.rfind("\n", 0, end)
+        if cut <= 0:
+            cut = end
+        parts.append(code[:cut])
+        code = code[cut:].lstrip("\n")
     return parts
 
 def _split_telegram(text: str, limit: int = 4096) -> list[str]:
diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py
index f3d1b3d..4808111 100644
--- a/bot/tests/test_cards.py
+++ b/bot/tests/test_cards.py
@@ -1,4 +1,14 @@
 import json
+from cards import (
+    _PRE_CLOSE,
+    _PRE_OPEN,
+    _split_telegram,
+    format_card,
+    format_card_discord,
+    get_next_cards,
+    get_random_card,
+)
+from html import unescape
 from unittest.mock import MagicMock
 
 import pytest
@@ -132,6 +142,24 @@ def test_special_chars_escaped(self):
         assert ">" in full
         assert "&" in full
 
+    def test_code_parts_within_limit_after_escape(self):
+        """Код с символами < > & после escape и обёртки не длиннее 4096."""
+        card = make_card(code_example="<" * 5000)
+        result = format_card(card)
+        assert all(len(part) <= 4096 for part in result)
+
+    def test_code_parts_preserve_code(self):
+        """Склейка кусков кода даёт исходный код."""
+        code = "\n".join(f"    if a < {i} and b > {i}: pass" for i in range(400))
+        card = make_card(code_example=code)
+        code_parts = format_card(card)[1:]
+        restored = "".join(
+            unescape(p.removeprefix(_PRE_OPEN).removesuffix(_PRE_CLOSE))
+            for p in code_parts
+        )
+        assert len(code_parts) > 1
+        assert restored.replace("\n", "") == code.replace("\n", "")
+
 
 class TestGetNextCards:
     def test_returns_requested_count(self):

From 445750fbe0d2bc6bff530b4aaac5c47b29448cbb Mon Sep 17 00:00:00 2001
From: Wlwool 
Date: Mon, 5 Oct 2026 11:46:42 +0300
Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=D1=80=D0=B5=D1=82=D1=80=D0=B0=D0=B9?=
 =?UTF-8?q?=20Discord=20=D0=BF=D1=80=D0=B8=20429=20=D0=BE=D0=B3=D1=80?=
 =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=20=5FMAX=5FATTEMPTS=20?=
 =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=D0=BC=D0=B8,=20retry?=
 =?UTF-8?q?=5Fafter=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?=
 =?UTF-8?q?=D0=B1=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 bot/discord_sender.py            | 62 +++++++++++++++---------
 bot/tests/test_discord_sender.py | 83 ++++++++++++++++++++++++++++++++
 2 files changed, 123 insertions(+), 22 deletions(-)
 create mode 100644 bot/tests/test_discord_sender.py

diff --git a/bot/discord_sender.py b/bot/discord_sender.py
index 68b1fe6..1792015 100644
--- a/bot/discord_sender.py
+++ b/bot/discord_sender.py
@@ -6,6 +6,11 @@
 
 log = logging.getLogger(__name__)
 
+_MAX_ATTEMPTS = 8
+_NETWORK_RETRY_DELAY = 5   # пауза после сетевой ошибки, сек
+_DEFAULT_RETRY_AFTER = 5   # пауза при 429, если Discord не сказал сколько, сек
+_MAX_RETRY_AFTER = 60      # потолок паузы при 429, сек
+
 class DiscordSender:
     def __init__(self, webhook_url: str) -> None:
         if not webhook_url:
@@ -41,32 +46,45 @@ async def send(self, messages: list[str]) -> bool:
         log.info(f"Discord: отправлено {total} чанков ({len(messages)} сообщений)")
         return True
 
-
-    async def _post(self, content: str, _retries: int = 3) -> bool:
-        """Отправляет один кусок текста через webhook."""
+    async def _post(self, content: str) -> bool:
+        """Отправляет один кусок текста через webhook (не более _MAX_ATTEMPTS попыток)."""
         if not self.session or self.session.closed:
             log.error("Discord: сессия не открыта")
             return False
-        try:
-            async with self.session.post(
-                    self.webhook_url, json={"content": content}) as r:
-                if r.status in (200, 204):
-                    return True
-                if r.status == 429:
-                    retry_after = (await r.json()).get("retry_after", 5)
-                    log.warning(f"Discord: rate limit, повтор через {retry_after} сек")
-                    await asyncio.sleep(retry_after)
-                    return await self._post(content)
-                log.error(f"Discord: webhook вернул {r.status}: {await r.text()}")
-                return False
-        except (aiohttp.ClientError, asyncio.TimeoutError) as e:
-            if _retries > 0:
-                log.warning(f"Discord: network error, retry ({_retries}): {e}")
-                await asyncio.sleep(5)
-                return await self._post(content, _retries - 1)
-            log.error(f"Discord: ошибка сети: {e}")
-            return False
 
+        for attempt in range(1, _MAX_ATTEMPTS + 1):
+            delay: float = _NETWORK_RETRY_DELAY
+            try:
+                async with self.session.post(
+                        self.webhook_url, json={"content": content}) as r:
+                    if r.status in (200, 204):
+                        return True
+                    if r.status != 429:
+                        log.error(f"Discord: webhook вернул {r.status}: {await r.text()}")
+                        return False
+                    delay = await _read_retry_after(r)
+                    log.warning(
+                        f"Discord: rate limit, попытка {attempt}/{_MAX_ATTEMPTS}, "
+                        f"повтор через {delay} сек")
+            except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+                log.warning(
+                    f"Discord: ошибка сети, попытка {attempt}/{_MAX_ATTEMPTS}: {e}")
+
+            if attempt < _MAX_ATTEMPTS:
+                await asyncio.sleep(delay)
+
+        log.error(f"Discord: не удалось отправить за {_MAX_ATTEMPTS} попыток")
+        return False
+
+async def _read_retry_after(r) -> float:
+    """Достаёт retry_after из тела ответа 429.
+    При любой проблеме берёт значение по умолчанию.
+    """
+    try:
+        value = float((await r.json()).get("retry_after", _DEFAULT_RETRY_AFTER))
+    except (aiohttp.ClientError, ValueError, TypeError, AttributeError):
+        return _DEFAULT_RETRY_AFTER
+    return min(max(value, 0.0), _MAX_RETRY_AFTER)
 
 def _split_into_chunks(text: str) -> list[str]:
     limit = settings.discord_max_length
diff --git a/bot/tests/test_discord_sender.py b/bot/tests/test_discord_sender.py
new file mode 100644
index 0000000..2c509af
--- /dev/null
+++ b/bot/tests/test_discord_sender.py
@@ -0,0 +1,83 @@
+import asyncio
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from discord_sender import _MAX_ATTEMPTS, DiscordSender
+
+
+class FakeResponse:
+    """Подменяет ответ aiohttp: статус, json() и text()."""
+    def __init__(self, status, body=None, json_error=False):
+        self.status = status
+        self._body = body if body is not None else {}
+        self._json_error = json_error
+
+    async def json(self):
+        if self._json_error:
+            raise ValueError("не JSON")
+        return self._body
+
+    async def text(self):
+        return str(self._body)
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+class FakeSession:
+    """Отдаёт ответы по очереди; последний повторяется бесконечно."""
+    closed = False
+
+    def __init__(self, responses):
+        self._responses = list(responses)
+        self.calls = 0
+
+    def post(self, url, json):
+        self.calls += 1
+        if len(self._responses) > 1:
+            return self._responses.pop(0)
+        return self._responses[0]
+
+
+def make_sender(responses) -> DiscordSender:
+    sender = DiscordSender("https://example.test/webhook")
+    sender.session = FakeSession(responses)
+    return sender
+
+
+@pytest.fixture
+def sleeps(monkeypatch):
+    """Не спит по-настоящему, записывает паузы. Лишние паузы означают бесконечный цикл."""
+    calls = []
+
+    async def fake_sleep(delay):
+        calls.append(delay)
+        if len(calls) > _MAX_ATTEMPTS + 5:
+            raise RuntimeError("бесконечный повтор")
+
+    monkeypatch.setattr(asyncio, "sleep", fake_sleep)
+    return calls
+
+
+class TestPostRateLimit:
+    def test_constant_rate_limit_gives_up(self, sleeps):
+        sender = make_sender([FakeResponse(429, {"retry_after": 1})])
+        assert asyncio.run(sender._post("x")) is False
+        assert sender.session.calls == _MAX_ATTEMPTS
+
+    def test_rate_limit_then_ok(self, sleeps):
+        sender = make_sender([FakeResponse(429, {"retry_after": 2}), FakeResponse(204)])
+        assert asyncio.run(sender._post("x")) is True
+        assert sleeps == [2]
+
+    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]

From 958f1541c8bed6caa8263fb2e731f50c2a99aab7 Mon Sep 17 00:00:00 2001
From: Wlwool 
Date: Mon, 5 Oct 2026 11:53:05 +0300
Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?=
 =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=BE=D1=82=D0=B0=20=D0=BF=D0=B8?=
 =?UTF-8?q?=D1=88=D0=B5=D1=82=D1=81=D1=8F=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0?=
 =?UTF-8?q?=D1=80=D0=BD=D0=BE,=20=D0=BF=D0=BE=D0=B2=D1=80=D0=B5=D0=B6?=
 =?UTF-8?q?=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB?=
 =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BB=D0=BE=D0=BC=D0=B0=D0=B5=D1=82=20=D1=80?=
 =?UTF-8?q?=D0=B0=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 bot/state.py            | 37 ++++++++++++++++++++++-----
 bot/tests/test_state.py | 55 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 86 insertions(+), 6 deletions(-)
 create mode 100644 bot/tests/test_state.py

diff --git a/bot/state.py b/bot/state.py
index 5f03565..88a6301 100644
--- a/bot/state.py
+++ b/bot/state.py
@@ -1,17 +1,42 @@
 import json
+import logging
 import os
 
+log = logging.getLogger(__name__)
+
 STATE_FILE = "data/bot_state.json"
 
 
 def load_last_id() -> int:
+    """Возвращает id последней отправленной карточки.
+    Если файла нет или он повреждён, возвращает 0 (рассылка начнётся сначала).
+    """
     if not os.path.exists(STATE_FILE):
         return 0
-    with open(STATE_FILE) as f:
-        return json.load(f).get("last_id", 0)
+    try:
+        with open(STATE_FILE) as f:
+            data = json.load(f)
+        last_id = data["last_id"]
+    except (OSError, ValueError, KeyError, TypeError) as e:
+        log.warning(f"Файл состояния {STATE_FILE} не прочитан ({e!r}), начинаю с 0")
+        return 0
+    if not isinstance(last_id, int):
+        log.warning(f"В {STATE_FILE} last_id не число ({last_id!r}), начинаю с 0")
+        return 0
+    return last_id
 
 
-def save_last_id(last_id: int):
-    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
-    with open(STATE_FILE, "w") as f:
-        json.dump({"last_id": last_id}, f)
+def save_last_id(last_id: int) -> None:
+    """Записывает состояние атомарно: во временный файл, затем os.replace."""
+    os.makedirs(os.path.dirname(STATE_FILE) or ".", exist_ok=True)
+    tmp_path = f"{STATE_FILE}.tmp"
+    try:
+        with open(tmp_path, "w") as f:
+            json.dump({"last_id": last_id}, f)
+            f.flush()
+            os.fsync(f.fileno())
+        os.replace(tmp_path, STATE_FILE)
+    except BaseException:
+        if os.path.exists(tmp_path):
+            os.remove(tmp_path)
+        raise
diff --git a/bot/tests/test_state.py b/bot/tests/test_state.py
new file mode 100644
index 0000000..b57f735
--- /dev/null
+++ b/bot/tests/test_state.py
@@ -0,0 +1,55 @@
+import json
+import os
+import sys
+
+import pytest
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+import state
+
+
+@pytest.fixture(autouse=True)
+def state_file(tmp_path, monkeypatch):
+    """Каждый тест работает со своим файлом состояния во временном каталоге."""
+    path = str(tmp_path / "data" / "bot_state.json")
+    monkeypatch.setattr(state, "STATE_FILE", path)
+    return path
+
+
+class TestLoadLastId:
+    def test_missing_file_returns_zero(self):
+        assert state.load_last_id() == 0
+
+    @pytest.mark.parametrize(
+        "content",
+        ["", "{broken", "[1, 2]", '{"last_id": "abc"}', "null"],
+    )
+    def test_invalid_file_returns_zero(self, state_file, content):
+        os.makedirs(os.path.dirname(state_file))
+        with open(state_file, "w") as f:
+            f.write(content)
+        assert state.load_last_id() == 0
+
+
+class TestSaveLastId:
+    def test_save_then_load(self):
+        state.save_last_id(7)
+        assert state.load_last_id() == 7
+
+    def test_creates_directory(self, state_file):
+        state.save_last_id(1)
+        assert os.path.exists(state_file)
+
+    def test_failed_write_keeps_previous_state(self, state_file, monkeypatch):
+        state.save_last_id(5)
+
+        def boom(*args, **kwargs):
+            raise OSError("диск переполнен")
+
+        monkeypatch.setattr(state.json, "dump", boom)
+        with pytest.raises(OSError):
+            state.save_last_id(9)
+
+        assert state.load_last_id() == 5
+        assert not os.path.exists(state_file + ".tmp")

From 294de10d314fb7150a43fbeb08959fc6a03316e8 Mon Sep 17 00:00:00 2001
From: Wlwool 
Date: Mon, 5 Oct 2026 12:01:46 +0300
Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20?=
 =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B9=20=D0=BE=D0=B1=D1=80=D0=B0?=
 =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BB=D1=8D=D1=88=20=D0=B2=20?=
 =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20/start?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 bot/main.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/bot/main.py b/bot/main.py
index 338b183..4a8476e 100644
--- a/bot/main.py
+++ b/bot/main.py
@@ -166,7 +166,7 @@ async def cmd_start(message: Message) -> None:
     if message.from_user.id not in settings.admin_ids:
         return
     await message.answer(
-        "Бот для изучения Python по карточкам\\.\n\n"
+        "Бот для изучения Python по карточкам.\n\n"
         "/card — случайная карточка прямо сейчас",
     )