Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions bot/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
from config import settings
from models import Card

_CODE_FENCE_OPEN = "```python\n"
_CODE_FENCE_CLOSE = "\n```"
_PRE_OPEN = '<pre><code class="language-python">'
_PRE_CLOSE = "</code></pre>"

def _difficulty_emoji(difficulty: str) -> str:
return {"easy": "🟢", "normal": "🟡", "hard": "🔴"}.get(difficulty, "⚪")
Expand Down Expand Up @@ -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"<pre><code class=\"language-python\">{escape(code_part)}</code></pre>"
)
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 и обёртки <pre><code> сообщение влезло в 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]:
Expand Down Expand Up @@ -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]:
Expand Down
62 changes: 40 additions & 22 deletions bot/discord_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion bot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 — случайная карточка прямо сейчас",
)

Expand Down
37 changes: 31 additions & 6 deletions bot/state.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 43 additions & 1 deletion bot/tests/test_cards.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -125,6 +142,24 @@ def test_special_chars_escaped(self):
assert "&gt;" in full
assert "&amp;" 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):
Expand Down Expand Up @@ -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)
83 changes: 83 additions & 0 deletions bot/tests/test_discord_sender.py
Original file line number Diff line number Diff line change
@@ -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]
Loading
Loading