diff --git a/bot/cards.py b/bot/cards.py index 661e159..f456380 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -5,6 +5,10 @@ from config import settings from models import Card +_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, "⚪") @@ -42,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]:
@@ -87,15 +115,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/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/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 — случайная карточка прямо сейчас",
     )
 
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_cards.py b/bot/tests/test_cards.py
index ed7c1c7..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
@@ -7,7 +17,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
 
 
@@ -125,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):
@@ -164,3 +199,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)
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]
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")