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
20 changes: 18 additions & 2 deletions bot/cards.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging
import random
from html import escape

Expand All @@ -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 = '<pre><code class="language-python">'
Expand All @@ -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 = (
Expand Down Expand Up @@ -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"<code>{escape(t)}</code>" for t in tags)
text += f"\n\n🏷 {tags_line}"
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -48,3 +57,4 @@ class Settings:


settings = Settings()
_check_delivery_channels(settings.telegram_enabled, settings.discord_webhook_url)
10 changes: 6 additions & 4 deletions bot/discord_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions bot/tests/test_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from html import unescape
from unittest.mock import MagicMock

import pytest

from cards import (
_PRE_CLOSE,
_PRE_OPEN,
Expand All @@ -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:
"""Создаёт карточку с дефолтными значениями для тестов."""
Expand Down Expand Up @@ -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))
16 changes: 16 additions & 0 deletions bot/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 18 additions & 0 deletions bot/tests/test_discord_sender.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import logging
import os
import sys

Expand Down Expand Up @@ -83,3 +84,20 @@ 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
Loading