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")